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:
Huanqi Cao
2026-08-12 00:06:42 +08:00
parent da403d6086
commit 557c21cd6c
9 changed files with 292 additions and 32 deletions
+1 -1
View File
@@ -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
View File
@@ -42,6 +42,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
+44 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../bash/pwsh-local"
},
{
"path": "../../core/agent"
},