|
|
@@ -1,9 +1,9 @@
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import { Context } from 'cordis'
|
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
|
-import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
+import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
-import { join } from 'node:path'
|
|
|
+import { join, resolve } from 'node:path'
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
import SubagentService from '@deepseek-ai/dsh-subagent'
|
|
|
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
|
|
|
@@ -22,8 +22,8 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI
|
|
|
|
|
|
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
|
|
|
|
|
-/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
|
|
|
-const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
|
|
+/** A parent Agent stub. The ACP backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */
|
|
|
+const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
|
|
|
|
|
|
function request(text = 'p', signal = new AbortController().signal) {
|
|
|
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
|
|
@@ -115,6 +115,184 @@ describe('buildChildEnv', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+describe('cwd resolution', () => {
|
|
|
+ it('falls back to the parent session cwd for the child process AND its ACP session', async () => {
|
|
|
+ // realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var),
|
|
|
+ // and the child reports its REAL process.cwd() — compare canonical paths.
|
|
|
+ const workdir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
|
|
|
+ try {
|
|
|
+ const ctx = await setup({ MOCK_ECHO_CWD: '1' })
|
|
|
+ const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent
|
|
|
+ const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
|
|
+ const result = await run.result
|
|
|
+ await run.dispose()
|
|
|
+ // Line 1: where the child process actually ran; line 2: the workspace the
|
|
|
+ // backend announced in `session/new`. Both must be the parent's workspace.
|
|
|
+ expect(text(result.output)).toBe(`${workdir}\n${workdir}`)
|
|
|
+ } finally {
|
|
|
+ rmSync(workdir, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects before spawning when neither config.cwd nor the parent session provides one', async () => {
|
|
|
+ const tmp = mkdtempSync(join(tmpdir(), 'acp-no-cwd-'))
|
|
|
+ const sentinel = join(tmp, 'spawned')
|
|
|
+ try {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ // A command that would create the sentinel if the child were ever spawned.
|
|
|
+ await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
|
|
+ const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
|
|
+ await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
+ .rejects.toThrow('no working directory')
|
|
|
+ // Resolution failed BEFORE the process boundary — nothing was launched.
|
|
|
+ expect(existsSync(sentinel)).toBe(false)
|
|
|
+ } finally {
|
|
|
+ rmSync(tmp, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('prefers the configured cwd override to the parent session cwd', async () => {
|
|
|
+ const configured = realpathSync(mkdtempSync(join(tmpdir(), 'acp-cfg-cwd-')))
|
|
|
+ const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
|
|
|
+ try {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ await ctx.plugin(acp, {
|
|
|
+ providerName: 'acp',
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: configured,
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_ECHO_CWD: '1' },
|
|
|
+ })
|
|
|
+ const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent
|
|
|
+ const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
|
|
+ const result = await run.result
|
|
|
+ await run.dispose()
|
|
|
+ expect(text(result.output)).toBe(`${configured}\n${configured}`)
|
|
|
+ } finally {
|
|
|
+ rmSync(configured, { recursive: true, force: true })
|
|
|
+ rmSync(parentDir, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resolves a relative config cwd against the launch directory at load', async () => {
|
|
|
+ // The child process AND its announced ACP session cwd must both get the
|
|
|
+ // ABSOLUTE form — DSH's own ACP server rejects a relative session cwd, and
|
|
|
+ // deferring resolution to spawn would hide the launch-dir dependency.
|
|
|
+ const relative = 'packages/subagent/subagent-acp'
|
|
|
+ const absolute = resolve(relative)
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ await ctx.plugin(acp, {
|
|
|
+ providerName: 'acp',
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: relative,
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_ECHO_CWD: '1' },
|
|
|
+ })
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ await run.dispose()
|
|
|
+ expect(text(result.output)).toBe(`${realpathSync(absolute)}\n${absolute}`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects an empty config cwd at load', async () => {
|
|
|
+ // `path.resolve('')` is the process cwd, so an empty string would silently
|
|
|
+ // reintroduce the launch-directory fallback this resolution removed.
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ await expect(ctx.plugin(acp, {
|
|
|
+ providerName: 'acp',
|
|
|
+ command: 'true',
|
|
|
+ args: [],
|
|
|
+ cwd: '',
|
|
|
+ permission: 'reject',
|
|
|
+ env: {},
|
|
|
+ })).rejects.toThrow('config cwd must not be empty')
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a config cwd directory without search permission at load', async () => {
|
|
|
+ // statSync().isDirectory() is true for a mode-600 directory, but a
|
|
|
+ // subprocess cwd needs SEARCH permission — spawn would fail EACCES.
|
|
|
+ const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
|
|
|
+ chmodSync(tmp, 0o600)
|
|
|
+ try {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ await expect(ctx.plugin(acp, {
|
|
|
+ providerName: 'acp',
|
|
|
+ command: 'true',
|
|
|
+ args: [],
|
|
|
+ cwd: tmp,
|
|
|
+ permission: 'reject',
|
|
|
+ env: {},
|
|
|
+ })).rejects.toThrow('not an accessible directory')
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ } finally {
|
|
|
+ chmodSync(tmp, 0o700)
|
|
|
+ rmSync(tmp, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a config cwd that is not an accessible directory at load', async () => {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ await expect(ctx.plugin(acp, {
|
|
|
+ providerName: 'acp',
|
|
|
+ command: 'true',
|
|
|
+ args: [],
|
|
|
+ cwd: '/nonexistent/acp-child-workspace',
|
|
|
+ permission: 'reject',
|
|
|
+ env: {},
|
|
|
+ })).rejects.toThrow('not an accessible directory')
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a parent session cwd that is not absolute', async () => {
|
|
|
+ // SessionHeader documents cwd as absolute; a relative value here is a broken
|
|
|
+ // header, and resolving it against the server process cwd would silently
|
|
|
+ // re-introduce the launch-directory dependency this resolution removes.
|
|
|
+ const ctx = await setup({})
|
|
|
+ const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
|
|
|
+ await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
+ .rejects.toThrow('must be an absolute path')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a parent session cwd that names a FILE, not a directory', async () => {
|
|
|
+ const tmp = mkdtempSync(join(tmpdir(), 'acp-file-cwd-'))
|
|
|
+ const file = join(tmp, 'a-file')
|
|
|
+ writeFileSync(file, 'x')
|
|
|
+ try {
|
|
|
+ const ctx = await setup({})
|
|
|
+ const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
|
|
|
+ await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
+ .rejects.toThrow('not an accessible directory')
|
|
|
+ } finally {
|
|
|
+ rmSync(tmp, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a parent session cwd that is not an accessible directory, before spawning', async () => {
|
|
|
+ const tmp = mkdtempSync(join(tmpdir(), 'acp-bad-parent-cwd-'))
|
|
|
+ const sentinel = join(tmp, 'spawned')
|
|
|
+ try {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
|
|
+ const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
|
|
|
+ await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
+ .rejects.toThrow('not an accessible directory')
|
|
|
+ expect(existsSync(sentinel)).toBe(false)
|
|
|
+ } finally {
|
|
|
+ rmSync(tmp, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
describe('dsh-subagent-acp', () => {
|
|
|
it('drives child processes with parent-unique run ids and returns streamed output', async () => {
|
|
|
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' })
|