mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge origin/master: web permission sandbox, default pi-ai providers
This commit is contained in:
@@ -24,17 +24,33 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
|
||||
expect(parse([])).toEqual({ mode: 'tui' })
|
||||
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
// `meta` accepts `--resume` but does not redeclare it: a shared option parses
|
||||
// into program.opts() on either side of the subcommand, and redeclaring it
|
||||
// would leave the subcommand's own options empty and drop the id.
|
||||
expect(parse(['meta'])).toEqual({ mode: 'meta' })
|
||||
expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' })
|
||||
expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' })
|
||||
// Credential setup is option-free: it writes the Harness-home .env, so
|
||||
// there is nothing for a flag to select.
|
||||
// Bare `web` carries no host/port: the shipped cordis.yml owns the default.
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
|
||||
// Host/port are unvalidated pass-throughs (the webserver schema gates them
|
||||
// at boot); the adapter only coerces the port string to a number.
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
// Guided fresh-session entries carry nothing: bare mode discriminant only.
|
||||
expect(parse(['migrate'])).toEqual({ mode: 'migrate' })
|
||||
expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
// `list-sessions` has one option and no workspace filter: the listing is always
|
||||
// global. `ps` is its alias and resolves to the same mode.
|
||||
expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false })
|
||||
expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true })
|
||||
expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true })
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
@@ -55,6 +71,27 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['web', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
|
||||
// Same rule for credential setup: it shares no option with the default
|
||||
// surface, so a leaked flag is a typo, not something to ignore.
|
||||
// `meta` fixes its own config tree and is interactive, so --config/-p are
|
||||
// rejected; an empty id is swallowed downstream exactly as above.
|
||||
expect(exitCode(['meta', '--resume='])).toBe(1)
|
||||
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
|
||||
// `migrate`/`upgrade` take no options: any leaked default-surface flag is a
|
||||
// mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['migrate', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['migrate', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
|
||||
// `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a
|
||||
// mistyped invocation, not a listing with a silently dropped input.
|
||||
expect(exitCode(['ps', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['--resume', 's', 'ps'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
|
||||
@@ -16,19 +17,28 @@ import { describe, expect, it } from 'vitest'
|
||||
* node_modules, so no external consumer is assembled; missing-config fail-loud
|
||||
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
|
||||
* built-bin suite, and interactive TTY behavior is PTY-covered by
|
||||
* examples/tui-agent. Skips before the bin is built.
|
||||
* examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that
|
||||
* boots no agent tree, so the built bin is the whole product path.
|
||||
* Skips before the bin is built.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
|
||||
/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
|
||||
async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin], {
|
||||
/**
|
||||
* Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
|
||||
* + exit code. `env` isolates the Harness home for surfaces that read it.
|
||||
*/
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -45,4 +55,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
describe('dsh list-sessions', () => {
|
||||
let home: string
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) })
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
||||
|
||||
it('reports an empty listing as success, not an error', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stdout.trim()).toBe('no dsh sessions running')
|
||||
expect(stderr).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
it('emits an empty JSON array for machines', async () => {
|
||||
const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(JSON.parse(stdout)).toEqual([])
|
||||
}, 30_000)
|
||||
|
||||
it('runs without a TTY, unlike the TUI surface', async () => {
|
||||
// The listing is read-only and boots no agent tree, so piped stdio — the
|
||||
// launch the TUI refuses — is a supported way to run it.
|
||||
const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).not.toContain('interactive TTYs')
|
||||
}, 30_000)
|
||||
|
||||
it('rejects a leaked default-surface flag instead of listing', async () => {
|
||||
const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home })
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('list-sessions takes none of')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
74
apps/cli/tests/list-sessions.spec.ts
Normal file
74
apps/cli/tests/list-sessions.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building
|
||||
* (newest first, absent-title placeholder) and table alignment without trailing
|
||||
* padding. Every displayed field comes from the record, so there is no log
|
||||
* reading to cover here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts'
|
||||
|
||||
function record(overrides: Partial<SessionRegistryRecord> = {}): SessionRegistryRecord {
|
||||
return {
|
||||
sessionId: SessionId('sess-1'),
|
||||
pid: 4242,
|
||||
cwd: '/work/project',
|
||||
startedAt: 1_000,
|
||||
bootId: BootId('boot-1'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatUptime', () => {
|
||||
it.each([
|
||||
[0, '0s'],
|
||||
[999, '0s'],
|
||||
[12_000, '12s'],
|
||||
[59_999, '59s'],
|
||||
[60_000, '1m'],
|
||||
[3_540_000, '59m'],
|
||||
[3_600_000, '1h'],
|
||||
[8_040_000, '2h14m'],
|
||||
[86_400_000, '1d'],
|
||||
[90_000_000, '1d1h'],
|
||||
])('renders %ims as %s', (ms, expected) => {
|
||||
expect(formatUptime(ms)).toBe(expected)
|
||||
})
|
||||
|
||||
it('never renders a negative duration for a clock that moved backwards', () => {
|
||||
expect(formatUptime(-5_000)).toBe('0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRows', () => {
|
||||
it('orders newest first and marks a missing title', () => {
|
||||
const rows = buildRows([
|
||||
record({ sessionId: SessionId('older'), startedAt: 1_000 }),
|
||||
record({ sessionId: SessionId('newer'), startedAt: 5_000 }),
|
||||
], 65_000)
|
||||
expect(rows.map(row => row[0])).toEqual(['newer', 'older'])
|
||||
expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTable', () => {
|
||||
it('aligns columns and leaves no trailing whitespace', () => {
|
||||
const table = renderTable(buildRows([
|
||||
record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }),
|
||||
record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }),
|
||||
], 1_000))
|
||||
const lines = table.split('\n')
|
||||
expect(lines[0]).toMatch(/^SESSION {18}\s+PID/)
|
||||
for (const line of lines) expect(line).toBe(line.trimEnd())
|
||||
// The header and every row align on the same column starts.
|
||||
const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID')
|
||||
expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? ''))
|
||||
expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? ''))
|
||||
})
|
||||
|
||||
it('renders a header even with no rows, so the columns stay discoverable', () => {
|
||||
expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n')
|
||||
})
|
||||
})
|
||||
20
apps/cli/tests/sessions-root.spec.ts
Normal file
20
apps/cli/tests/sessions-root.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Pins the launcher side of the shared-session-store contract: `dsh` defaults
|
||||
* its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The
|
||||
* plugin side — the slot treated as opaque, explicit config winning, and a
|
||||
* project-local fallback with no globality assumption — is pinned by
|
||||
* `packages/examples/tui-demo/tests/tui-agent.spec.ts`.
|
||||
*/
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { launcherSessionsRoot } from '../src/tui.ts'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('launcherSessionsRoot', () => {
|
||||
it('defaults the boot slot to sessions under DSH_HOME', () => {
|
||||
vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home')
|
||||
expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions')))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user