mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(pty-local): resolve the owner workspace policy
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md
|
||||
README.md: 6f243a6edf3ab8bc228cfda3f6b3b774dabadcbb
|
||||
README.zh.md: 0cf2981332fa41dab22a496de5ae302066fbdba8
|
||||
README.md: de17c1e56108726daf4009492012caaa79155eca
|
||||
README.zh.md: 95d8350716da685381333187d534d696873db605
|
||||
|
||||
@@ -6,7 +6,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo
|
||||
|
||||
## Plugin (`pty-local`)
|
||||
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. A foreground group's stdin wait that already existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 插件(`pty-local`)
|
||||
|
||||
该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
|
||||
该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。Spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
|
||||
|
||||
Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表 dispose(资源释放)时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { createProcessInspector } from './process-inspector.ts'
|
||||
@@ -71,14 +71,10 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
}
|
||||
}
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
|
||||
if (mode === 'danger-full-access') return argv
|
||||
return ctx.sandbox.confine(argv, {
|
||||
mode: mode,
|
||||
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
|
||||
}).argv
|
||||
if (policy.mode === 'danger-full-access') return argv
|
||||
return ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
|
||||
}
|
||||
|
||||
/** Local shell backend registered under the configured type. */
|
||||
@@ -102,14 +98,15 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
spec.signal?.throwIfAborted()
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
|
||||
const argv = spawnArgv(this.ctx, this.config, policy)
|
||||
const file = argv[0]
|
||||
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
const options: IPtyForkOptions = {
|
||||
name: 'dumb',
|
||||
cols: this.config.cols,
|
||||
rows: this.config.rows,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
cwd: spec.cwd ?? policy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
}
|
||||
const terminal = this.spawnTerminal(file, argv.slice(1), options)
|
||||
|
||||
@@ -38,10 +38,13 @@ function config(): ResolvedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context): Agent {
|
||||
function agent(ctx: Context, cwd?: string): Agent {
|
||||
const id = SessionId('agent')
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
|
||||
status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
@@ -125,10 +128,10 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
} satisfies Partial<PtyBackendCleanupError>))
|
||||
})
|
||||
|
||||
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
|
||||
it('resolves session mode and root together before wrapping the shell', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
|
||||
const terminal = {} as IPty
|
||||
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
|
||||
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
|
||||
@@ -146,8 +149,10 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
)
|
||||
const previous = process.env.PTY_TEST_SECRET
|
||||
process.env.PTY_TEST_SECRET = 'must-not-leak'
|
||||
const owner = agent(ctx, '/session-workspace')
|
||||
setSandboxMode(owner.session, 'workspace-write')
|
||||
try {
|
||||
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
|
||||
expect(await backend.spawn(spec(owner))).toBe(session)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PTY_TEST_SECRET
|
||||
else process.env.PTY_TEST_SECRET = previous
|
||||
@@ -157,7 +162,7 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
file: '/sandbox',
|
||||
args: ['--', '/bin/bash', '-i'],
|
||||
options: {
|
||||
name: 'dumb', cols: 80, rows: 24, cwd: '/work',
|
||||
name: 'dumb', cols: 80, rows: 24, cwd: '/session-workspace',
|
||||
env: {
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
@@ -166,6 +171,10 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
})
|
||||
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
|
||||
argv: ['/bin/bash', '-i'],
|
||||
policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('composes the default local session around a spawned terminal', async () => {
|
||||
|
||||
Reference in New Issue
Block a user