feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes the two gaps in the "brand ids that cross package boundaries" policy and fixes the dependency direction so a capability package never pulls in an unrelated one. - Extract the `Branded<B>` primitive into a new standalone type-only package `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps. dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session, dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a generic execution backend must not couple to the LLM or session vocabulary). - Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id, the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary that casts SessionId -> OwnerToken. - Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters at the config boundary and the inner create()/resume casts disappear (only the genuinely-new per-run session-id string is cast). - Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store Map keys and public params/exports (SessionStore, AgentRegistry + factory options, the ACP session-id surface + ToolPresenter CallId map, the persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps). - Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point the Branded type-equiv at dsh-brand, fix stale param types in the session/ agent/bash READMEs, regenerate the cordis catalog + module graph. Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
This commit is contained in:
@@ -15,8 +15,8 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
@@ -50,7 +50,7 @@ interface TrackedTask extends BashTask {
|
||||
stdoutOffset: number
|
||||
stderrOffset: number
|
||||
/** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
|
||||
owner: string | undefined
|
||||
owner: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
})
|
||||
|
||||
private tasks = new Map<string, TrackedTask>()
|
||||
private tasks = new Map<BashTaskId, TrackedTask>()
|
||||
private nextTaskId = 1
|
||||
/** Test seam: timer/spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
@@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
signal: spec.signal,
|
||||
}, this.internals)
|
||||
|
||||
const id = `bash-${this.nextTaskId++}`
|
||||
const id = BashTaskId(`bash-${this.nextTaskId++}`)
|
||||
const task: TrackedTask = {
|
||||
id,
|
||||
command: spec.command,
|
||||
@@ -176,11 +176,11 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: string): string | undefined {
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
return this.tasks.get(id)?.owner
|
||||
@@ -190,7 +190,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
|
||||
@@ -213,7 +213,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
kill(id: string): boolean {
|
||||
kill(id: BashTaskId): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
|
||||
it('readOutput throws for unknown ids', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.readOutput('nope')).toThrow(/unknown bash task "nope"/)
|
||||
expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
||||
})
|
||||
|
||||
it('kill terminates the process group and reports status killed', async () => {
|
||||
@@ -172,7 +172,7 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(bash.kill(task.id)).toBe(false)
|
||||
expect(() => bash.kill('nope')).toThrow(/unknown bash task "nope"/)
|
||||
expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
||||
})
|
||||
|
||||
it('notifies onTaskDone listeners on completion', async () => {
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
}
|
||||
|
||||
@@ -28,4 +28,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `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.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `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.
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
@@ -86,7 +87,7 @@ export abstract class BashExecutor extends Service {
|
||||
abstract start(spec: BashExecSpec): BashTask
|
||||
|
||||
/** Look up a background task by id. */
|
||||
abstract get(id: string): BashTask | undefined
|
||||
abstract get(id: BashTaskId): BashTask | undefined
|
||||
|
||||
/**
|
||||
* The opaque OWNER token recorded for a background task at {@link start}
|
||||
@@ -101,19 +102,19 @@ export abstract class BashExecutor extends Service {
|
||||
* Storing ownership in the executor (disposed with ITS fiber) — not in the
|
||||
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
|
||||
*/
|
||||
abstract ownerOf(id: string): string | undefined
|
||||
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
|
||||
|
||||
/** All tracked background tasks (insertion order). */
|
||||
abstract list(): BashTask[]
|
||||
|
||||
/** Read output produced since the previous read. Throws for unknown ids. */
|
||||
abstract readOutput(id: string): BashTaskRead
|
||||
abstract readOutput(id: BashTaskId): BashTaskRead
|
||||
|
||||
/**
|
||||
* Kill a running background task. Returns false when it had already
|
||||
* finished (no-op). Throws for unknown ids.
|
||||
*/
|
||||
abstract kill(id: string): boolean
|
||||
abstract kill(id: BashTaskId): boolean
|
||||
|
||||
/**
|
||||
* Register a background-task completion listener (disposed with the
|
||||
|
||||
@@ -6,6 +6,31 @@
|
||||
* @module dsh-bash/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Identifies one background task within an executor (generated `bash-N`). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
|
||||
/** Brand a string as a {@link BashTaskId}. */
|
||||
export function BashTaskId(id: string): BashTaskId {
|
||||
return id as BashTaskId
|
||||
}
|
||||
|
||||
/**
|
||||
* A background task's opaque isolation key — the CONSUMER's owner identity, not
|
||||
* the bash seam's. The executor stores and returns it verbatim and never
|
||||
* interprets it; the access policy lives in the consumer (`dsh-tool-bash`),
|
||||
* which is the single boundary that casts its own id vocabulary into one. A
|
||||
* DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled — a
|
||||
* sandboxed/remote executor inherits no session dependency.
|
||||
*/
|
||||
export type OwnerToken = Branded<'OwnerToken'>
|
||||
|
||||
/** Brand a string as an {@link OwnerToken}. */
|
||||
export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
|
||||
* filled by {@link BashExecutor.resolve} from the implementation's config.
|
||||
@@ -28,7 +53,7 @@ export interface BashExecRequest {
|
||||
* seam — that is the consumer's job). Absent for foreground runs and for an
|
||||
* ownerless background start (a non-agent caller).
|
||||
*/
|
||||
owner?: string | undefined
|
||||
owner?: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +78,7 @@ export interface BashExecSpec {
|
||||
* silently-absent property that yields an unowned (cross-session-readable)
|
||||
* task. `start()` stores it; `run()` (foreground) ignores it.
|
||||
*/
|
||||
owner: string | undefined
|
||||
owner: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
@@ -87,7 +112,7 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed'
|
||||
|
||||
/** A tracked background task handle. */
|
||||
export interface BashTask {
|
||||
readonly id: string
|
||||
readonly id: BashTaskId
|
||||
readonly command: string
|
||||
status: BashTaskStatus
|
||||
/** Exit code once finished (null = killed by signal / still running). */
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/** Minimal concrete executor: records calls, lets tests drive completions. */
|
||||
class StubExecutor extends BashExecutor {
|
||||
tasks = new Map<string, BashTask>()
|
||||
private owners = new Map<string, string | undefined>()
|
||||
tasks = new Map<BashTaskId, BashTask>()
|
||||
private owners = new Map<BashTaskId, OwnerToken | undefined>()
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
@@ -32,7 +32,7 @@ class StubExecutor extends BashExecutor {
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
const task: BashTask = {
|
||||
id: `stub-${this.tasks.size + 1}`,
|
||||
id: BashTaskId(`stub-${this.tasks.size + 1}`),
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
@@ -44,11 +44,11 @@ class StubExecutor extends BashExecutor {
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: string): string | undefined {
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
return this.owners.get(id)
|
||||
}
|
||||
|
||||
@@ -56,13 +56,13 @@ class StubExecutor extends BashExecutor {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task, delta: '', lossy: false }
|
||||
}
|
||||
|
||||
kill(id: string): boolean {
|
||||
kill(id: BashTaskId): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
@@ -79,11 +80,11 @@ function validateBashArgs(args: {
|
||||
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
*/
|
||||
function validateTaskId(value: string): string {
|
||||
function validateTaskId(value: string): BashTaskId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return value
|
||||
return BashTaskId(value)
|
||||
}
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
@@ -279,7 +280,8 @@ export function apply(ctx: Context): void {
|
||||
* the conventions flag. The two are equal in production, but the header is the
|
||||
* canonical identity.
|
||||
*/
|
||||
const callerToken = (exec: { agent?: Agent }): string | undefined => exec.agent?.session.header.id
|
||||
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
|
||||
exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
|
||||
|
||||
/**
|
||||
* Authorize a `bash_output`/`bash_kill` call against the task's stored owner
|
||||
@@ -291,7 +293,7 @@ export function apply(ctx: Context): void {
|
||||
* `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
|
||||
* (`callerToken` undefined) cannot match an owned task and is rejected.
|
||||
*/
|
||||
const assertTaskAccess = (taskId: string, exec: { agent?: Agent }): void => {
|
||||
const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
|
||||
const owner = ctx.bash.ownerOf(taskId)
|
||||
if (owner !== undefined && owner !== callerToken(exec)) {
|
||||
throw new Error(`task ${taskId} belongs to another session`)
|
||||
@@ -310,7 +312,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.bash.onTaskDone((task) => {
|
||||
const ownerToken = ctx.bash.ownerOf(task.id)
|
||||
if (ownerToken === undefined) return
|
||||
const agent = ctx.get('agents')?.list().find(a => a.session.header.id === ownerToken)
|
||||
const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken)
|
||||
if (!agent) return
|
||||
try {
|
||||
agent.inject(
|
||||
|
||||
@@ -5,9 +5,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -73,7 +74,7 @@ describe('bash tool through the agent loop', () => {
|
||||
textResponse('The command printed integration-ok.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('it-fg', { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -105,7 +106,7 @@ describe('bash tool through the agent loop', () => {
|
||||
textResponse('It failed with code 9.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('it-exit', { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run exit 9' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -126,7 +127,7 @@ describe('bash tool through the agent loop', () => {
|
||||
let taskId = ''
|
||||
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('it-bg', { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
|
||||
|
||||
// Intercept the first tool result to capture the generated task id, then
|
||||
// rewrite the second scripted call's arguments to use it.
|
||||
@@ -147,7 +148,7 @@ describe('bash tool through the agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Wait for the background task itself (completion may race turn end).
|
||||
const task = ctx.bash.get(taskId)
|
||||
const task = ctx.bash.get(BashTaskId(taskId))
|
||||
if (!task) throw new Error(`task ${taskId} not registered`)
|
||||
await task.done
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
|
||||
class LossyReadBashExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: 'bash-lossy',
|
||||
id: BashTaskId('bash-lossy'),
|
||||
command: 'fake',
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
@@ -94,11 +94,11 @@ class LossyReadBashExecutor extends BashExecutor {
|
||||
return this.task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return id === this.task.id ? this.task : undefined
|
||||
}
|
||||
|
||||
ownerOf(): string | undefined {
|
||||
ownerOf(): OwnerToken | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ class LossyReadBashExecutor extends BashExecutor {
|
||||
return [this.task]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task: this.task, delta: 'tail', lossy: true }
|
||||
}
|
||||
@@ -279,7 +279,7 @@ describe('background tools', () => {
|
||||
it('bash_output polls incrementally and reports status', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
const first = await call(ctx, 'bash_output', { task_id: id })
|
||||
@@ -305,7 +305,7 @@ describe('background tools', () => {
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
|
||||
@@ -325,7 +325,7 @@ describe('background tools', () => {
|
||||
it('bash_kill stops a running task; repeat reports already-finished', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
const killed = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(killed)).toBe(`killed background task ${id}`)
|
||||
@@ -372,7 +372,7 @@ describe('background tools', () => {
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
@@ -395,7 +395,7 @@ describe('background tools', () => {
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -414,7 +414,7 @@ describe('background tools', () => {
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
// notifyTaskDone caught and logged the rethrown error.
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
@@ -440,7 +440,7 @@ describe('background tools', () => {
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Unregister the agent BEFORE the task completes (simulate disconnect).
|
||||
unregisterFakeAgents(ctx)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
@@ -450,7 +450,7 @@ describe('background tools', () => {
|
||||
it('does not notify when no agent owned the task', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -474,7 +474,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const b = fakeAgent('sess-b')
|
||||
// Agent A starts a long-running background task.
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
// Agent B (a different session token) cannot read or kill A's task.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
@@ -498,7 +498,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const a1 = fakeAgent('sess-shared')
|
||||
const a2 = fakeAgent('sess-shared') // distinct object, same token
|
||||
const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id })
|
||||
expect(readByA2.isError).toBe(false)
|
||||
await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup
|
||||
@@ -508,7 +508,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// A call with no exec.agent has no token → cannot prove ownership of an owned task.
|
||||
const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
|
||||
expect(read.isError).toBe(true)
|
||||
@@ -520,7 +520,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const ctx = await setup()
|
||||
// Started by a non-loop caller (no exec.agent) → no owner token recorded.
|
||||
const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Any agent (and the no-agent caller) may read/kill it.
|
||||
const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id })
|
||||
expect(read.isError).toBe(false)
|
||||
@@ -533,7 +533,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
// Completion does NOT clear ownership: B is still rejected, A still allowed.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
@@ -559,7 +559,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Before reload: B is rejected (A owns it).
|
||||
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
|
||||
|
||||
@@ -674,7 +674,7 @@ describe('status lines', () => {
|
||||
it('reports kills without a recorded signal (executor raced process exit)', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const task = ctx.bash.get(id)!
|
||||
|
||||
await call(ctx, 'bash_kill', { task_id: id })
|
||||
@@ -688,7 +688,7 @@ describe('status lines', () => {
|
||||
it('reports completed tasks with a null exit code as exit 0', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const task = ctx.bash.get(id)!
|
||||
await task.done
|
||||
// Defensive: completed tasks always carry an exit code in practice; the
|
||||
|
||||
Reference in New Issue
Block a user