Răsfoiți Sursa

fix(pty-local): restore the session-resolved workspace policy

The linear replay resurrected the July spawn path: mode folded inline
and both confinement and the default cwd fenced to the deployment
workspaceRoot. Master (and the pre-rebase merge result) resolve the
policy once per spawn — resolve({ session }) — so session.header.cwd
is both the workspace-write boundary and the default shell cwd, as the
README already states. Restores the single resolve, threads the policy
through spawnArgv and the cwd fallback, re-expresses master's
session-root test on the terminal spec, and pins the recorded confine
policy in the explicit-cwd test.

Also from the same review round: the Python SDK exe build stages
pty.node from subprocess-local (node-pty's home since the relocation),
and fs-local's README counts the seam's eleven primitives.
Tianyi Cui 1 lună în urmă
părinte
comite
fbceb8dcd5

+ 2 - 2
packages/fs/fs-local/README.i18n.yaml

@@ -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/fs/fs-local/README.md
-README.md: fe0e5e9dec07fad745d6bea28da9009e517c7d85
-README.zh.md: 195f3963328035e9c6c382dd924cd04ee9e8c642
+README.md: 2e934298ceff75440357b0742770010c8b1c3904
+README.zh.md: 14f4867ce0f9eaae2a1dea98dbd7d401c98d4535

+ 1 - 1
packages/fs/fs-local/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the twelve `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
+The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
 
 ```ts ignore-check
 import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'

+ 1 - 1
packages/fs/fs-local/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
+`ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
 
 ```ts ignore-check
 import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'

+ 9 - 11
packages/pty/pty-local/src/index.ts

@@ -10,7 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
 import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
 import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
-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 { LocalPtySession } from './session.ts'
@@ -68,18 +68,15 @@ function childEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
   }
 }
 
-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
+  if (policy.mode === 'danger-full-access') return argv
   const sandbox = ctx.get('sandbox')
   if (sandbox === undefined) {
-    throw new Error(`pty-local: sandbox mode "${mode}" requires a ctx.sandbox provider in the execution world`)
+    throw new Error(`pty-local: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
   }
-  return sandbox.confine(argv, {
-    mode: mode,
-    workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
-  }).argv
+  // Re-state the discriminant because object spread does not preserve its narrowed type.
+  return sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
 }
 
 // TODO(pty-initialize-race-home): Fold this outer abort race into
@@ -122,11 +119,12 @@ 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)
     if (argv[0] === undefined) throw new Error('pty-local: sandbox returned empty argv')
     const terminal = await this.spawnTerminal({
       argv,
-      cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
+      cwd: spec.cwd ?? policy.workspaceRoot,
       env: childEnvironment(spec),
       rows: this.config.rows,
       cols: this.config.cols,

+ 38 - 2
packages/pty/pty-local/tests/index.spec.ts

@@ -44,9 +44,9 @@ function config(): ResolvedConfig {
   }
 }
 
-function agent(ctx: Context): Agent {
+function agent(ctx: Context, cwd?: string): Agent {
   const id = SessionId('agent')
-  const session = Session.create(id, undefined, { version: 0, id, createdAt: 0 })
+  const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
   return {
     id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
     status: 'idle',
@@ -213,6 +213,42 @@ describe('LocalPtyBackend startup rollback', () => {
     })
     expect(spawned?.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: '/workspace' },
+    }])
+  })
+
+  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: 'read-only', workspaceRoot: '/deployment-fallback' })
+    const terminal = terminalHandle()
+    let spawned: SubprocessTerminalSpawnSpec | undefined
+    const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
+      spawned = spec
+      return terminal
+    }
+    const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
+    const session = { initialize: initialized } as unknown as LocalPtySession
+    const backend = new LocalPtyBackend(
+      ctx,
+      { ...config(), shellArgs: ['-i'] },
+      spawnTerminal,
+      () => session,
+    )
+    const owner = agent(ctx, '/session-workspace')
+    setSandboxMode(owner.session, 'workspace-write')
+    expect(await backend.spawn(spec(owner))).toBe(session)
+
+    expect(spawned).toMatchObject({
+      argv: ['/sandbox', '--', '/bin/bash', '-i'],
+      cwd: '/session-workspace',
+    })
+    expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
+      argv: ['/bin/bash', '-i'],
+      policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' },
+    }])
   })
 
   it('rejects a confined spawn without a sandbox provider', async () => {

+ 1 - 1
scripts/build-exe-for-python-sdk.ts

@@ -326,7 +326,7 @@ class SingleExeBuild {
     if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
     else await rm(stagedBuild, { recursive: true, force: true })
     if (target.platform !== 'linux') return
-    const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
+    const source = join(root, 'packages', 'subprocess', 'subprocess-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
     const destination = join(stagedBuild, 'Release', 'pty.node')
     if (this.cli.dryRun) {
       console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)