Merge branch 'next' into fix/2117-audit-milestone-not-validated

This commit is contained in:
Tom Boucher
2026-07-12 00:03:52 -04:00
committed by GitHub
29 changed files with 2006 additions and 157 deletions

View File

@@ -3952,12 +3952,14 @@ describe('FIX 3: tightened runtime validator — probeExists optional string', (
// ── 24e. Closed-vocab set exports are correct ─────────────────────────────────
describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
test('VALID_CONFIG_HOME_KINDS has exactly the 4 expected values', () => {
test('VALID_CONFIG_HOME_KINDS has exactly the 5 expected values', () => {
assert.ok(VALID_CONFIG_HOME_KINDS instanceof Set);
for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']) {
// 'none' added #2103 — a runtime with NO file-projected config directory
// at all (e.g. vscode — Marketplace/VSIX-distributed).
for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']) {
assert.ok(VALID_CONFIG_HOME_KINDS.has(v), 'VALID_CONFIG_HOME_KINDS must contain "' + v + '"');
}
assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 4, 'VALID_CONFIG_HOME_KINDS must have exactly 4 members');
assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 5, 'VALID_CONFIG_HOME_KINDS must have exactly 5 members');
});
test('VALID_COMMAND_STYLES has exactly 2 values', () => {
@@ -4261,15 +4263,18 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () =>
});
// INSTALL_SURFACE_TO_CONFIG_FORMAT export check
test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 6 installSurface values with correct mappings', () => {
test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 7 installSurface values with correct mappings', () => {
assert.ok(INSTALL_SURFACE_TO_CONFIG_FORMAT instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 6, 'Must cover 6 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 7, 'Must cover 7 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('settings-json'), 'settings-json');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('codex-toml'), 'toml');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('copilot-instructions'), 'markdown');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cline-rules'), 'markdown-dir');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cursor-hooks-json'), 'none');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('profile-marker-only'), 'none');
// 'none' added #2103 — a runtime with no CLI install surface at all (e.g.
// vscode) has no config-file format to write either.
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('none'), 'none');
});
// Feature capabilities (role:feature) are silently ignored by the gate
@@ -4432,10 +4437,71 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT
);
});
// #2103: configHome.kind==='none' carve-out (VS Code — Marketplace/VSIX, no
// file-projected config directory). Adversarial review flagged AC4's
// "accept vscode AND reject a faked configHome" requirement as untested —
// the real vscode descriptor validating clean (tests/capability-registry.test.cjs's
// "ADR-1016 phase 5a" suite, capability-validator-parity tests, etc.) only
// proves the ACCEPT half. These three tests are the REJECT half: they prove
// the two new branches in validateRuntimeBody/validateConfigHome actually
// fire, quoting their EXACT error strings so a future edit that silently
// weakens either branch fails loudly here.
describe('#2103 configHome.kind==="none" carve-out (vscode) — accept/reject pair', () => {
test('ACCEPT: configHome.kind:"none" + localConfigDir:null + installSurface:"none" + hooksSurface:"none" validates with ZERO errors', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: 'test-none-rt', env: [] },
localConfigDir: null,
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(errors, [], 'a genuinely kind:"none" descriptor (vscode-shaped) must validate clean, got: ' + JSON.stringify(errors));
});
test('REJECT #1: localConfigDir non-null while configHome.kind==="none" → exact validateRuntimeBody error', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: 'test-none-rt', env: [] },
localConfigDir: '.vscode', // faked — a kind:'none' descriptor must not also claim a local dir
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(
errors,
['runtime.localConfigDir must be null or absent when configHome.kind is "none"; got: ".vscode"'],
'expected the exact localConfigDir-vs-kind:"none" rejection, got: ' + JSON.stringify(errors),
);
});
test('REJECT #2: configHome.name present-but-empty while configHome.kind==="none" → exact validateConfigHome error', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: '', env: [] }, // faked — present name must still be non-empty
localConfigDir: null,
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(
errors,
['capability "test-runtime" runtime.configHome.name must be a non-empty string if present when kind is "none"'],
'expected the exact configHome.name-vs-kind:"none" rejection, got: ' + JSON.stringify(errors),
);
});
});
// Verify the new constants are well-formed
test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 6 installSurface values', () => {
test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 7 installSurface values', () => {
assert.ok(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 6, 'Must cover 6 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 7, 'Must cover 7 installSurface values');
for (const installSurface of VALID_INSTALL_SURFACES) {
assert.ok(
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.has(installSurface),
@@ -6509,7 +6575,10 @@ describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () =
test('equals the registry runtimes that declare an installSurface', () => {
const descriptorAllowed = new Set(
Object.entries(enh1055Registry.runtimes)
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string')
// installSurface:'none' (#2103 vscode — extension-distributed, no config
// directory) is NOT a config-adapter runtime: production ALLOWED_CONFIG_RUNTIMES
// excludes it so `allRuntimes === ALLOWED_CONFIG_RUNTIMES` (issue-57) stays true.
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none')
.map(([id]) => id),
);
assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed);

View File

@@ -1,69 +1,12 @@
'use strict';
/**
* Reference VS Code IDE host binding for GSD (ADR-1239 Phase D / #1933).
* Thin re-export of the SHIPPED VS Code host binding (#2103).
*
* VS Code is the IDE-profile reference host. It composes the Phase-3 engine
* seams for the negotiated `ide` profile (host-integration.cts PROFILE_BASELINES):
*
* - modelMode: 'active' → createModelAdapter({modelMode:'active'}, {sendRequest})
* backed by `vscode.lm` (LanguageModelChat). VS Code rejects
* system-role messages, so the request mapper uses User role only.
* - hookBus: 'engine' → createHookBus({bus:'engine'}) — VS Code has NO host event bus,
* so GSD owns the bus in-process (full subscribe + emit).
* - stateIO: 'sandboxed-storage' → createStateIO({io:'sandboxed-storage'}, {backend}) bound to a
* host-supplied storage (no arbitrary FS — web/no-child_process safe).
* - embeddingMode: 'imperative' → createImperativeAdapter({runtime:'vscode'}) — engine-as-library.
*
* Distribution: VS Code is shipped as an EXTENSION (Marketplace), NOT file-projected onto a
* config dir, so it intentionally has NO runtime descriptor / `--vscode` installer entry — the
* extension IS the host. This module is the binding the extension's activate() runs.
*
* Mock-friendly: takes `vscode` (with `vscode.lm`) + `hostStorage` ({read,write}) so it is
* behaviorally testable without a live VS Code host.
*
* @param {{ lm: { sendRequest: (req: unknown) => unknown } }} vscode VS Code namespace (vscode.lm)
* @param {{ read: (path: string) => string, write: (path: string, content: string) => void }} hostStorage
* sandboxed-storage backend (e.g. globalState/workspaceState/secrets).
* @returns {object} the composed IDE host surface: { runtime, model, hookBus, stateIO, adapter, commands }
* `bindGsdToVscode` moved to `vscode/host-binding.js` (the module the real
* extension's `activate()` requires) so it ships with the extension instead of
* living only under tests/. This fixture re-exports it so every existing test
* that requires `./fixtures/vscode-host-binding.cjs` keeps resolving without
* edits (tests/vscode-ide-reference.test.cjs and any other consumer).
*/
module.exports = function bindGsdToVscode(vscode, hostStorage) {
if (!vscode || !vscode.lm || typeof vscode.lm.sendRequest !== 'function') {
throw new TypeError('bindGsdToVscode: vscode.lm.sendRequest is required (active model provider)');
}
if (!hostStorage || typeof hostStorage.read !== 'function' || typeof hostStorage.write !== 'function') {
throw new TypeError('bindGsdToVscode: hostStorage {read,write} is required (sandboxed-storage backend)');
}
const { createImperativeAdapter } = require('../../gsd-core/bin/lib/adapter-imperative.cjs');
const { createModelAdapter } = require('../../gsd-core/bin/lib/model-adapter.cjs');
const { createHookBus } = require('../../gsd-core/bin/lib/hook-bus.cjs');
const { createStateIO } = require('../../gsd-core/bin/lib/state-io.cjs');
// Active model: GSD model calls route through vscode.lm. (No system-role
// messages — VS Code rejects them; a full extension builds LanguageModelChatMessages
// with User role only and selects a model via vscode.lm.selectChatModels.)
const model = createModelAdapter({ modelMode: 'active' }, {
sendRequest(req) {
return vscode.lm.sendRequest(req);
},
});
// Engine-owned hook bus: VS Code has no host bus, so GSD owns it in-process.
const hookBus = createHookBus({ bus: 'engine' });
// Sandboxed-storage stateIO bound to the host storage backend (no fs / no child_process).
const stateIO = createStateIO({ io: 'sandboxed-storage' }, { backend: hostStorage });
// Imperative adapter: the engine-as-library for the VS Code runtime.
const adapter = createImperativeAdapter({ runtime: 'vscode' });
// Command surface: Command Palette + Chat participant entries bound to the
// GSD command-routing hub via the imperative adapter (interface point 1).
const commands = Object.freeze({
'gsd.invoke': Object.freeze({ description: 'Invoke a GSD command via the embedded engine (palette/chat).' }),
'gsd.help': Object.freeze({ description: 'List GSD commands available in the IDE host.' }),
});
return Object.freeze({ runtime: 'vscode', model, hookBus, stateIO, adapter, commands });
};
module.exports = require('../../vscode/host-binding.js');

View File

@@ -164,15 +164,21 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => {
assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default');
});
test('runtimeFlags has no isGemini and covers exactly the non-claude registry runtimes (count-agnostic)', () => {
test('runtimeFlags has no isGemini and covers exactly the non-claude, CLI-installable registry runtimes (count-agnostic)', () => {
const flags = runtimeFlags('claude');
assert.ok(!('isGemini' in flags), 'isGemini flag must be gone');
// The flag set tracks the non-claude registry runtimes (one is<Runtime> per
// id), so adding a runtime updates the count automatically — no hand-pinned
// number that would break on the next runtime addition.
const expectedNonClaudeCount = Object.keys(registry.runtimes).filter((id) => id !== 'claude').length;
// #2103: registry runtimes with installSurface === 'none' (e.g. vscode —
// Marketplace/VSIX-distributed, never CLI-installed) have no --<rt> flag
// by design (see tests/runtime-flags.test.cjs's NON_INSTALLABLE_RUNTIMES)
// and are excluded from this count too.
const expectedNonClaudeCount = Object.keys(registry.runtimes)
.filter((id) => id !== 'claude' && registry.runtimes[id].runtime.installSurface !== 'none')
.length;
assert.strictEqual(Object.keys(flags).length, expectedNonClaudeCount,
'flag count must equal the non-claude registry runtime count');
'flag count must equal the non-claude, CLI-installable registry runtime count');
});
test('gemini no longer maps to GEMINI.md (defaults to AGENTS.md)', () => {

View File

@@ -14,6 +14,13 @@
* - a structural cross-check that every descriptor's localConfigDir is a
* non-empty dot-dir string.
*
* #2103: vscode's `configHome.kind === 'none'` (no file-projected config
* directory at all) is the first descriptor with `localConfigDir: null` — it
* is carved out of the "non-empty dot-dir string" invariant below and
* asserted against getDirName's documented NO_LOCAL_CONFIG_DIR_SENTINEL
* instead (mirrors the carve-out in
* tests/non-claude-runtimes-registry-derivation.test.cjs).
*
* ADR-1239 Phase B (#1679). Behavioral tests only: assert on returned values.
*/
@@ -22,14 +29,36 @@ const assert = require('node:assert/strict');
const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { getDirName } = runtimeNamePolicy;
const { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } = runtimeNamePolicy;
const RUNTIME_IDS = Object.keys(registry.runtimes);
// Runtimes whose configHome.kind === 'none' have NO file-projected config
// directory at all (localConfigDir is legitimately null) — carved out of the
// "non-empty dot-dir string" invariant, the same way dot-home-nested's
// .parent is a conditional carve-out in the validator.
const NO_LOCAL_CONFIG_DIR_RUNTIMES = new Set(
RUNTIME_IDS.filter((id) => {
const desc = registry.runtimes[id] && registry.runtimes[id].runtime;
return !!(desc && desc.configHome && desc.configHome.kind === 'none');
}),
);
test('getDirName(id) projects each descriptor runtime.localConfigDir (derivation contract, count-agnostic)', () => {
assert.ok(RUNTIME_IDS.length > 0, 'registry must contain at least one runtime');
for (const id of RUNTIME_IDS) {
const desc = registry.runtimes[id] && registry.runtimes[id].runtime;
if (NO_LOCAL_CONFIG_DIR_RUNTIMES.has(id)) {
// #2103: no file-projected config dir — getDirName must return the
// documented sentinel, not the '.claude' default and not null.
assert.strictEqual(desc.localConfigDir, null,
`${id}: configHome.kind === 'none' runtimes must declare localConfigDir: null`);
assert.strictEqual(
getDirName(id),
NO_LOCAL_CONFIG_DIR_SENTINEL,
`getDirName('${id}') must equal the documented no-local-config-dir sentinel`);
continue;
}
const expected = desc && desc.localConfigDir;
assert.ok(typeof expected === 'string' && expected.length > 0,
`registry.runtimes['${id}'].runtime.localConfigDir must be a non-empty string`);
@@ -46,11 +75,16 @@ test('getDirName fallback: unknown / empty runtime returns ".claude" (fail-close
assert.strictEqual(getDirName('__nonexistent_runtime__'), '.claude');
});
test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string', () => {
test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string, except configHome.kind==="none" runtimes (localConfigDir: null)', () => {
for (const [id, entry] of Object.entries(registry.runtimes)) {
if (!entry || typeof entry !== 'object') continue;
const runtimeBlock = entry.runtime;
if (!runtimeBlock || typeof runtimeBlock !== 'object') continue;
if (NO_LOCAL_CONFIG_DIR_RUNTIMES.has(id)) {
assert.strictEqual(runtimeBlock.localConfigDir, null,
`registry.runtimes['${id}'].runtime.localConfigDir must be null (configHome.kind === 'none')`);
continue;
}
const dir = runtimeBlock.localConfigDir;
assert.strictEqual(typeof dir, 'string',
`registry.runtimes['${id}'].runtime.localConfigDir must be a string (got: ${typeof dir})`);

View File

@@ -51,8 +51,10 @@ const GOLDEN_FRAGMENT_MAP = {
};
// Runtimes intentionally NOT in the table: claude is the default; antigravity is
// resolved dynamically by the caller (resolveAntigravityGlobalDir + path.relative).
const SPECIAL_CASED = new Set(['claude', 'antigravity']);
// resolved dynamically by the caller (resolveAntigravityGlobalDir + path.relative);
// vscode (#2103) is extension-distributed with no file-projected config home at
// all — getGlobalConfigHomeFragment is never invoked for it (no install surface).
const SPECIAL_CASED = new Set(['claude', 'antigravity', 'vscode']);
test('getGlobalConfigHomeFragment: golden map matches for all 13 table runtimes', () => {
for (const [id, expected] of Object.entries(GOLDEN_FRAGMENT_MAP)) {

View File

@@ -40,7 +40,7 @@ const RUNTIME_IDS = Object.keys(registry.runtimes);
// Contract-pinned profile split (derived from .host-cli-final.json):
// programmatic-cli: claude, cline, cursor, hermes, kilo, kimi, opencode, pi, qwen, trae (10)
// declarative-cli: antigravity, augment, codebuddy, codex, copilot, windsurf, zcode (7)
// ide: 0
// ide: vscode (1) — #2103, the first installed ide-profile host.
const EXPECTED_PROFILES = {
claude: 'programmatic-cli',
cline: 'programmatic-cli',
@@ -59,6 +59,7 @@ const EXPECTED_PROFILES = {
copilot: 'declarative-cli',
windsurf: 'declarative-cli',
zcode: 'declarative-cli',
vscode: 'ide',
};
describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
@@ -215,7 +216,9 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
test('contract-pin: profile split is internally consistent with EXPECTED_PROFILES (count-agnostic)', () => {
// The counts are DERIVED from the curated EXPECTED_PROFILES map rather than
// hand-pinned, so adding a runtime + its profile entry updates the counts
// automatically. ide must remain 0 (no installed ide-profile host yet).
// automatically. #2103: vscode is now the first installed ide-profile host,
// so 'ide' is no longer pinned at a hardcoded 0 — it is derived below like
// the other two profiles.
const counts = { 'programmatic-cli': 0, 'declarative-cli': 0, 'ide': 0 };
for (const id of RUNTIME_IDS) {
const cap = registry.runtimes[id];
@@ -236,7 +239,8 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
}
assert.strictEqual(counts['programmatic-cli'], expectedCounts['programmatic-cli']);
assert.strictEqual(counts['declarative-cli'], expectedCounts['declarative-cli']);
assert.strictEqual(counts['ide'], 0, 'no installed host may carry the ide profile yet');
assert.strictEqual(counts['ide'], expectedCounts['ide'],
'ide-profile count must match EXPECTED_PROFILES (#2103: vscode is the first ide-profile host)');
});
// ─── backgroundDispatch presence ─────────────────────────────────────────────
@@ -291,6 +295,10 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
trae: true,
windsurf: true,
zcode: true,
// #2103: vscode's dispatch.backgroundDispatch is 'undocumented' (no
// documented background-subagent primitive) → fails closed to false →
// force-flattened, mirroring the pi (#2102) precedent above.
vscode: true,
};
for (const id of RUNTIME_IDS) {

View File

@@ -184,6 +184,36 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit
);
});
// allow-test-rule: structural guard over bin/install.js source (#2103). VS Code
// (capabilities/vscode/capability.json) is a registry runtime (role:runtime, for
// validator/host-integration coverage) but is NEVER CLI-installed — it is a
// Marketplace/VSIX extension with no --vscode flag and no allRuntimes membership
// (see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs). It must stay
// fully descriptor-driven: bin/install.js must never special-case it by name.
// This is a stricter, clearer-failure-message sibling of the generic
// "every inline runtime === ..." guard above (which would also catch this, but
// with a misleading "register it in the adapter registry" suggestion — vscode
// must never be registered there at all, see the ALLOWED_CONFIG_RUNTIMES filter
// in src/runtime-config-adapter-registry.cts).
test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => {
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)];
assert.deepStrictEqual(
runtimeComparisons.map((m) => m[0]),
[],
'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no '
+ 'install surface at all (installSurface: "none") and is never CLI-installed; any '
+ 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.',
);
const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)];
assert.deepStrictEqual(
isVscodeRefs.map((m) => m[0]),
[],
'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded '
+ 'from runtimeFlags (Marketplace-distributed, never CLI-installed).',
);
});
// allow-test-rule: delegation-presence guard. Catches wholesale removal of the registry
// dispatch (a regression to scattered per-runtime config branching). Presence-style, not
// absence-grep, so it does not bite on incidental non-config `runtime === '...'` checks.

View File

@@ -23,7 +23,7 @@ const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const { NON_CLAUDE_RUNTIMES } = conversion;
const { getDirName } = runtimeNamePolicy;
const { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } = runtimeNamePolicy;
// Golden oracle: hardcoded sorted known-good list of all non-Claude runtimes.
// A pinned expected value in a TEST is correct — the test IS the oracle.
@@ -78,3 +78,17 @@ test('DRIFT GUARD: every registry-declared non-Claude runtime has an explicit ge
);
}
});
// #2103: vscode is a registry runtime (role:runtime) whose configHome.kind is
// 'none' — it has NO file-projected config directory at all (Marketplace/VSIX
// extension). It is covered by the generic loop above (its dir must not be
// '.claude'), but that assertion alone would ALSO pass for a plain string
// typo, so this pins the actual documented sentinel value honestly rather
// than riding on the generic "not .claude" check.
test('#2103: getDirName("vscode") returns the documented no-local-config-dir sentinel, not .claude and not a real dot-dir', () => {
assert.ok(NON_CLAUDE_RUNTIMES.includes('vscode'), 'vscode must be a registered non-Claude runtime');
const dir = getDirName('vscode');
assert.equal(dir, NO_LOCAL_CONFIG_DIR_SENTINEL);
assert.notEqual(dir, '.claude');
assert.ok(!dir.startsWith('.'), 'the sentinel must not look like a plausible dot-dir name');
});

View File

@@ -197,15 +197,26 @@ describe('resolveRuntimeConfigIntent — fresh object each call', () => {
// ---------------------------------------------------------------------------
describe('ALLOWED_CONFIG_RUNTIMES completeness', () => {
test('ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare an installSurface', () => {
test('ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare a real (non-"none") installSurface', () => {
// #2103: installSurface 'none' means "no CLI install surface at all"
// (e.g. vscode — Marketplace/VSIX-distributed, never CLI-installed), so
// it is excluded from the config-adapter runtime set by definition — this
// mirrors the exclusion already baked into the production
// ALLOWED_CONFIG_RUNTIMES filter (src/runtime-config-adapter-registry.cts).
const descriptorAllowed = new Set(
Object.entries(registry.runtimes)
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string')
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none')
.map(([id]) => id),
);
assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed);
});
test('#2103: vscode declares installSurface "none" and is registered but intentionally excluded from ALLOWED_CONFIG_RUNTIMES', () => {
assert.strictEqual(registry.runtimes.vscode.runtime.installSurface, 'none');
assert.ok(!ALLOWED_CONFIG_RUNTIMES.has('vscode'),
'vscode must not be a config-adapter runtime — it has no CLI install surface');
});
test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => {
for (const runtime of ALLOWED_CONFIG_RUNTIMES) {
assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`);
@@ -225,9 +236,11 @@ describe('INSTALL_SURFACES export', () => {
'cline-rules',
'cursor-hooks-json',
'profile-marker-only',
// 'none' added #2103 — vscode has no CLI install surface at all.
'none',
]);
test('INSTALL_SURFACES contains exactly the 6 surface strings', () => {
test('INSTALL_SURFACES contains exactly the 7 surface strings', () => {
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
});
});

View File

@@ -17,6 +17,14 @@ const EXPECTED_FLAGS = [
'isCodebuddy', 'isCline', 'isKimi', 'isZcode', 'isPi',
];
// #2103: registry runtimes that are NEVER CLI-installed via bin/install.js
// (Marketplace/VSIX-distributed, no --<rt> flag, not in allRuntimes) — these
// have no runtimeFlags entry by design, not by drift. vscode is the first
// (and, today, only) member: it enters `registry.runtimes` (role:runtime, for
// validator/host-integration coverage) but never enters bin/install.js's
// allRuntimes, so it must not be required to have an isVscode flag here.
const NON_INSTALLABLE_RUNTIMES = new Set(['vscode']);
test('runtimeFlags: every known non-claude runtime sets exactly its own flag true', () => {
const ids = EXPECTED_FLAGS.map((f) => f.slice(2).toLowerCase());
for (const id of ids) {
@@ -44,11 +52,27 @@ test('runtimeFlags: all 16 flags present + boolean + the object is frozen', () =
assert.ok(Object.isFrozen(flags), 'flags object must be frozen');
});
test('runtimeFlags drift guard: covers every registry runtime except claude', () => {
test('runtimeFlags drift guard: covers every registry runtime except claude and the non-installable set', () => {
// Adding a registry runtime that is not claude must get a flag or be added to
// RUNTIME_FLAG_IDS — pin the set so a new runtime forces a deliberate update.
const registryNonClaude = Object.keys(registry.runtimes).filter((r) => r !== 'claude').sort();
// NON_INSTALLABLE_RUNTIMES (#2103) is filtered out first: a runtime that is
// never CLI-installed (e.g. vscode — Marketplace/VSIX only) has no --<rt>
// flag by design and must not trip this guard.
const registryNonClaude = Object.keys(registry.runtimes)
.filter((r) => r !== 'claude' && !NON_INSTALLABLE_RUNTIMES.has(r))
.sort();
const flagIds = EXPECTED_FLAGS.map((f) => f.slice(2).toLowerCase()).sort();
const missing = registryNonClaude.filter((r) => !flagIds.includes(r));
assert.deepEqual(missing, [], `registry runtimes missing a runtimeFlags entry: ${missing.join(', ')} — add to RUNTIME_FLAG_IDS`);
});
test('#2103: vscode is registered but intentionally excluded from runtimeFlags (Marketplace-distributed, never CLI-installed)', () => {
assert.ok(registry.runtimes.vscode, 'vscode must be present in the registry (role:runtime)');
assert.ok(NON_INSTALLABLE_RUNTIMES.has('vscode'));
const flags = runtimeFlags('vscode');
for (const f of EXPECTED_FLAGS) {
assert.strictEqual(flags[f], false, `runtime 'vscode': ${f} must be false (no isVscode flag exists)`);
}
assert.deepStrictEqual(Object.keys(flags).sort(), [...EXPECTED_FLAGS].sort(),
'runtimeFlags(\'vscode\') must NOT introduce a new isVscode key — still exactly the 16 flags');
});

View File

@@ -0,0 +1,128 @@
// allow-test-rule: source-text-is-the-product (#2103). browser.js's entire
// contract IS "zero Node APIs" — a VS Code Web/webworker host has no Node
// core modules at all, so this is a runtime-contract source check, not a
// behavioral proxy for something observable another way (there is no Node
// runtime to observe failing against in CI). Mirrors the existing
// phase6-capstone-conformance.test.cjs precedent for the same exemption class.
'use strict';
/**
* VS Code browser (Web Extension) entry static guard — #2103.
*
* `vscode/browser.js` is the `browser` field entry VS Code Web / vscode.dev
* loads in a webworker context, which has NO Node core modules at all
* (`fs`, `path`, `child_process`) and no Node globals (`process`, `Buffer`,
* `__dirname`, `__filename`). Requiring one throws immediately at load time.
*
* This is the reason browser.js does NOT require `./host-binding.js` (whose
* transitive engine-lib dependencies pull in `node:fs` — see host-binding.js's
* and browser.js's own header comments for the full chain) — it implements an
* independent, minimal composition directly against `vscode.lm`.
*
* No real browser or VS Code host is available in CI (mock vscode only, per
* every other vscode-*.test.cjs in this suite).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BROWSER_PATH = path.join(__dirname, '..', 'vscode', 'browser.js');
test('vscode/browser.js source contains ZERO require("fs" | "path" | "child_process") (or "node:" prefixed forms)', () => {
const src = fs.readFileSync(BROWSER_PATH, 'utf8');
const bannedRequires = [
/require\(\s*['"]fs['"]\s*\)/,
/require\(\s*['"]node:fs['"]\s*\)/,
/require\(\s*['"]path['"]\s*\)/,
/require\(\s*['"]node:path['"]\s*\)/,
/require\(\s*['"]child_process['"]\s*\)/,
/require\(\s*['"]node:child_process['"]\s*\)/,
];
const offenders = [];
for (const line of src.split(/\r?\n/)) {
// Skip comment lines (this file's own header documents the constraint in
// prose, which legitimately contains the string `require('fs')` etc.).
const trimmed = line.trim();
if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue;
for (const re of bannedRequires) {
if (re.test(line)) offenders.push(line.trim());
}
}
assert.deepEqual(offenders, [],
`vscode/browser.js must never require a Node core module outside a comment; found: ${JSON.stringify(offenders)}`);
});
test('vscode/browser.js does NOT require ./host-binding.js or ./extension.js (both pull in fs-heavy engine-lib modules transitively)', () => {
const src = fs.readFileSync(BROWSER_PATH, 'utf8');
const codeLines = src.split(/\r?\n/).filter((l) => {
const t = l.trim();
return !(t.startsWith('*') || t.startsWith('//'));
}).join('\n');
assert.doesNotMatch(codeLines, /require\(\s*['"]\.\/(host-binding|extension)\.js['"]\s*\)/,
'browser.js must compose its own zero-Node-API surface, not require a module with fs-pulling transitive deps');
});
test('vscode/browser.js source contains no Node-only globals (process/Buffer/__dirname/__filename) outside comments', () => {
const src = fs.readFileSync(BROWSER_PATH, 'utf8');
const offenders = [];
for (const line of src.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue;
if (/\bprocess\.|\bBuffer\.|__dirname\b|__filename\b/.test(line)) offenders.push(line.trim());
}
assert.deepEqual(offenders, []);
});
test('vscode/browser.js is valid, loadable JS (node --check equivalent — require does not throw)', () => {
assert.doesNotThrow(() => require(BROWSER_PATH));
});
test('REACHABILITY: browser.js activate() runs against a mock vscode host without throwing (no real VS Code/browser in CI)', () => {
const Module = require('module');
const originalLoad = Module._load;
const registeredCommands = {};
let chatCreated = false;
const toolNames = [];
const mockVscode = {
commands: {
registerCommand(id, handler) {
registeredCommands[id] = handler;
return { dispose() {} };
},
},
chat: {
createChatParticipant(id, handler) {
chatCreated = true;
return { id, handler, dispose() {} };
},
},
lm: {
registerTool(name) {
toolNames.push(name);
return { dispose() {} };
},
},
workspace: { getConfiguration: () => ({ get: () => undefined }) },
LanguageModelTextPart: class { constructor(t) { this.text = t; } },
LanguageModelToolResult: class { constructor(p) { this.parts = p; } },
};
Module._load = function (request, ...rest) {
if (request === 'vscode') return mockVscode;
return originalLoad.call(this, request, ...rest);
};
try {
delete require.cache[BROWSER_PATH];
const browser = require(BROWSER_PATH);
const context = { subscriptions: [] };
assert.doesNotThrow(() => browser.activate(context));
assert.ok(registeredCommands['gsd.invoke'], 'gsd.invoke command registered on web too');
assert.ok(chatCreated, 'chat participant registered on web');
assert.ok(toolNames.length > 0, 'LM tools registered on web');
assert.ok(context.subscriptions.length > 0);
} finally {
Module._load = originalLoad;
delete require.cache[BROWSER_PATH];
}
});

View File

@@ -1,18 +1,34 @@
'use strict';
/**
* VS Code extension reachability test — ADR-1239 Phase D / #1942.
* VS Code extension reachability test — ADR-1239 Phase D / #1942, upgraded #2103.
*
* Proves the VS Code extension is keystone-WIRED: the gsd.invoke handler
* dispatches through the GSD command-routing hub and returns a result (not just
* a stub). The handler is exported separately from activate() so it is testable
* WITHOUT a VS Code host.
* dispatches through gsd-tools.cjs (subprocess-reuse — the shared
* `dispatchGsdCommand` in gsd-core/bin/lib/shell-command-projection.cjs) and
* returns REAL output, not just a registration on a stub. The handler is
* exported separately from activate() so it is testable WITHOUT a VS Code host.
*
* The original cut called `createHub()` with NO args — no hub factory in the
* tree fully populates a hub, so every dispatch silently answered
* UnknownCommand. The prior version of this test only asserted the result was
* "a JSON object" — a VACUOUS assertion that passed whether or not dispatch
* actually worked. Dispatch is now exercised with a real read-only
* family/subcommand (progress/json) against a real temp project, matching the
* sibling tests/pi-extension-reachability.test.cjs pattern — no fake
* dispatcher injected, because the whole point of "reachability" is that the
* real engine is reached.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');
const path = require('node:path');
const { activate, dispatchGsdCommand, resolveEngineRoot } = require('../vscode/extension.js');
const extension = require('../vscode/extension.js');
const { activate, dispatchGsdCommand, resolveEngineRoot, resolveWorkspaceCwd } = extension;
const { createTempDir, cleanup } = require('./helpers.cjs');
const shellCommandProjection = require('../gsd-core/bin/lib/shell-command-projection.cjs');
test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot', () => {
assert.equal(typeof activate, 'function');
@@ -20,18 +36,43 @@ test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot',
assert.equal(typeof resolveEngineRoot, 'function');
});
test('REACHABILITY: dispatchGsdCommand dispatches through the engine hub (keystone wired)', async () => {
const result = await dispatchGsdCommand({ family: 'query', subcommand: 'help' });
assert.equal(typeof result, 'string', 'returns a string result');
const parsed = JSON.parse(result);
assert.ok(parsed !== null && typeof parsed === 'object',
'dispatch produced a result object (the engine was reached)');
test('REACHABILITY: dispatchGsdCommand dispatches a real family/subcommand through gsd-tools.cjs and returns REAL output (keystone wired, not UnknownCommand)', async () => {
const dir = createTempDir();
try {
const result = await dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: dir });
assert.equal(typeof result, 'string', 'returns a string result');
const parsed = JSON.parse(result);
assert.ok(parsed !== null && typeof parsed === 'object', 'dispatch produced a result object');
assert.equal(parsed.ok, true, `expected ok:true (real dispatch), got: ${result}`);
assert.equal(typeof parsed.stdout, 'string');
assert.ok(parsed.stdout.length > 0, 'stdout must be non-empty');
const inner = JSON.parse(parsed.stdout);
assert.equal(typeof inner.percent, 'number',
'the real progress command ran (proves the engine was reached — not UnknownCommand)');
assert.equal(parsed.code, 0);
} finally {
cleanup(dir);
}
});
test('dispatchGsdCommand works with default args (no args → query/help)', async () => {
test('REACHABILITY: an unknown family surfaces ok:false without throwing (not a silent UnknownCommand success)', async () => {
const dir = createTempDir();
try {
const result = await dispatchGsdCommand({ family: 'no-such-family-8675309', cwd: dir });
const parsed = JSON.parse(result);
assert.equal(parsed.ok, false);
assert.match(parsed.stderr, /no-such-family-8675309|Unknown command/);
} finally {
cleanup(dir);
}
});
test('dispatchGsdCommand works with default args (no args → gsd-tools.cjs --help, a real working default)', async () => {
const result = await dispatchGsdCommand();
assert.equal(typeof result, 'string');
JSON.parse(result); // must be valid JSON
const parsed = JSON.parse(result); // must be valid JSON
assert.equal(parsed.ok, true, `expected the --help default to be ok:true, got: ${result}`);
assert.match(parsed.stdout, /Usage: gsd-tools/, 'the --help default produced real usage output');
});
test('resolveEngineRoot finds the gsd-core/ dir from the extension location', () => {
@@ -49,3 +90,108 @@ test('the extension manifest declares the gsd.invoke command', () => {
assert.ok(pkg.engines && pkg.engines.vscode, 'manifest declares VS Code engine');
assert.equal(pkg.main, './extension.js', 'manifest main points to extension.js');
});
// ── #2103 FIX (adversarial review, MAJOR): all three desktop dispatch
// surfaces previously called dispatchGsdCommand WITHOUT a cwd, silently
// defaulting to the extension host's own process.cwd() instead of the user's
// project — meaning GSD ran against the wrong directory. resolveWorkspaceCwd
// resolves vscode.workspace.workspaceFolders[0].uri.fsPath, computed fresh
// per-invocation (never cached at activate() time). ─────────────────────────
test('resolveWorkspaceCwd resolves workspaceFolders[0].uri.fsPath when a workspace is open', () => {
const mockVscode = { workspace: { workspaceFolders: [{ uri: { fsPath: '/tmp/some-workspace' } }] } };
assert.equal(resolveWorkspaceCwd(mockVscode), '/tmp/some-workspace');
});
test('resolveWorkspaceCwd falls back to process.cwd() when no workspace folder is open (fail-soft, never throws)', () => {
assert.doesNotThrow(() => {
assert.equal(resolveWorkspaceCwd({ workspace: { workspaceFolders: [] } }), process.cwd());
assert.equal(resolveWorkspaceCwd({ workspace: {} }), process.cwd());
assert.equal(resolveWorkspaceCwd({}), process.cwd());
});
});
test('#2103 FIX: all three desktop dispatch surfaces (gsd.invoke command, @gsd chat participant, LM tool invoke) thread the resolved workspace folder as cwd — not process.cwd()', async () => {
// Spy on the SHARED subprocess-shim dispatchGsdCommand (the one every
// surface ultimately calls via extension.js's own dispatchGsdCommand) so we
// observe the actual `cwd` each surface passes, without needing a real
// gsd-tools.cjs project rooted at the mock workspace path.
const originalShimDispatch = shellCommandProjection.dispatchGsdCommand;
const calls = [];
shellCommandProjection.dispatchGsdCommand = (args) => {
calls.push(args);
return originalShimDispatch(args);
};
const registeredCommands = {};
let chatHandler = null;
const toolImpls = [];
const mockVscode = {
commands: {
registerCommand(id, handler) { registeredCommands[id] = handler; return { dispose() {} }; },
},
chat: {
createChatParticipant(id, handler) { chatHandler = handler; return { id, dispose() {} }; },
},
lm: {
registerTool(name, impl) { toolImpls.push({ name, impl }); return { dispose() {} }; },
},
workspace: {
workspaceFolders: [{ uri: { fsPath: '/tmp/mock-gsd-workspace' } }],
getConfiguration: () => ({ get: () => undefined }),
},
LanguageModelTextPart: class { constructor(t) { this.text = t; } },
LanguageModelToolResult: class { constructor(parts) { this.parts = parts; } },
};
const originalLoad = Module._load;
Module._load = function (request, ...rest) {
if (request === 'vscode') return mockVscode;
return originalLoad.call(this, request, ...rest);
};
const extensionPath = path.join(__dirname, '..', 'vscode', 'extension.js');
const hostBindingPath = path.join(__dirname, '..', 'vscode', 'host-binding.js');
try {
delete require.cache[extensionPath];
delete require.cache[hostBindingPath];
const freshExtension = require(extensionPath);
const context = {
subscriptions: [],
globalState: { get: () => undefined, update: () => Promise.resolve() },
};
freshExtension.activate(context);
// Surface 1: gsd.invoke command handler.
calls.length = 0;
await registeredCommands['gsd.invoke']({ family: 'progress', subcommand: 'json' });
assert.equal(calls.length, 1, 'gsd.invoke handler must dispatch exactly once');
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'gsd.invoke must thread the workspace folder as cwd');
// Surface 1b: an explicit args.cwd still takes precedence over the resolved workspace.
calls.length = 0;
await registeredCommands['gsd.invoke']({ family: 'progress', subcommand: 'json', cwd: '/explicit/override' });
assert.equal(calls[0].cwd, '/explicit/override', 'an explicit cwd argument must still override the resolved workspace folder');
// Surface 2: @gsd chat participant handler.
calls.length = 0;
let markdownOut = '';
await chatHandler({ prompt: 'progress json' }, {}, { markdown: (t) => { markdownOut += t; } }, {});
assert.equal(calls.length, 1, 'the chat participant handler must dispatch exactly once');
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'the chat participant must thread the workspace folder as cwd');
assert.ok(markdownOut.length > 0);
// Surface 3: Language Model Tool invoke().
calls.length = 0;
const progressTool = toolImpls.find((t) => t.name === 'gsd_progress');
assert.ok(progressTool, 'gsd_progress tool must be registered');
await progressTool.impl.invoke({ input: {} }, {});
assert.equal(calls.length, 1, 'the LM tool invoke() must dispatch exactly once');
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'the LM tool invoke() must thread the workspace folder as cwd');
} finally {
Module._load = originalLoad;
shellCommandProjection.dispatchGsdCommand = originalShimDispatch;
delete require.cache[extensionPath];
delete require.cache[hostBindingPath];
}
});

View File

@@ -18,8 +18,9 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { profileOf } = require('../gsd-core/bin/lib/host-integration.cjs');
const { profileOf, negotiateHostCapabilities } = require('../gsd-core/bin/lib/host-integration.cjs');
const bindGsdToVscode = require('./fixtures/vscode-host-binding.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('VS Code IDE axes classify as the ide profile', () => {
// ide baseline (host-integration.cts PROFILE_BASELINES): imperative + sandboxed-web.
@@ -27,10 +28,17 @@ test('VS Code IDE axes classify as the ide profile', () => {
assert.notEqual(profileOf({ embeddingMode: 'imperative', runtime: 'node' }), 'ide');
});
test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', () => {
test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', async () => {
// Real API: vscode.lm.selectChatModels() (async → LanguageModelChat[]); the
// SELECTED MODEL has .sendRequest, NOT vscode.lm itself (#2103 correction —
// there is no vscode.lm.sendRequest).
let lastLmReq = null;
const vscode = {
lm: { sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } },
lm: {
selectChatModels: async () => [
{ sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } },
],
},
};
const storageWrites = [];
const hostStorage = {
@@ -41,9 +49,10 @@ test('bindGsdToVscode composes the full IDE profile (active model + engine bus +
const host = bindGsdToVscode(vscode, hostStorage);
assert.equal(host.runtime, 'vscode');
// Active model routes through vscode.lm (no system messages — User role only).
// Active model routes through vscode.lm.selectChatModels() → model.sendRequest
// (no system messages — User role only).
assert.equal(host.model.mode, 'active');
assert.equal(host.model.sendRequest({ prompt: 'hi' }), 'lm-response');
assert.equal(await host.model.sendRequest({ prompt: 'hi' }), 'lm-response');
assert.deepEqual(lastLmReq, { prompt: 'hi' });
// Engine-owned hook bus: in-process pub/sub (VS Code has no host bus).
@@ -67,9 +76,30 @@ test('bindGsdToVscode composes the full IDE profile (active model + engine bus +
assert.ok(host.commands['gsd.invoke'], 'palette/chat command surface present');
});
// #2103: proves the binding actually COMPOSES (does not throw) against a
// realistic desktop VS Code `vscode.lm` shape — the whole point of the guard
// fix: a real host has `selectChatModels`, never `sendRequest` directly, and
// the binding must succeed, not silently fail-open at the extension.js layer.
test('#2103: bindGsdToVscode composes successfully against a REALISTIC vscode.lm (selectChatModels only, no vscode.lm.sendRequest)', async () => {
const vscode = {
lm: {
selectChatModels: async () => [{ sendRequest: (req) => Promise.resolve({ echoed: req }) }],
// Deliberately no top-level sendRequest — matches the real API surface.
},
};
const hostStorage = { read: () => '', write: () => {} };
let host;
assert.doesNotThrow(() => { host = bindGsdToVscode(vscode, hostStorage); },
'bindGsdToVscode must succeed on a realistic host (selectChatModels-only vscode.lm)');
assert.equal(host.model.mode, 'active');
const response = await host.model.sendRequest({ prompt: 'hello' });
assert.deepEqual(response, { echoed: { prompt: 'hello' } });
});
test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => {
const okStorage = { read() {}, write() {} };
const okVscode = { lm: { sendRequest() {} } };
const okVscode = { lm: { selectChatModels: async () => [] } };
// vscode.lm missing or incomplete → vscode.lm error
assert.throws(() => bindGsdToVscode({}, okStorage), /vscode\.lm/);
assert.throws(() => bindGsdToVscode({ lm: {} }, okStorage), /vscode\.lm/);
@@ -77,3 +107,73 @@ test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => {
assert.throws(() => bindGsdToVscode(okVscode, null), /hostStorage/);
assert.throws(() => bindGsdToVscode(okVscode, { read() {} }), /hostStorage/);
});
test('#2103: bindGsdToVscode still throws when vscode.lm.selectChatModels is ABSENT (a vscode.lm.sendRequest-only mock, matching the OLD incorrect API assumption, must be rejected)', () => {
const okStorage = { read() {}, write() {} };
// The pre-#2103 (wrong) shape: sendRequest directly on vscode.lm, no selectChatModels.
const staleShapeVscode = { lm: { sendRequest: () => 'stale' } };
assert.throws(() => bindGsdToVscode(staleShapeVscode, okStorage), /selectChatModels/);
});
// ── #2103: negotiate fail-closed for an UNDECLARED vscode axis ──────────────
// negotiateHostCapabilities must never throw and must degrade an undeclared
// (missing) axis to its most-restrictive documented default — the same
// fail-closed contract already proven for the "undocumented" sentinel
// (tests/host-integration-descriptors.test.cjs), but exercised here against a
// genuinely ABSENT key (not the string "undocumented") on vscode's real
// hostIntegration object, to prove the negotiation seam is defensive against
// both failure modes.
//
// #2103 FIX (adversarial review, MINOR): strengthened from a weak
// `!== undefined` check to a STRICT equality against the actual
// most-restrictive value negotiateHostCapabilities produces — the fail-closed
// floor pinned as `SAFE_DEFAULTS` in gsd-core/bin/lib/host-integration.cjs
// (not exported, so the values are pinned here verbatim, verified via node -e
// against the real negotiation output before writing this test — see the PR
// report). A `!== undefined` check would pass even if negotiation regressed
// to a WRONG-but-defined value (e.g. a less-restrictive default) — this
// assertion pins the exact known floor per the AC's own wording
// ("most-restrictive known value").
const HOST_INTEGRATION_SAFE_DEFAULTS = {
embeddingMode: 'declarative',
commandSurface: 'prose-only',
modelMode: 'passive',
hookBus: 'none',
stateIO: 'session-log-append',
transport: 'mcp',
runtime: 'node',
};
const HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS = {
namedDispatch: false,
nested: false,
maxDepth: 0,
background: false,
subagentToolkit: 'read-only',
backgroundDispatch: false,
};
test('#2103: negotiateHostCapabilities never throws for vscode with an UNDECLARED axis, and degrades to the EXACT most-restrictive SAFE_DEFAULTS value', () => {
const realHi = registry.runtimes.vscode.runtime.hostIntegration;
for (const axis of Object.keys(HOST_INTEGRATION_SAFE_DEFAULTS)) {
const undeclared = { ...realHi, dispatch: { ...realHi.dispatch } };
delete undeclared[axis];
let result;
assert.doesNotThrow(() => { result = negotiateHostCapabilities(undeclared); },
`negotiateHostCapabilities must not throw when vscode's "${axis}" axis is entirely absent`);
assert.strictEqual(result.effective[axis], HOST_INTEGRATION_SAFE_DEFAULTS[axis],
`effective.${axis} must resolve to the exact most-restrictive SAFE_DEFAULTS value "${HOST_INTEGRATION_SAFE_DEFAULTS[axis]}", got: ${JSON.stringify(result.effective[axis])}`);
}
});
test('#2103: negotiateHostCapabilities never throws for vscode with an UNDECLARED dispatch sub-axis, and degrades to the EXACT most-restrictive value', () => {
const realHi = registry.runtimes.vscode.runtime.hostIntegration;
for (const key of Object.keys(HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS)) {
const undeclared = { ...realHi, dispatch: { ...realHi.dispatch } };
delete undeclared.dispatch[key];
let result;
assert.doesNotThrow(() => { result = negotiateHostCapabilities(undeclared); },
`negotiateHostCapabilities must not throw when vscode's dispatch.${key} is entirely absent`);
assert.strictEqual(result.effective.dispatch[key], HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS[key],
`effective.dispatch.${key} must resolve to the exact most-restrictive value "${HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS[key]}", got: ${JSON.stringify(result.effective.dispatch[key])}`);
}
});

View File

@@ -0,0 +1,155 @@
'use strict';
/**
* VS Code Language Model Tools test — #2103 UPGRADE 1.
*
* Proves the GSD extension's Language Model Tools are keystone-WIRED:
* 1. contributes.languageModelTools is present in package.json and its
* `name` entries match the runtime registration names (extension.js's
* LM_TOOLS / browser.js's LM_TOOL_NAMES) exactly — a mismatch here would
* mean VS Code rejects the tool registration at activation.
* 2. registerLanguageModelTools() calls vscode.lm.registerTool for every
* manifest entry (mock vscode.lm — no real VS Code host available in CI).
* 3. A registered tool's invoke() dispatches through the SAME shared
* dispatchGsdCommand as gsd.invoke (desktop) and returns REAL output —
* the "user can invoke X" proof, matching the reachability-test pattern.
* 4. The desktop and web entries register the identical tool NAME set (only
* the invoke() behavior differs — real dispatch vs. honest web-mode message).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const pkg = require('../vscode/package.json');
const extension = require('../vscode/extension.js');
const browser = require('../vscode/browser.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
class FakeTextPart {
constructor(text) { this.text = text; }
}
class FakeToolResult {
constructor(parts) { this.parts = parts; }
}
function mockVscodeLm() {
const registered = [];
return {
lm: {
registerTool(name, impl) {
registered.push({ name, impl });
return { dispose() {} };
},
},
LanguageModelTextPart: FakeTextPart,
LanguageModelToolResult: FakeToolResult,
registered,
};
}
test('package.json contributes.languageModelTools is present with well-formed entries', () => {
assert.ok(pkg.contributes && Array.isArray(pkg.contributes.languageModelTools),
'contributes.languageModelTools must be an array');
assert.ok(pkg.contributes.languageModelTools.length > 0, 'must declare at least one tool');
for (const tool of pkg.contributes.languageModelTools) {
assert.equal(typeof tool.name, 'string');
assert.ok(tool.name.length > 0);
assert.equal(typeof tool.toolReferenceName, 'string');
assert.equal(typeof tool.displayName, 'string');
assert.equal(typeof tool.modelDescription, 'string');
assert.equal(typeof tool.userDescription, 'string');
assert.equal(tool.canBeReferencedInPrompt, true);
assert.ok(Array.isArray(tool.tags));
assert.equal(typeof tool.inputSchema, 'object');
}
});
test('manifest tool names exactly match extension.js LM_TOOLS registration names', () => {
const manifestNames = pkg.contributes.languageModelTools.map((t) => t.name).sort();
const runtimeNames = extension.LM_TOOLS.map((t) => t.name).sort();
assert.deepEqual(runtimeNames, manifestNames,
'a mismatch here means VS Code would reject the runtime registerTool call against the manifest');
});
test('manifest tool names exactly match browser.js LM_TOOL_NAMES (web entry registers the same surface)', () => {
const manifestNames = pkg.contributes.languageModelTools.map((t) => t.name).sort();
assert.deepEqual([...browser.LM_TOOL_NAMES].sort(), manifestNames);
});
test('REACHABILITY (desktop): registerLanguageModelTools registers every manifest tool via vscode.lm.registerTool', () => {
const mock = mockVscodeLm();
const context = { subscriptions: [] };
const count = extension.registerLanguageModelTools(mock, context);
assert.equal(count, pkg.contributes.languageModelTools.length);
assert.equal(mock.registered.length, pkg.contributes.languageModelTools.length);
assert.equal(context.subscriptions.length, pkg.contributes.languageModelTools.length);
assert.deepEqual(mock.registered.map((r) => r.name).sort(), extension.LM_TOOLS.map((t) => t.name).sort());
});
test('REACHABILITY (desktop): gsd_progress tool.invoke() dispatches through the hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
assert.ok(progressTool, 'gsd_progress must be registered');
const result = await progressTool.impl.invoke({ input: {}, cwd: dir }, {});
assert.ok(result instanceof FakeToolResult, 'invoke must return a LanguageModelToolResult');
assert.ok(Array.isArray(result.parts) && result.parts.length === 1);
assert.ok(result.parts[0] instanceof FakeTextPart, 'result part must be a LanguageModelTextPart');
const parsed = JSON.parse(result.parts[0].text);
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (engine reached, not a stub)');
} finally {
cleanup(dir);
}
});
test('REACHABILITY (desktop): gsd_plan_phase tool.invoke() forwards the "phase" input through dispatch (real, not UnknownCommand)', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const planPhaseTool = mock.registered.find((r) => r.name === 'gsd_plan_phase');
assert.ok(planPhaseTool);
const result = await planPhaseTool.impl.invoke({ input: { phase: 'nonexistent-phase-8675309' }, cwd: dir }, {});
const parsed = JSON.parse(result.parts[0].text);
// Real dispatch reaches gsd-tools.cjs and returns a structured "phase not
// found" response (proves the engine was reached) — not the manifest's
// own family/subcommand rejected as unknown.
assert.equal(parsed.phase, 'nonexistent-phase-8675309');
assert.ok('error' in parsed || 'plans' in parsed, 'expected a real phase-plan-index response shape');
} finally {
cleanup(dir);
}
});
test('gsd_workstreams tool.invoke() dispatches through the hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const wsTool = mock.registered.find((r) => r.name === 'gsd_workstreams');
const result = await wsTool.impl.invoke({ input: {}, cwd: dir }, {});
const parsed = JSON.parse(result.parts[0].text);
assert.ok('workstreams' in parsed || 'mode' in parsed, 'expected a real workstream list response shape');
} finally {
cleanup(dir);
}
});
test('registerLanguageModelTools fails soft (returns 0, does not throw) when vscode.lm is absent', () => {
assert.doesNotThrow(() => {
const count = extension.registerLanguageModelTools({}, { subscriptions: [] });
assert.equal(count, 0);
});
});
test('WEB MODE: browser.js registerLanguageModelTools registers the same names but invoke() returns an honest web-mode message (no engine dispatch)', async () => {
const mock = mockVscodeLm();
const count = browser.registerLanguageModelTools(mock, { subscriptions: [] });
assert.equal(count, browser.LM_TOOL_NAMES.length);
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
const result = await progressTool.impl.invoke({ input: {} }, {});
assert.match(result.parts[0].text, /web mode/i);
assert.match(result.parts[0].text, /MCP server/);
});

View File

@@ -0,0 +1,148 @@
'use strict';
/**
* VS Code #runSubagent wiring test — #2103 UPGRADE 2.
*
* VS Code 1.105+ lets the primary chat agent invoke registered chat
* participants / Language Model Tools as a nested agent turn via
* `#runSubagent`, gated by the `chat.subagents.allowInvocationsFromSubagents`
* setting (default off; nested depth max 5 when enabled — docs/agents/subagents.md,
* release-notes/v1_105.md). There is no separate extension-side "subagent
* contribution" registration API: VS Code's chat engine surfaces the already
* -registered chat participant + languageModelTools directly. This extension's
* own contribution is `registerSubagentDispatch` — availability detection
* (fail-soft on older/Insiders-gated hosts) plus a belt-and-suspenders
* maxDepth:5 ceiling (dispatchAsSubagent) that mirrors
* capabilities/vscode/capability.json's hostIntegration.dispatch.maxDepth,
* independent of whatever the host itself enforces.
*
* Mock vscode only — no real VS Code host in CI.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const extension = require('../vscode/extension.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
function mockVscodeWithSubagentSupport(allow) {
return {
workspace: {
getConfiguration(section) {
return {
get(key) {
if (section === 'chat.subagents' && key === 'allowInvocationsFromSubagents') return allow;
return undefined;
},
};
},
},
};
}
test('registerSubagentDispatch reports maxDepth matching capabilities/vscode/capability.json (dispatch.maxDepth:5)', () => {
const cap = require('../capabilities/vscode/capability.json');
const wiring = extension.registerSubagentDispatch(mockVscodeWithSubagentSupport(true));
assert.equal(wiring.maxDepth, cap.runtime.hostIntegration.dispatch.maxDepth,
'the extension-side depth cap must mirror the descriptor-declared maxDepth');
assert.equal(extension.GSD_MAX_SUBAGENT_DEPTH, cap.runtime.hostIntegration.dispatch.maxDepth);
});
test('registerSubagentDispatch: available:true when chat.subagents.allowInvocationsFromSubagents is configured', () => {
const wiring = extension.registerSubagentDispatch(mockVscodeWithSubagentSupport(true));
assert.equal(wiring.available, true);
assert.equal(typeof wiring.dispatchAsSubagent, 'function');
});
test('registerSubagentDispatch: available:false (fail-soft, no throw) when the setting is absent (older/Insiders-gated VS Code)', () => {
assert.doesNotThrow(() => {
const wiring = extension.registerSubagentDispatch({});
assert.equal(wiring.available, false);
});
});
test('registerSubagentDispatch: available:false (fail-soft) when vscode.workspace.getConfiguration itself throws', () => {
const throwingVscode = {
workspace: { getConfiguration() { throw new Error('simulated host failure'); } },
};
assert.doesNotThrow(() => {
const wiring = extension.registerSubagentDispatch(throwingVscode);
assert.equal(wiring.available, false);
});
});
test('REACHABILITY: a background-eligible dispatchAsSubagent call at depth 0 dispatches through the shared hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', cwd: dir, depth: 0,
}));
assert.equal(result.ok, true);
const parsed = JSON.parse(result.stdout);
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (engine reached)');
} finally {
cleanup(dir);
}
});
// #2103 FIX (adversarial review, MINOR — boundary gap): the repo's own
// TESTING-STANDARDS mandate exercising limit-1/limit/limit+1 around a boundary.
// The maxDepth:5 ceiling previously covered only 5 (limit) and 6 (limit+1) —
// this completes the triple with depth 4 (limit-1).
test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 4 (limit-1) still dispatches', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', cwd: dir, depth: 4,
}));
assert.equal(result.ok, true, 'depth one below the ceiling must be allowed');
} finally {
cleanup(dir);
}
});
test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 5 (at limit) still dispatches', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', cwd: dir, depth: 5,
}));
assert.equal(result.ok, true, 'depth exactly at the ceiling must still be allowed');
} finally {
cleanup(dir);
}
});
test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 6 (over limit) is refused, never throws', async () => {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', depth: 6,
}));
assert.equal(result.ok, false);
assert.match(result.stderr, /exceeds maxDepth/);
assert.equal(result.code, null);
});
test('dispatchAsSubagent defaults depth to 0 when omitted (a direct, non-nested call is always allowed)', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({ family: 'progress', subcommand: 'json', cwd: dir }));
assert.equal(result.ok, true);
} finally {
cleanup(dir);
}
});
// ── Web mode: #runSubagent availability detection is registered identically,
// but there is no dispatchAsSubagent on web (no engine dispatch at all — see
// browser.js's header comment). ─────────────────────────────────────────────
test('WEB MODE: browser.js detectSubagentSupport uses the same fail-soft availability contract', () => {
const browser = require('../vscode/browser.js');
assert.doesNotThrow(() => {
const wiring = browser.detectSubagentSupport(mockVscodeWithSubagentSupport(true));
assert.equal(wiring.available, true);
});
assert.doesNotThrow(() => {
const wiring = browser.detectSubagentSupport({});
assert.equal(wiring.available, false);
});
});