diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md index 4e019ec..f79d81a 100644 --- a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -34,7 +34,7 @@ - [x] Bootstrap `using-superpowers`: резолв версии, marker, dedupe, compaction. - [x] `/role`: парсинг, два слоя, инъекция, followup. - [x] `dsh/install.sh`, `mcp.codegraph.yml`, README/PORT. -- [x] `bash dsh/tests/run.sh` — 22 теста зелёные. +- [x] `bash dsh/tests/run.sh` — 25 тестов зелёные. - [x] `bash tests/run.sh` — 630 тестов зелёные (регрессий нет). - [ ] Живая проверка в DSH: маркер, guard на main, `/role`, acceptance superpowers. diff --git a/dsh/PORT.md b/dsh/PORT.md index 3f9eb39..1ddc38f 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -90,10 +90,14 @@ dsh/ mcp.codegraph.yml # optional CodeGraph MCP row ``` -`index.js` is the only file importing DSH packages -(`@deepseek-ai/dsh-llm`, `@deepseek-ai/schemastery`). The pure modules are -testable with plain Node; this is why the port's 22 tests run without a -DSH process. +The bundle imports only Node builtins and files inside this package. It +deliberately does not import `@deepseek-ai/dsh-llm` or +`@deepseek-ai/schemastery`: a local-directory profile install resolves imports +from the profile, not from the DSH installation, so a DSH-internal import fails +at activation with `ERR_MODULE_NOT_FOUND`. `lib/message.js` is a local copy of +the tiny `createUserMessage` constructor, and `Config` was replaced by defaults +normalized in `apply()`. The pure modules are testable with plain Node; this is +why the port's 25 tests run without a DSH process. ### Guard flow @@ -217,6 +221,14 @@ profile was removed; the real `desktop` profile was not touched. - **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and re-run the tests after upgrades. +- **Local profile imports are not resolved from the DSH installation.** The + first install attempt failed at activation with + `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/schemastery'`. The + bundle now imports only Node builtins and local files: `lib/message.js` + replaces `createUserMessage` from `@deepseek-ai/dsh-llm`, and `Config` was + dropped in favor of defaults normalized in `apply()`. Any future DSH service + must be reached through `ctx` (injected service), never through an + `@deepseek-ai/*` import; a package test enforces this. - **`ctx.tools.guard` is synchronous.** The guard resolves cwd through `workingDirectory.get(session)` (sync) and runs the hooks with `execFileSync`. If a future DSH version changes the guard contract or makes diff --git a/dsh/bundle/command-role.js b/dsh/bundle/command-role.js index ac17d21..07c90a9 100644 --- a/dsh/bundle/command-role.js +++ b/dsh/bundle/command-role.js @@ -6,7 +6,7 @@ * the five-line role summary. The command's own result is UI-only. */ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from './lib/message.js' import { buildRoleText, listRoles, parseRole } from './lib/role.js' function currentDirectory(workingDirectory, agent) { diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index e3ed3d1..edbc028 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -11,37 +11,25 @@ * * Everything else (CLAUDE.md, roles, OpenSpec, Gitea, codegraph, project * templates) stays in the repository and is consumed as ordinary files. + * + * The bundle intentionally has no `@deepseek-ai/*` imports: a local-directory + * profile install resolves imports from the profile, not from the DSH + * installation, so only Node builtins and files in this package are safe. */ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { z } from '@deepseek-ai/schemastery' -import { createUserMessage } from '@deepseek-ai/dsh-llm' import { registerRoleCommand } from './command-role.js' import { createGuard } from './lib/guards.js' import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js' +import { createUserMessage } from './lib/message.js' import { DEFAULT_ROLE_NAMES } from './lib/role.js' export const name = 'dsh-harness' export const inject = ['tools', 'workingDirectory'] -export const Config = z.object({ - enabled: z.boolean().default(true), - guards: z.boolean().default(true), - bootstrap: z.boolean().default(true), - bootstrapSubagents: z.boolean().default(false), - roleCommand: z.boolean().default(true), - hooksDir: z.string().default(''), - rolesDir: z.string().default(''), - projectRolesDir: z.string().default(''), - roleNames: z.array(z.string()).default(DEFAULT_ROLE_NAMES), - bootstrapSkillPath: z.string().default(''), - toolMappingPath: z.string().default(''), - bootstrapMaxBytes: z.number().default(65_536), -}) - /** Resolve the hook directory for both a linked checkout and a packed package. */ function resolveHooksDir(bundleDir, configured) { if (configured) return configured diff --git a/dsh/bundle/lib/message.js b/dsh/bundle/lib/message.js new file mode 100644 index 0000000..db9f569 --- /dev/null +++ b/dsh/bundle/lib/message.js @@ -0,0 +1,28 @@ +/** + * Minimal local copy of the DSH user-message constructor. + * + * The bundle must load in a profile whose module resolution does not include + * the DSH installation's internal packages. `createUserMessage` in + * `@deepseek-ai/dsh-llm` is only `{ ...input, role: "user", id: randomUUID() }` + * plus a deep freeze, so keeping a local copy removes the only runtime import + * outside this package. + */ + +import { randomUUID } from 'node:crypto' + +function deepFreeze(value) { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +/** Create an immutable user-role message with a fresh identity. */ +export function createUserMessage(input) { + const message = { + ...structuredClone(input), + role: 'user', + id: randomUUID(), + } + return deepFreeze(message) +} diff --git a/dsh/tests/message.test.mjs b/dsh/tests/message.test.mjs new file mode 100644 index 0000000..4bf1ce5 --- /dev/null +++ b/dsh/tests/message.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createUserMessage } from '../bundle/lib/message.js' + +test('createUserMessage returns a frozen user message with a fresh id', () => { + const message = createUserMessage({ + content: [{ type: 'text', text: 'hello' }], + source: { kind: 'user' }, + }) + + assert.equal(message.role, 'user') + assert.match(message.id, /^[0-9a-f-]{36}$/) + assert.deepEqual(message.content, [{ type: 'text', text: 'hello' }]) + assert.deepEqual(message.source, { kind: 'user' }) + assert.ok(Object.isFrozen(message)) + assert.ok(Object.isFrozen(message.content)) + assert.ok(Object.isFrozen(message.content[0])) +}) + +test('createUserMessage does not reuse an id', () => { + const a = createUserMessage({ content: [], source: { kind: 'user' } }) + const b = createUserMessage({ content: [], source: { kind: 'user' } }) + assert.notEqual(a.id, b.id) +}) diff --git a/dsh/tests/package.test.mjs b/dsh/tests/package.test.mjs index 9dba3f3..dc63957 100644 --- a/dsh/tests/package.test.mjs +++ b/dsh/tests/package.test.mjs @@ -50,3 +50,17 @@ test('index.js registers all three integration points', () => { assert.match(source, /createGuard/) assert.match(source, /prepareBootstrap/) }) + +test('runtime files have no external @deepseek-ai imports', () => { + for (const file of [ + 'index.js', + 'command-role.js', + 'lib/message.js', + 'lib/guards.js', + 'lib/bootstrap.js', + 'lib/role.js', + ]) { + const source = readFileSync(join(bundleDir, file), 'utf8') + assert.doesNotMatch(source, /from ['"]@deepseek-ai\//, `${file} must not import DSH internals`) + } +})