diff --git a/dsh/PORT.md b/dsh/PORT.md index a34d04b..e7ac230 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -361,8 +361,9 @@ profile was removed; the real `desktop` profile was not touched. (`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands, `workingDirectory.get`), logs `[dsh-harness] self-check: not-ok: ` for each missing - service, and skips only the guard wiring when `tools.guard` is missing - (bootstrap and `/role` keep working when their own services exist). + service, and skips the feature whose service is missing (guards, bootstrap, + or commands — each gated by its own check), so a DSH version without one of + the services does not crash the bundle. `/dsh-harness-check` re-runs the same check on demand. This is the in-DSH compat check that closes RV-11 (D-6) — the terminal `--check` cannot see the SDK inside `app.asar`. diff --git a/dsh/README.md b/dsh/README.md index b6b438f..3c80e68 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -222,8 +222,10 @@ repository layout: - The bundle self-checks the DSH contract it depends on at activation: `ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands, `workingDirectory.get`. Each missing service is logged as - `[dsh-harness] self-check: not-ok: …`; the guard wiring is the only - feature skipped when its service is missing — bootstrap and `/role` keep - working when their own services exist. The `/dsh-harness-check` command - re-runs the same check on demand (one `ok` / `not-ok` line per feature). - Disable with `selfCheck: false` / `checkCommand: false`. + `[dsh-harness] self-check: not-ok: …`, and the feature whose + service is missing is skipped (guards, bootstrap, or commands — each gated + by its own check), so a DSH version without one of the services does not + crash the bundle; the remaining features keep working. The + `/dsh-harness-check` command re-runs the same check on demand (one + `ok` / `not-ok` line per feature). Disable with `selfCheck: false` / + `checkCommand: false`. diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index b6544b4..af25189 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -105,13 +105,18 @@ export function apply(ctx, rawConfig = {}) { const selfCheckReport = config.enabled && config.selfCheck ? collectSelfCheck(ctx, config) : null - const guardsOk = selfCheckReport - ? selfCheckReport.find((entry) => entry.feature === 'guards')?.ok === true - : true + // A feature is wired only when its own service passed the self-check (or + // the self-check itself is off): a missing service must disable its feature + // instead of crashing the whole bundle. + const serviceOk = (feature) => + !selfCheckReport || + selfCheckReport.find((entry) => entry.feature === feature)?.ok === true + const guardsOk = serviceOk('guards') + const bootstrapOk = serviceOk('bootstrap') + const commandsOk = serviceOk('commands') - // Log every not-ok feature before any wiring runs, so a service that is - // missing from this DSH version leaves a diagnostic line even when a later - // wiring block throws on first use. + // Log every not-ok feature before any wiring runs, so a missing service + // always leaves a diagnostic line for the feature being skipped. if (selfCheckReport) { for (const entry of selfCheckReport) { if (!entry.ok) { @@ -133,7 +138,7 @@ export function apply(ctx, rawConfig = {}) { }) } - if (config.enabled && config.bootstrap) { + if (config.enabled && config.bootstrap && bootstrapOk) { ctx.on('agent/pre-step', async ({ agent, signal }, next) => { const decision = await next() if (decision.kind === 'reject' || signal?.aborted) return decision @@ -161,7 +166,7 @@ export function apply(ctx, rawConfig = {}) { }) } - if (config.enabled && (config.roleCommand || config.checkCommand)) { + if (config.enabled && (config.roleCommand || config.checkCommand) && commandsOk) { ctx.inject(['commands'], (commandCtx) => { if (config.roleCommand) { registerRoleCommand({ diff --git a/dsh/tests/selfcheck.test.mjs b/dsh/tests/selfcheck.test.mjs index 8a229b8..d3d6e9e 100644 --- a/dsh/tests/selfcheck.test.mjs +++ b/dsh/tests/selfcheck.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { apply } from '../bundle/index.js' import { collectSelfCheck } from '../bundle/lib/selfcheck.js' import { registerCheckCommand } from '../bundle/command-check.js' @@ -117,3 +118,52 @@ test('registerCheckCommand builds report text', async () => { assert.match(badResult.text, /guards: not-ok/) assert.match(badResult.text, /problem/) }) + +/** Logger fake that interpolates `%s` and records error lines. */ +function recordingLogger(errors) { + return { + info() {}, + warn() {}, + error(fmt, ...args) { + const line = fmt.replace(/%s/g, () => String(args.shift() ?? '')) + errors.push(line) + }, + } +} + +test('apply does not throw when ctx.on and ctx.inject are missing', () => { + const calls = { guard: 0 } + const errors = [] + const ctx = { + tools: { guard() { calls.guard += 1 } }, + workingDirectory: { get() {} }, + logger: recordingLogger(errors), + } + + assert.doesNotThrow(() => apply(ctx)) + assert.equal(calls.guard, 1, 'guards feature stays wired') + assert.ok( + errors.some((line) => line.includes('self-check: bootstrap not-ok')), + 'bootstrap not-ok is logged', + ) + assert.ok( + errors.some((line) => line.includes('self-check: commands not-ok')), + 'commands not-ok is logged', + ) +}) + +test('apply wires guards, bootstrap, and commands with a full ctx', () => { + const calls = { guard: 0, on: 0, inject: 0 } + const ctx = { + tools: { guard() { calls.guard += 1 } }, + on() { calls.on += 1 }, + inject() { calls.inject += 1 }, + workingDirectory: { get() {} }, + logger: { info() {}, warn() {}, error() {} }, + } + + assert.doesNotThrow(() => apply(ctx)) + assert.equal(calls.guard, 1, 'guards wired') + assert.equal(calls.on, 1, 'bootstrap wired') + assert.equal(calls.inject, 1, 'commands wired') +})