Răsfoiți Sursa

feat(pty-local): shell dialect for Windows pwsh sessions

Adds shellDialect ('bash' | 'pwsh') to the local PTY backend. The
effective shellPath/shellArgs resolve per dialect (pwsh through the
shared dsh-pwsh-local resolver, bash defaults unchanged), the child
environment drops bash-only PS1/PROMPT_COMMAND markers and adds
NO_COLOR for pwsh, and pwsh startup bootstraps the prompt function that
emits the shared OSC 133;D + BEL marker, waiting (across follow-up
sends) until the controlled prompt is actually visible so the
banner-to-prompt gap cannot settle startup early. Bash behavior is
byte-identical; the real-pwsh suite exercises persistent state and
secret scrubbing on Windows.
Huanqi Cao 2 luni în urmă
părinte
comite
557c21cd6c

+ 1 - 1
packages/e2b/e2b/tests/composition.e2e.ts

@@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
         whenIdle: () => Promise.resolve(),
       }
       const backend = new LocalPtyBackend(ctx, {
-        backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
+        backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
         rows: 24, cols: 80,
         scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
         pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,

+ 1 - 0
packages/pty/pty-local/package.json

@@ -42,6 +42,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {

+ 44 - 10
packages/pty/pty-local/src/config.ts

@@ -1,14 +1,20 @@
 /** Validated configuration for the local PTY backend. */
 
 import z from '@deepseek-ai/schemastery'
+import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
+
+/** One supported interactive shell dialect. */
+export type ShellDialect = 'bash' | 'pwsh'
 
 /** Public plugin configuration. */
 export interface Config {
   /** Backend registry type (default: `shell`). */
   backendType?: string
-  /** Interactive shell executable (default: `/bin/bash`). */
+  /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
+  shellDialect?: ShellDialect
+  /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
   shellPath?: string
-  /** Shell arguments (default: `--noprofile --norc -i`). */
+  /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
   shellArgs?: string[]
   /** Terminal rows. */
   rows?: number
@@ -37,14 +43,43 @@ export interface Config {
   disposeGraceMs?: number
 }
 
-/** Configuration after Schemastery defaults. */
-export type ResolvedConfig = Required<Config>
+/** Configuration after Schemastery defaults and dialect resolution. */
+export type ResolvedConfig = Omit<Required<Config>, 'shellDialect' | 'shellPath' | 'shellArgs'> & {
+  shellDialect: ShellDialect
+  shellPath: string
+  shellArgs: string[]
+}
+
+/** Bash dialect default executable. */
+export const DEFAULT_BASH_SHELL = '/bin/bash'
+/** Bash dialect default arguments (interactive, profile-free). */
+export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i']
+/** Pwsh dialect default arguments (interactive host, profile-free). */
+export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile']
+
+/**
+ * Resolve the effective per-dialect shell specification. Defaulting is this
+ * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's
+ * defaults, while an explicit value always wins.
+ * @param config - Schemastery-resolved plugin configuration.
+ * @returns the fully resolved configuration.
+ */
+export function resolveConfig(config: Config): ResolvedConfig {
+  const shellDialect = config.shellDialect ?? 'bash'
+  return {
+    ...(config as Required<Config>),
+    shellDialect,
+    shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL),
+    shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS),
+  }
+}
 
 /** Schemastery config exposed by the plugin. */
 export const Config: z<Config> = z.object({
   backendType: z.string().default('shell'),
-  shellPath: z.string().default('/bin/bash'),
-  shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
+  shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'),
+  shellPath: z.string().required(false),
+  shellArgs: z.array(z.string()).required(false),
   rows: z.number().default(40),
   cols: z.number().default(160),
   scrollbackLines: z.number().default(10_000),
@@ -59,12 +94,11 @@ export const Config: z<Config> = z.object({
 })
 
 /**
- * Assert every numeric config field is a positive safe integer and bounds compose.
+ * Assert every effective numeric config field is a positive safe integer and bounds compose.
  * @param config - Schemastery-resolved plugin configuration.
- * @returns Narrows the input to the fully resolved configuration.
  */
-export function validateConfig(config: Config): asserts config is ResolvedConfig {
-  const resolved = config as ResolvedConfig
+export function validateConfig(config: Config): void {
+  const resolved = resolveConfig(config)
   if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty')
   if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty')
   for (const [name, value] of Object.entries(resolved)) {

+ 62 - 12
packages/pty/pty-local/src/index.ts

@@ -12,7 +12,7 @@ import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
 import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 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 { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts'
 import { LocalPtySession } from './session.ts'
 import { CONTROLLED_PROMPT } from './sanitize.ts'
 
@@ -52,22 +52,39 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
   }, { global: true })
 }
 
-function childEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
+function childEnvironment(spec: PtyBackendSpawnSpec, dialect: ShellDialect): Record<string, string> {
   // The subprocess provider supplies its own scrubbed ambient base; these are
   // deliberate terminal-specific overrides layered after it.
-  return {
+  const common = {
     TERM: 'dumb',
     PAGER: 'cat',
     GIT_PAGER: 'cat',
-    PS1: CONTROLLED_PROMPT,
-    PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
-    BASH_SILENCE_DEPRECATION_WARNING: '1',
     DSH_SHELL: '1',
     DSH_SESSION_ID: spec.owner.id,
     DSH_PTY_SESSION_ID: spec.sessionId,
   }
+  if (dialect === 'pwsh') {
+    // pwsh ignores PS1/PROMPT_COMMAND; its prompt is installed by the startup
+    // bootstrap instead, and NO_COLOR keeps the renderer quiet.
+    return { ...common, NO_COLOR: '1' }
+  }
+  return {
+    ...common,
+    PS1: CONTROLLED_PROMPT,
+    PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
+    BASH_SILENCE_DEPRECATION_WARNING: '1',
+  }
 }
 
+/**
+ * The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker
+ * before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7`
+ * build the control bytes at runtime because raw ESC characters in submitted
+ * input are unreliable under PSReadLine.
+ */
+export const PWSH_PROMPT_SETUP =
+  "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }"
+
 function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
   const argv = [config.shellPath, ...config.shellArgs]
   if (policy.mode === 'danger-full-access') return argv
@@ -82,9 +99,42 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio
 // TODO(pty-initialize-race-home): Fold this outer abort race into
 // LocalPtySession.initialize when the send-state consolidation lands; the
 // session already owns the send lifecycle the race protects.
-async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
+async function startupSession(
+  session: LocalPtySession,
+  dialect: ShellDialect,
+  signal?: AbortSignal,
+): Promise<void> {
+  const start = async (): Promise<void> => {
+    if (dialect === 'bash') {
+      await session.initialize(signal)
+      return
+    }
+    // pwsh cannot install its prompt from the environment: write the prompt
+    // function through the session and wait for the first marker prompt,
+    // which is also the readiness contract of the bash initialize path. The
+    // banner-to-prompt gap can outlast the silence bound, so the wait loops
+    // over follow-up sends until the controlled prompt is actually visible
+    // (in the viewport or the retained scrollback when it landed between
+    // sends), bounded by the send deadline.
+    let viewport = ''
+    for (;;) {
+      const first = viewport.length === 0
+      const operation = session.startSend({
+        text: first ? PWSH_PROMPT_SETUP : '',
+        submit: first,
+        ...signal !== undefined ? { signal } : {},
+      })
+      const result = await operation.done
+      if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
+      if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
+      viewport = result.viewport
+      const scrollback = session.read({ offset: 0, count: 20 }).text
+      if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break
+    }
+    session.motd = viewport
+  }
   if (signal === undefined) {
-    await session.initialize(signal)
+    await start()
     return
   }
   const aborted = Promise.withResolvers<never>()
@@ -92,7 +142,7 @@ async function initializeSession(session: LocalPtySession, signal?: AbortSignal)
   signal.addEventListener('abort', onAbort, { once: true })
   try {
     signal.throwIfAborted()
-    await Promise.race([session.initialize(signal), aborted.promise])
+    await Promise.race([start(), aborted.promise])
   } finally {
     signal.removeEventListener('abort', onAbort)
   }
@@ -125,7 +175,7 @@ export class LocalPtyBackend implements PtyBackend {
     const terminal = await this.spawnTerminal({
       argv,
       cwd: spec.cwd ?? policy.workspaceRoot,
-      env: childEnvironment(spec),
+      env: childEnvironment(spec, this.config.shellDialect),
       rows: this.config.rows,
       cols: this.config.cols,
       graceMs: this.config.disposeGraceMs,
@@ -133,7 +183,7 @@ export class LocalPtyBackend implements PtyBackend {
     })
     const session = this.createSession(terminal, this.config)
     try {
-      await initializeSession(session, spec.signal)
+      await startupSession(session, this.config.shellDialect, spec.signal)
       return session
     } catch (error) {
       try {
@@ -149,5 +199,5 @@ export class LocalPtyBackend implements PtyBackend {
 /** Register the local PTY backend. */
 export function apply(ctx: Context, config: Config): void {
   validateConfig(config)
-  ctx.pty.registerBackend(new LocalPtyBackend(ctx, config))
+  ctx.pty.registerBackend(new LocalPtyBackend(ctx, resolveConfig(config)))
 }

+ 31 - 2
packages/pty/pty-local/tests/config.spec.ts

@@ -1,10 +1,10 @@
 import { describe, expect, it } from 'vitest'
 import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts'
-import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
+import { resolveConfig, validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
 
 function config(overrides: Partial<Config> = {}): Config {
   return {
-    backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
+    backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
     scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
     pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000,
     disposeGraceMs: 100,
@@ -30,3 +30,32 @@ describe('pty-local config', () => {
     expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
   })
 })
+
+describe('pty-local dialect resolution', () => {
+  it('defaults bash argv to the interactive profile-free form', () => {
+    const { shellPath, shellArgs, shellDialect } = resolveConfig({ backendType: 'shell', rows: 24, cols: 80 })
+    expect(shellDialect).toBe('bash')
+    expect(shellPath).toBe('/bin/bash')
+    expect(shellArgs).toEqual(['--noprofile', '--norc', '-i'])
+  })
+
+  it('defaults pwsh argv to the interactive profile-free form and resolves the executable', () => {
+    const resolved = resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })
+    expect(resolved.shellDialect).toBe('pwsh')
+    expect(resolved.shellPath.length).toBeGreaterThan(0)
+    expect(resolved.shellArgs).toEqual(['-NoLogo', '-NoProfile'])
+  })
+
+  it('lets an explicit shell specification win over the dialect defaults', () => {
+    const resolved = resolveConfig({
+      backendType: 'shell', shellDialect: 'pwsh', shellPath: '/custom/pwsh', shellArgs: ['-NoProfile'], rows: 24, cols: 80,
+    })
+    expect(resolved.shellPath).toBe('/custom/pwsh')
+    expect(resolved.shellArgs).toEqual(['-NoProfile'])
+  })
+
+  it('validates the effective shell path, not only the raw one', () => {
+    expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 }) }).not.toThrow()
+    expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) }).not.toThrow()
+  })
+})

+ 100 - 5
packages/pty/pty-local/tests/index.spec.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it, vi } from 'vitest'
 import { PassThrough } from 'node:stream'
+import { resolve } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -8,7 +9,8 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox'
 import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
 import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
 import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
-import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
+import type { PtySendRequest, PtyWaitReason } from '@deepseek-ai/dsh-pty'
+import { LocalPtyBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-pty-local'
 import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
 import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
 import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
@@ -37,7 +39,7 @@ class RecordingSandbox extends SandboxProvider {
 
 function config(): ResolvedConfig {
   return {
-    backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
+    backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
     scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
     pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
     disposeGraceMs: 10,
@@ -215,7 +217,7 @@ describe('LocalPtyBackend startup rollback', () => {
     expect(initialized).toHaveBeenCalledWith(undefined)
     expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
       argv: ['/bin/bash', '-i'],
-      policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' },
+      policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/workspace') },
     }])
   })
 
@@ -243,11 +245,11 @@ describe('LocalPtyBackend startup rollback', () => {
 
     expect(spawned).toMatchObject({
       argv: ['/sandbox', '--', '/bin/bash', '-i'],
-      cwd: '/session-workspace',
+      cwd: resolve('/session-workspace'),
     })
     expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
       argv: ['/bin/bash', '-i'],
-      policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' },
+      policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/session-workspace') },
     }])
   })
 
@@ -337,6 +339,99 @@ describe('LocalPtyBackend startup rollback', () => {
     expect(session.motd).toBe('dsh> ')
     await session.close('test complete')
   })
+
+  it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => {
+    const ctx = new Context()
+    await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
+    let spawned: SubprocessTerminalSpawnSpec | undefined
+    let sent: PtySendRequest | undefined
+    const session = {
+      motd: '',
+      startSend: (request: PtySendRequest) => {
+        sent = request
+        return {
+          done: Promise.resolve({
+            viewport: 'setup-echo dsh> ', waitReason: 'stdin_read' as const,
+            sessionStatus: { kind: 'running' as const }, truncated: false,
+          }),
+          readOutput: () => ({ delta: '', truncated: false }),
+          cancel: () => false,
+        }
+      },
+      read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
+    } as unknown as LocalPtySession
+    const backend = new LocalPtyBackend(
+      ctx,
+      { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' },
+      async (spec) => { spawned = spec; return terminalHandle() },
+      () => session,
+    )
+    expect(await backend.spawn(spec(agent(ctx)))).toBe(session)
+    expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true })
+    expect(session.motd).toBe('setup-echo dsh> ')
+    expect(spawned?.env).toMatchObject({
+      TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
+    })
+    expect(spawned?.env?.PS1).toBeUndefined()
+    expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined()
+  })
+
+  it('keeps waiting for the marker prompt when the first send settles on silence', async () => {
+    const ctx = new Context()
+    await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
+    const sends: PtySendRequest[] = []
+    const session = {
+      motd: '',
+      startSend: (request: PtySendRequest) => {
+        sends.push(request)
+        const second = sends.length > 1
+        return {
+          done: Promise.resolve({
+            viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n',
+            waitReason: 'inferred_idle' as const,
+            sessionStatus: { kind: 'running' as const }, truncated: false,
+          }),
+          readOutput: () => ({ delta: '', truncated: false }),
+          cancel: () => false,
+        }
+      },
+      read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
+    } as unknown as LocalPtySession
+    const backend = new LocalPtyBackend(
+      ctx,
+      { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' },
+      async () => terminalHandle(),
+      () => session,
+    )
+    await backend.spawn(spec(agent(ctx)))
+    expect(sends).toHaveLength(2)
+    expect(sends[1]).toMatchObject({ text: '', submit: false })
+    expect(session.motd).toBe('dsh> ')
+  })
+
+  it('rejects a pwsh bootstrap whose shell exits or times out', async () => {
+    const ctx = new Context()
+    await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
+    const sessionFor = (waitReason: PtyWaitReason): LocalPtySession => ({
+      startSend: () => ({
+        done: Promise.resolve({
+          viewport: 'no-prompt', waitReason,
+          sessionStatus: { kind: 'running' as const }, truncated: false,
+        }),
+        readOutput: () => ({ delta: '', truncated: false }),
+        cancel: () => false,
+      }),
+      read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
+      close: () => Promise.resolve(),
+    }) as unknown as LocalPtySession
+    const exited = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit'))
+    await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup')
+    const timedOut = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout'))
+    await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout')
+  })
 })
 
 describe('pty-local plugin shape', () => {

+ 49 - 1
packages/pty/pty-local/tests/local.spec.ts

@@ -1,4 +1,5 @@
 import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
@@ -12,6 +13,7 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox'
 import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
 import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
 import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
 
 const roots: string[] = []
@@ -49,6 +51,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
 async function harness(
   mode: 'danger-full-access' | 'workspace-write',
   timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {},
+  dialect: 'bash' | 'pwsh' = 'bash',
 ) {
   const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
   roots.push(root)
@@ -60,6 +63,7 @@ async function harness(
   await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
   await ctx.plugin(LocalSubprocessService)
   const fiber = await ctx.plugin(ptyLocal, {
+    shellDialect: dialect,
     pollIntervalMs: 10,
     exactProbeAfterMs: 20,
     idleSilenceMs: timing.idleSilenceMs ?? 250,
@@ -114,7 +118,9 @@ function processIsRunning(pid: number): boolean {
   }
 }
 
-describe('pty-local real shell', () => {
+// The real-shell suite drives a POSIX bash over the actual node-pty terminal;
+// Windows has no bash, and its pwsh counterpart lives in the describe below.
+describe.skipIf(process.platform === 'win32')('pty-local real shell', () => {
   it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
     const previous = process.env.DSH_TEST_SECRET
     process.env.DSH_TEST_SECRET = 'must-not-leak'
@@ -247,3 +253,45 @@ describe('pty-local real shell', () => {
     await ctx.pty.kill(agent, created.sessionId)
   }, 35_000)
 })
+
+const hasPwsh = spawnSync(
+  resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
+  { encoding: 'utf8' },
+).status === 0
+
+describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => {
+  it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => {
+    const previous = process.env.DSH_TEST_SECRET
+    process.env.DSH_TEST_SECRET = 'must-not-leak'
+    try {
+      const { ctx, root, agent } = await harness('danger-full-access', {
+        idleSilenceMs: 300,
+        handoffGraceMs: 300,
+        timeoutMs: 8_000,
+      }, 'pwsh')
+      const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root })
+      expect(created.motd).toContain('dsh> ')
+
+      const first = ctx.pty.startSend(agent, created.sessionId, {
+        text: '$env:KEEP = "ok"; Set-Location /',
+        submit: true,
+      })
+      expect((await first.done).waitReason).toBe('stdin_read')
+      const second = ctx.pty.startSend(agent, created.sessionId, {
+        text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"',
+        submit: true,
+      })
+      const result = await second.done
+      expect(result.viewport).toContain('keep=ok')
+      expect(result.viewport).toContain('secret=')
+      expect(result.viewport).not.toContain('must-not-leak')
+
+      expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok')
+      expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true)
+      expect(ctx.pty.list(agent)).toEqual([])
+    } finally {
+      if (previous === undefined) delete process.env.DSH_TEST_SECRET
+      else process.env.DSH_TEST_SECRET = previous
+    }
+  }, 30_000)
+})

+ 1 - 1
packages/pty/pty-local/tests/session.spec.ts

@@ -130,7 +130,7 @@ function makeSession(
 
 function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
   return {
-    backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
+    backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
     scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
     pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
     disposeGraceMs: 20,

+ 3 - 0
packages/pty/pty-local/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../../vendor/schemastery"
     },
+    {
+      "path": "../../bash/pwsh-local"
+    },
     {
       "path": "../../core/agent"
     },