Merge origin/master: web permission sandbox, default pi-ai providers

This commit is contained in:
Turtle
2026-07-29 14:29:32 +08:00
parent 42e3cceb64
commit e7c0a5b794
147 changed files with 6770 additions and 195 deletions

View File

@@ -60,7 +60,6 @@
config:
provider: deepseek
model: deepseek-v4-pro
resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536

View File

@@ -0,0 +1,38 @@
# Test-only composition: the model attempts one `write` into a staging-shaped
# git fixture, so the guard's denial is observed through the real Loader and app.
- id: source-guard-mock-llm
name: './mock-llm.ts'
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: fs
name: '@deepseek-ai/dsh-fs-local'
# Read-before-edit policy: without it the write would resolve `createIfAbsent`
# and the transcript would not show the guard as the sole reason for refusal.
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
# Mounts the guard with `protectedCheckout` resolved against the process cwd, so
# it arms for the staging fixture the smoke builds there rather than for the
# checkout running the test (the config default is this module's own location).
- id: source-guard-fixture
name: './mount-guard.ts'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: source-guard-mock
model: source-guard-mock
persona: 'Test the source guard.'
persistenceRoot: './.sessions'
persistenceCompression: none
workspaceContext: false

View File

@@ -0,0 +1,43 @@
import { resolve } from 'node:path'
import type { Context } from 'cordis'
import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */
const TARGET = resolve('staging/guarded.ts')
/**
* Two-step adapter for the source-guard Loader fixture: the first step calls
* `write` on the staged file, the second closes the turn once a tool result has
* come back, so the transcript records what the model received.
*/
class SourceGuardMockAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const alreadyCalled = options.messages.some(message => message.content.some(
block => block.type === 'tool-result',
))
if (alreadyCalled) {
const text = 'denied as expected'
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text }
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'stop' } }
return
}
const callId = CallId('source-guard-write')
const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' })
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args }
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
}
}
export const name = 'source-guard-mock-llm'
export const inject = ['llm']
/** Register the test-only `source-guard-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter())
}

View File

@@ -0,0 +1,14 @@
import { resolve } from 'node:path'
import type { Context } from 'cordis'
import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
export const name = 'source-guard-fixture'
/**
* Mount the real guard against the staging fixture in the process cwd. The
* checkout under protection is a runtime fact of the isolated smoke directory,
* which no static config value can name.
*/
export async function apply(ctx: Context): Promise<void> {
await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') })
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
/** Test driver that sends two turns through one Headless Loader composition. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('tmux-context driver requires a config path')
const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined))
try {
await runOneShot(ctx, { task: 'first' })
await runOneShot(ctx, { task: 'second' })
} finally {
await ctx.fiber.dispose()
}

View File

@@ -0,0 +1,46 @@
import type { Context } from 'cordis'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
/**
* Deterministic `ctx.bash` for the tmux-context Loader fixture: any command
* (the plugin's `tmux display-message`) returns a fixed tab-delimited reading,
* so the injected tmux location is stable without a real tmux server. `start()`
* throws — tmux-context must never spawn a background process.
*/
class TmuxMockBash extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override run(_spec: BashExecSpec): Promise<BashRunResult> {
const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t')
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 60_000,
stdout: { text: `${line}\n`, truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('tmux-context must never start a background task')
}
}
export const name = 'tmux-context-mock-bash'
/** Register the deterministic `ctx.bash` executor for the fixture. */
export function apply(ctx: Context): void {
ctx.plugin(TmuxMockBash)
}

View File

@@ -0,0 +1,22 @@
import type { Context } from 'cordis'
import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
/** Deterministic one-step adapter for the tmux-context Loader fixture. */
class TmuxContextMockAdapter extends LlmAdapter {
async * stream(): AsyncIterable<StreamChunk> {
const text = 'tmux context sampled'
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text }
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'tmux-context-mock-llm'
export const inject = ['llm']
/** Register the test-only `tmux-context-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter())
}

View File

@@ -0,0 +1,21 @@
# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path.
# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location
# is stable without a real tmux server on the test host.
- id: tmux-context-mock-llm
name: './tmux-context-mock-llm.ts'
- id: bash
name: './tmux-context-mock-bash.ts'
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: tmux-context-mock
model: tmux-context-mock
persona: 'Test the tmux-context plugin.'
persistenceRoot: './.sessions'
persistenceCompression: 'none'
workspaceContext: false

View File

@@ -10,6 +10,7 @@
"@deepseek-ai/dsh-acp-demo": "workspace:*",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-bash": "workspace:*",
"@deepseek-ai/dsh-bash-local": "workspace:*",
"@deepseek-ai/dsh-bash-sandbox": "workspace:*",
"@deepseek-ai/dsh-cli-demo": "workspace:*",
@@ -44,6 +45,7 @@
"@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:*",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
"@deepseek-ai/dsh-source-guard": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
@@ -54,6 +56,7 @@
"@deepseek-ai/dsh-tasks-local": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-timeout-policy": "workspace:*",
"@deepseek-ai/dsh-tmux-context": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-cordis": "workspace:*",

View File

@@ -29,7 +29,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
dsh --resume <prior-session-id>
```
`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume <id>` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately.
`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`; a host that cannot hand off in place says so and leaves the session running. Resume needs no key in this file: `dsh` provides the session identity and the exit line on the boot context, so `--resume <id>` and the printed resume command survive any personal-overlay patch of the `tui-agent` entry. With no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately.
## Code Mode

View File

@@ -10,9 +10,7 @@
config:
provider: deepseek
model: deepseek-v4-pro
resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
persistenceRoot: './.sessions'
resumeCommand: 'dsh --resume {session}'
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -59,6 +59,8 @@ flowchart LR
cfg --> plugin_tui_fs_policy
plugin_tui_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"]
cfg --> plugin_tui_tool_fs
plugin_tui_source_guard["source-guard<br/>@deepseek-ai/dsh-source-guard"]
cfg --> plugin_tui_source_guard
plugin_tui_tool_fs_search["tool-fs-search<br/>@deepseek-ai/dsh-tool-fs-search"]
cfg --> plugin_tui_tool_fs_search
plugin_tui_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-timeout-policy"]
@@ -93,6 +95,7 @@ flowchart LR
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |
| `source-guard` | `@deepseek-ai/dsh-source-guard` |
| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |

View File

@@ -47,15 +47,11 @@
config:
provider: deepseek
model: deepseek-v4-pro
# `dsh --resume <id>` provides the session id on the boot context (the ids
# live under ./.sessions); with no flag the identifier is undefined and a
# fresh session starts each run. The typeof guard tolerates a launcher that
# never provides the slot, reading undefined rather than throwing.
resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
persistenceRoot: './.sessions'
# Printed on exit and listed by `/resume`; `{session}` fills the live id.
# `dsh --resume <id>` resumes that session, so run it from this cwd.
resumeCommand: 'dsh --resume {session}'
# Session identity and the resume command printed on exit are launcher-owned:
# `dsh` provides both on the boot context, so `--resume <id>` and the exit
# hint need no key here. `persistenceRoot` is omitted: the dsh launcher
# supplies its shared Harness-home store through a boot slot, and a bare
# example boot falls back to the bundle's project-local `./.sessions`.
workspaceContext:
maxBytes: 65536
ui:
@@ -169,6 +165,15 @@
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
# Refuses write/edit inside the dsh checkout this launcher runs from, on that
# checkout's own branch, until the session loads dsh-customize — the skill whose
# workflow (task worktree, then integrate under the staging lock) the refusal
# points at. Inert everywhere else: another repository, a task worktree nested
# under the protected one, a sibling checkout on a different branch, and any
# workspace outside a dsh source install all pass through untouched.
- id: source-guard
name: '@deepseek-ai/dsh-source-guard'
# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the
# local bash executor above — not ctx.fs. Capped results save the complete
# formatted list through the spill backend below (ctx.spillStore, optional).

View File

@@ -20,6 +20,13 @@ const SKILL_BLOCK_OPEN = '<skill name="scripted-skill">'
const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER'
const SKILL_RECEIVED_TEXT = 'Scripted skill body received.'
const TITLE_TEXT = 'scripted session title'
// The failing-bash scenario proves the terminal card reports a non-zero exit
// exactly once: the model-facing result carries the `[exit code: N]` marker, and
// the card turns it into its own `[exit N]` pill instead of showing both.
const BASH_FAILURE_PROBE = 'Run the failing scripted command.'
const BASH_FAILURE_COMMAND = 'printf "SCRIPTED_BASH_FAILED\\n"; exit 3'
const BASH_FAILURE_TEXT = 'Scripted bash failure observed.'
const BASH_FAILURE_CALL_ID = CallId('call-bash-failure')
function textChunks(text: string): StreamChunk[] {
return [
@@ -108,9 +115,23 @@ class ScriptedTuiAdapter extends LlmAdapter {
return
}
const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false
if (hasToolResult) {
for (const chunk of textChunks(FINAL_TEXT)) yield chunk
const blocks = lastMessage?.content ?? []
if (blocks.some(block => block.type === 'tool-result')) {
const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID)
for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk
return
}
if (lastText.includes(BASH_FAILURE_PROBE)) {
const bashArgs = JSON.stringify({ command: BASH_FAILURE_COMMAND, description: 'Run the failing scripted command' })
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 0, id: BASH_FAILURE_CALL_ID, name: 'bash', argumentsDelta: bashArgs }
yield {
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: BASH_FAILURE_CALL_ID, name: 'bash', arguments: bashArgs },
}
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}

View File

@@ -33,8 +33,6 @@
# The smoke's log inspection reads plain `.jsonl`; keep the scripted
# fixture uncompressed like the other snapshot-facing configs.
persistenceCompression: none
resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
resumeCommand: 'dsh --resume {session}'
workspaceContext:
maxBytes: 65536
welcome: 'scripted TUI ready.'

View File

@@ -365,11 +365,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('must be a top-level YAML array of loader patch entries')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => {
// The flag path end to end: apps/cli parses `--resume missing-session` and
// provides the id on the boot context, the shipped config's `!!js` reads it
// as a bare identifier, and the resume fails loud — proving the printed
// `dsh --resume <id>` hint reaches the config resume intake with no env var.
it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => {
// The flag path end to end: apps/cli parses `--resume missing-session`,
// provides it as the launcher-owned identity on the boot context, and the
// resume fails loud — proving the printed hint reaches the app's resume
// intake with no config key and no environment variable.
const output = await smoke({
label: 'dsh resume flag failure',
tempDirPrefix: 'dsh-resume-flag-',
@@ -380,6 +380,70 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('prints the launcher-owned resume command on exit, naming the booted config', async () => {
// The exit line is built by apps/cli from this invocation, so it must carry
// `--config`: a hint that omitted it would resume into the default tree.
const output = await smoke({
label: 'dsh goodbye message',
tempDirPrefix: 'dsh-goodbye-',
binScript: dshBinScript,
configPath: scriptedConfigPath,
actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }],
})
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps resume working when the personal overlay replaces the whole tui-agent config', async () => {
// Loader patches replace a targeted `config` key wholesale, so a personal
// overlay that omits a resume key used to silently disable the exit hint.
// Launcher-owned identity and exit line make that unreachable.
const output = await smoke({
label: 'dsh overlay keeps resume',
tempDirPrefix: 'dsh-overlay-resume-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
personal: {
'config.yaml': [
'- id: tui-agent',
" name: '@deepseek-ai/dsh-tui-demo'",
' config:',
' provider: deepseek',
' model: deepseek-v4-flash',
' workspaceContext: false',
' welcome: OVERLAY REPLACED THE CONFIG.',
'',
].join('\n'),
},
}),
actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }],
})
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('reports a failing bash command exactly once, as the terminal card exit pill', async () => {
// The model-facing result ends in `[exit code: 3]`, which the terminal card
// consumes into its own `[exit 3]` pill. Rendering both would report the same
// exit twice, so the marker must not survive into the card body.
const output = await smoke({
label: 'tui-agent bash exit pill',
tempDirPrefix: 'dsh-bash-exit-pill-',
configPath: scriptedConfigPath,
actions: [
...SELECT_PRO_MODEL,
{
waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.',
send: 'Run the failing scripted command.\r',
},
{ waitFor: 'Scripted bash failure observed.', send: '/exit\r' },
],
})
// The command really ran: its stdout is in the card body.
expect(output).toContain('SCRIPTED_BASH_FAILED')
expect(output).toContain('[exit 3]')
expect(output).not.toContain('[exit code: 3]')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('tells the model its source path and offers the bundled maintenance skills', async () => {
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
// this test file sits an equal depth under the same root, so the same hop applies.