mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/pwsh-local"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user