fix(dev): make hooks and bash seams safer
This commit is contained in:
@@ -17,12 +17,12 @@ Install dependencies from the repo root:
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency.
|
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.
|
||||||
|
|
||||||
If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
|
If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm exec lefthook install
|
pnpm exec lefthook install --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Run typecheck once after a fresh clone:
|
Run typecheck once after a fresh clone:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts",
|
"demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts",
|
||||||
"demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts",
|
"demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts",
|
||||||
"demo:acp": "node --expose-internals --import tsx examples/acp-agent/start.ts",
|
"demo:acp": "node --expose-internals --import tsx examples/acp-agent/start.ts",
|
||||||
"postinstall": "lefthook install"
|
"postinstall": "node scripts/install-lefthook.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.25.1",
|
"@agentclientprotocol/sdk": "0.25.1",
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export interface Config {
|
|||||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||||
|
|
||||||
|
function assertPositiveFinite(name: string, value: number): void {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new Error(`bash-local: ${name} must be a positive finite number`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TrackedTask extends BashTask {
|
interface TrackedTask extends BashTask {
|
||||||
running: RunningBash
|
running: RunningBash
|
||||||
/** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
|
/** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
|
||||||
@@ -72,6 +78,9 @@ export class LocalBashExecutor extends BashExecutor {
|
|||||||
// schemastery (static Config) has already filled the defaulted fields;
|
// schemastery (static Config) has already filled the defaulted fields;
|
||||||
// the cast records that runtime fact for exactOptionalPropertyTypes.
|
// the cast records that runtime fact for exactOptionalPropertyTypes.
|
||||||
this.config = config as ResolvedConfig
|
this.config = config as ResolvedConfig
|
||||||
|
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||||
|
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||||
|
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||||
ctx.effect(() => async () => {
|
ctx.effect(() => async () => {
|
||||||
// Kill every live process group and WAIT for the processes to close so
|
// Kill every live process group and WAIT for the processes to close so
|
||||||
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
||||||
@@ -98,6 +107,7 @@ export class LocalBashExecutor extends BashExecutor {
|
|||||||
* values and never re-default.
|
* values and never re-default.
|
||||||
*/
|
*/
|
||||||
resolve(request: BashExecRequest): BashExecSpec {
|
resolve(request: BashExecRequest): BashExecSpec {
|
||||||
|
if (request.timeoutMs !== undefined) assertPositiveFinite('request.timeoutMs', request.timeoutMs)
|
||||||
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
|
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
|
||||||
return {
|
return {
|
||||||
command: request.command,
|
command: request.command,
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ describe('LocalBashExecutor.run', () => {
|
|||||||
expect(result.timeoutMs).toBe(2_000)
|
expect(result.timeoutMs).toBe(2_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects invalid numeric config and timeout overrides', async () => {
|
||||||
|
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
||||||
|
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
||||||
|
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||||
|
|
||||||
|
const { bash } = await setup()
|
||||||
|
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||||
|
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
||||||
|
})
|
||||||
|
|
||||||
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
||||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||||
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
|
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
|||||||
|
|
||||||
## Vocabulary
|
## Vocabulary
|
||||||
|
|
||||||
`BashExecSpec` (command, workdir?, timeoutMs?, signal?) → `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
`BashExecRequest` (command, workdir?, timeoutMs?, signal?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?) before execution; `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`);
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `command` | string (required) | Run via `bash -c`. No state persists between calls — use `workdir`, not `cd`. |
|
| `command` | string (required) | Run via `bash -c`. No state persists between calls — use `workdir`, not `cd`. |
|
||||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||||
| `timeoutMs` | number | Default/max from executor config (120s/600s for bash-local). |
|
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
|
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
|
||||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[timed
|
|||||||
|
|
||||||
### `bash_kill`
|
### `bash_kill`
|
||||||
|
|
||||||
`task_id` → SIGTERM→SIGKILL on the task's process group. Killing an already-finished task is a reported no-op; unknown ids are errors.
|
`task_id` → ask the executor to kill the background task. The concrete executor decides how to signal or stop the process; killing an already-finished task is a reported no-op, and unknown ids are errors.
|
||||||
|
|
||||||
### Task ownership (cross-session isolation)
|
### Task ownership (cross-session isolation)
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export function apply(ctx: Context): void {
|
|||||||
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
||||||
+ '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
|
+ '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
|
||||||
},
|
},
|
||||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds (default 120000, max 600000). The command is killed on expiry.' },
|
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||||
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
||||||
},
|
},
|
||||||
@@ -269,7 +269,7 @@ export function apply(ctx: Context): void {
|
|||||||
|
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
name: 'bash_kill',
|
name: 'bash_kill',
|
||||||
description: 'Kill a running background bash task (SIGTERM, then SIGKILL) by task id.',
|
description: 'Ask the executor to kill a running background bash task by task id.',
|
||||||
parameters: {
|
parameters: {
|
||||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||||
},
|
},
|
||||||
|
|||||||
13
scripts/install-lefthook.mjs
Normal file
13
scripts/install-lefthook.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
|
||||||
|
if (git.status !== 0) process.exit(0)
|
||||||
|
|
||||||
|
const lefthook = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
|
||||||
|
if (!existsSync(lefthook)) process.exit(0)
|
||||||
|
|
||||||
|
const result = spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
|
||||||
|
process.exit(result.status ?? 1)
|
||||||
Reference in New Issue
Block a user