* test(#2686): failing-first parity guard for Workflow-backend model threading The Workflow backend emitted every agent() call with no model, so model_overrides / model_policy / model_profile were silently inert on that path while the inline path honored them (ADR-1411). Neither existing suite contained the string 'model' at all. The centrepiece derives BOTH sides from resolveModelInternal(cwd,'gsd-executor') rather than hardcoding either, so it asserts backend parity rather than a fixed string. Also covers: omit-on-inherit/empty (#2517), byte-identical output when nothing resolves, the #2772/#2285 per-plan worktree gate, adversarial model ids reaching the code generator, the #2285 composed seam, CLI config-defaulting, and a fast-check round-trip property. RED expected: no model key is emitted anywhere, and --executor-model does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2686): thread the resolved executor model into the Workflow backend The Workflow backend emitted every agent() call with no model at all, so model_overrides / model_policy / model_profile_overrides / model_profile were silently inert on that path while the inline path honored all of them. The model was not dropped at the last step — it was absent from the whole seam: agentOptions() took no model, EmitInput had no field to carry one, and ResolveWaveDispatchInput (the #2285 seam the orchestrator actually calls) could not forward one. The generated script asserted the parity it broke. VERIFY-FIRST, which #2686 flags as the question that decides the fix: the Workflow tool's agent() DOES accept a per-call model. Its documented signature is agent(prompt, opts?: { label?, phase?, schema?, model?, effort?, isolation?, agentType? }) so fix branch 1 applies and branch 2 (declare model routing unavailable) is ruled out. ADR-1143:24's option enumeration omitting `model` is an incomplete enumeration, not a decision to exclude it. - agentOptions(p, executorModel) emits `model` only when it is a non-empty string that is not "inherit" (#2517: an empty model 404s on runtimes without native tier aliases). A non-string is a malformed config: omit, never throw. - executorModel threaded through EmitInput and ResolveWaveDispatchInput. - The CLI resolves gsd-executor from project config by DEFAULT rather than requiring a flag, reading the same source the inline path reads. An orchestrator that never learns about a new flag would otherwise silently keep the old bug. --executor-model exists only to pin/override. - ADR-1411 provenance: the generated header now states which model was applied, or that none resolved and why. A fallback must be a visible value. Compatibility: when nothing resolves, the emitted options object is byte-identical to before, so every existing caller and assertion is unaffected. Behavior change (Hyrum's Law): opted-in users move from session inheritance to the catalog-resolved executor model. Adding a `model` key also changes agent() opts, which invalidates the cached prefix of any in-flight resumeFromRunId run — a one-time re-execution. Both disclosed in the changeset. Fixes #2686 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2686): reject script-breaking model ids and share the emit predicate The isolated adversarial review found a BLOCKER in my own provenance comment, proven by execution (the emitted script exited 42 from an injected statement). U+2028/U+2029 are ECMAScript LineTerminators that END a `//` single-line comment in EVERY engine — the ES2019 change legalized them inside string LITERALS only. So quoteString (JSON.stringify) is sufficient for the `model: "..."` object literal but NOT for the `// model: ...` provenance line I added: a raw U+2028 in a model id closed the comment and made the rest of the line live top-level code. The value is reachable from `.planning/config.json` (model_overrides / model_policy), which `mapClaudeOverrideForRuntime` passes through verbatim on any non-claude runtime — attacker-influenceable in a cloned repo. `emitWorkflowScript` now rejects a string executorModel carrying any character in UNSCRIPTABLE_CHAR_RE — the same class `isScriptableIdentifier` already applied to phaseDir/runId, which is proof the codebase knew this hazard. Rejection is ok:false with a reason rather than a silent drop, and resolveWaveDispatch maps an emit failure to the inline backend WITH that reason, so the degradation is visible. A non-string stays on the existing defensive path (omit, never throw) — that is malformed config, not an injection attempt. Also from the reviews: - The predicate deciding "is this model emittable" was duplicated between the emission and the comment asserting it. Extracted to emittableModel() so a generated comment can never claim something the generator did not do — the exact failure class #2686 was filed for. - That predicate now trims and lower-cases before comparing, closing a real #2517-class gap: " " and "INHERIT" were previously emitted verbatim. - The adversarial test was pass-always against this very vulnerability — it asserted only that JSON.stringify appeared. Replaced with the real contract (rejection) plus an execution-level check that no LineTerminator survives into the comment. A raw U+2028 had also been committed into that test's fixture array where a tab was intended; both are now explicit \u escapes. - optionsOf in the test was /\{[^}]*\}/, which truncated at any brace a generated model contained — silently not testing what it claimed. Now brace- and string-aware. Stale-test corrections in tests/fix-2285-*: three assertions froze the exact options literal `{ agentType: "gsd-executor" }`. The object legitimately gained an optional additive `model` key, so they now assert the invariant they exist to protect (agentType present, isolation absent) rather than a frozen literal. The CLI-vs-pure equality test pins --executor-model on both sides; otherwise it compared a config-resolved CLI run against a pure call given no model. CONTEXT.md glossary updated for the changed emitWorkflowScript signature and the new rejection rule (CLAUDE.md: the glossary is a PR gate for core-module changes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * test(#2686): fix the options extractor and model the rejection path Two defects in my own test helper, caught by the full matrix: - optionsOf anchored on /\(\s*\{/ — a '(' immediately followed by '{'. The emitted shape is agent("brief", { ... }), so that never matched and the helper returned an empty array, making every assertion over it vacuously true. It now anchors on agent( and takes the first balanced, string-aware {...} after it. - The fast-check property predated the security fix and asserted ok:true for any generated string. Strings carrying an unscriptable character are now rejected, so the property models the real three-way contract: unscriptable -> ok:false; trims to empty or 'inherit' (any case) -> omitted; otherwise -> emitted as the trimmed value. Verified locally against the built module: omit values clean, both plans carry the model on the parity path, property passes 500 runs at seed 42. Test file re-scanned for raw hazardous codepoints — zero; the U+2028/U+2029 cases are explicit \u escapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * test(#2686): scope no-control-regex on the mirrored unscriptable-char class The class is the point of the assertion — those bytes are exactly what must be rejected — so the rule is disabled at that line rather than the class weakened. UNSCRIPTABLE_CHAR_RE is not exported from src/claude-orchestration.cts, hence the mirror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * chore(#2686): backfill changeset PR number Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
* `claude_orchestration.*` keys from .planning/config.json. Emits
|
||||
* { available, backend, reason }.
|
||||
*
|
||||
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
|
||||
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]
|
||||
* Reads a wave/plan manifest JSON file and emits the generated Workflow
|
||||
* script + summary. The manifest shape matches emitWorkflowScript's input:
|
||||
* { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }.
|
||||
@@ -31,7 +31,7 @@
|
||||
*
|
||||
* resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>]
|
||||
* [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
|
||||
* [--budget <n>]
|
||||
* [--budget <n>] [--executor-model <id>]
|
||||
* #2285 — the single composed seam a PRE-wave dispatch-backend selector
|
||||
* (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in
|
||||
* ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }.
|
||||
@@ -50,6 +50,8 @@ import core = require('./claude-orchestration.cjs');
|
||||
import configLoader = require('./config-loader.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import runtimeSlash = require('./runtime-slash.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
|
||||
import modelResolver = require('./model-resolver.cjs');
|
||||
|
||||
const { output } = io;
|
||||
const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core;
|
||||
@@ -67,8 +69,8 @@ function usage(error: (msg: string, reason?: string) => void): void {
|
||||
error(
|
||||
'Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
|
||||
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]\n' +
|
||||
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>]',
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]\n' +
|
||||
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +166,31 @@ function resolveDetectionArgs(args: string[], cwd?: string): { runtimeId: string
|
||||
return { runtimeId, hostIntegration, agentSdkVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2686 — resolve the `gsd-executor` model this dispatch should carry.
|
||||
*
|
||||
* Defaults from the project config rather than requiring a flag. The Workflow
|
||||
* backend previously emitted no model at all, so `model_overrides` /
|
||||
* `model_policy` / `model_profile` were silently inert on that path while the
|
||||
* inline path honored them. Reading the same source the inline path reads is
|
||||
* what makes the two backends agree by construction: an orchestrator that never
|
||||
* learns about a new flag would otherwise silently keep the old bug.
|
||||
*
|
||||
* `--executor-model` exists only to pin/override. Resolution is side-effect-free
|
||||
* and fails closed to `undefined` (emission then omits the key, i.e. exactly the
|
||||
* pre-#2686 output) rather than guessing a model.
|
||||
*/
|
||||
function resolveExecutorModel(args: string[], cwd: string): string | undefined {
|
||||
const pinned = argValue(args, '--executor-model');
|
||||
if (pinned !== undefined) return pinned;
|
||||
try {
|
||||
const resolved = modelResolver.resolveModelInternal(cwd, 'gsd-executor') as unknown;
|
||||
return typeof resolved === 'string' ? resolved : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Discriminated result for readWavesManifest — see doc comment below. */
|
||||
type WavesReadResult =
|
||||
| { ok: true; waves: unknown }
|
||||
@@ -208,7 +235,7 @@ function cmdDetectBackend(args: string[], cwd: string, raw: boolean): void {
|
||||
/**
|
||||
* Emit a Workflow script from a wave/plan manifest file.
|
||||
*/
|
||||
function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg: string, reason?: string) => void): void {
|
||||
function cmdEmitWorkflow(args: string[], cwd: string, raw: boolean, error: (msg: string, reason?: string) => void): void {
|
||||
const wavesPath = argValue(args, '--waves');
|
||||
const runId = argValue(args, '--run-id');
|
||||
const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
|
||||
@@ -234,6 +261,7 @@ function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg
|
||||
runId,
|
||||
waves: read.waves as EmitInput['waves'],
|
||||
budgetTokens: budget,
|
||||
executorModel: resolveExecutorModel(args, cwd),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -282,6 +310,7 @@ function cmdResolveWaveDispatch(args: string[], cwd: string, raw: boolean, error
|
||||
runId,
|
||||
waves: read.waves as EmitInput['waves'],
|
||||
budgetTokens: budget,
|
||||
executorModel: resolveExecutorModel(args, cwd),
|
||||
});
|
||||
|
||||
output(result, raw);
|
||||
|
||||
@@ -283,6 +283,14 @@ interface EmitInput {
|
||||
waves: Wave[];
|
||||
runId: string;
|
||||
budgetTokens?: number;
|
||||
/**
|
||||
* #2686 — the resolved `gsd-executor` model, threaded so this backend honors
|
||||
* model_overrides / model_policy / model_profile exactly as the inline path
|
||||
* does. Omitted from the emitted options when it is `"inherit"`, empty, or not
|
||||
* a string (#2517: an empty model 404s on runtimes without native tier
|
||||
* aliases). When absent the emitted script is byte-identical to pre-#2686.
|
||||
*/
|
||||
executorModel?: string;
|
||||
}
|
||||
|
||||
interface EmitOk {
|
||||
@@ -351,16 +359,48 @@ function quoteString(s: string): string {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2686 — the single decision of whether a resolved executor model is emittable,
|
||||
* and what to emit. Shared by `agentOptions` (the emission) and the provenance
|
||||
* comment (the claim about it) so the two can never disagree — a generated
|
||||
* comment asserting something the generator does not actually do is the exact
|
||||
* failure #2686 was filed for.
|
||||
*
|
||||
* Returns the model to emit, or `undefined` for "emit nothing":
|
||||
* - non-string → malformed config; omit rather than throw, matching the
|
||||
* defensive typeof guard `mapClaudeOverrideForRuntime`
|
||||
* already carries in model-resolver for the same reason.
|
||||
* - empty/whitespace → #2517: emitting `model: ""` 404s on runtimes without
|
||||
* native tier aliases. Trimmed, so `" "` is also "none".
|
||||
* - "inherit" → same rule; matched case-insensitively after trimming,
|
||||
* since config is user-authored free text.
|
||||
*
|
||||
* NOTE it does NOT reject unscriptable characters — that is a hard input error,
|
||||
* not a silent omission, and is rejected up front by `emitWorkflowScript` so the
|
||||
* caller sees a reason instead of quietly losing their model routing.
|
||||
*/
|
||||
function emittableModel(executorModel: unknown): string | undefined {
|
||||
if (typeof executorModel !== 'string') return undefined;
|
||||
const trimmed = executorModel.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
if (trimmed.toLowerCase() === 'inherit') return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `agent()` options object for a single plan — `isolation: "worktree"`
|
||||
* ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285
|
||||
* finding 1). This is the single place that decides worktree isolation for the
|
||||
* Workflow backend; it must never diverge from the inline path's per-plan gate.
|
||||
*/
|
||||
function agentOptions(p: Plan): string {
|
||||
return p.use_worktree === false
|
||||
? '{ agentType: "gsd-executor" }'
|
||||
: '{ agentType: "gsd-executor", isolation: "worktree" }';
|
||||
function agentOptions(p: Plan, executorModel?: unknown): string {
|
||||
const parts = ['agentType: "gsd-executor"'];
|
||||
if (p.use_worktree !== false) parts.push('isolation: "worktree"');
|
||||
// #2686: carry the resolved executor model so this backend honors
|
||||
// model_overrides / model_policy / model_profile exactly as the inline path.
|
||||
const model = emittableModel(executorModel);
|
||||
if (model !== undefined) parts.push('model: ' + quoteString(model));
|
||||
return '{ ' + parts.join(', ') + ' }';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,7 +429,7 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE
|
||||
if (input === null || input === undefined || typeof input !== 'object') {
|
||||
return { ok: false, reason: 'invalid_input' };
|
||||
}
|
||||
const { phaseDir, waves, runId } = input;
|
||||
const { phaseDir, waves, runId, executorModel } = input;
|
||||
// Identifiers/paths interpolated into the generated script must be free of any
|
||||
// character that could terminate a comment, break out of a string literal, or
|
||||
// smuggle control bytes — reject up front (security: #1143 review Finding 1).
|
||||
@@ -399,6 +439,19 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE
|
||||
if (!isScriptableIdentifier(runId)) {
|
||||
return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' };
|
||||
}
|
||||
// #2686 security: the resolved model is interpolated into BOTH an object
|
||||
// literal (safe under quoteString) and a `//` provenance comment (NOT safe
|
||||
// under quoteString — U+2028/U+2029 are LineTerminators that end a single-line
|
||||
// comment in every engine, so a hostile model id would make the rest of the
|
||||
// line live code). Reject the whole emission rather than silently dropping the
|
||||
// model: an unscriptable id is malformed input, and `resolveWaveDispatch` maps
|
||||
// an emit failure to the inline backend WITH a reason, so the user sees it.
|
||||
// Only a STRING carrying such a character is rejected. A non-string is a
|
||||
// malformed config rather than an injection attempt, and stays on the existing
|
||||
// defensive path: `emittableModel` omits it and emission proceeds.
|
||||
if (typeof executorModel === 'string' && UNSCRIPTABLE_CHAR_RE.test(executorModel)) {
|
||||
return { ok: false, reason: 'executorModel must not contain newlines/quotes/backslash/control/line-separator chars' };
|
||||
}
|
||||
if (!Array.isArray(waves) || waves.length === 0) {
|
||||
return { ok: false, reason: 'waves must be a non-empty array' };
|
||||
}
|
||||
@@ -477,6 +530,25 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE
|
||||
lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)');
|
||||
lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)');
|
||||
lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).');
|
||||
// #2686 / ADR-1411: state which model was applied — or that none resolved —
|
||||
// so an opted-in user can SEE the routing decision instead of having to read
|
||||
// the emitted options. A fallback must be a visible value, never silent.
|
||||
//
|
||||
// SECURITY: this is a `//` comment, and U+2028/U+2029 are ECMAScript
|
||||
// LineTerminators that END a single-line comment in every engine — the ES2019
|
||||
// change legalized them inside string LITERALS only, so `quoteString` alone is
|
||||
// NOT sufficient here even though it is sufficient in the object literal
|
||||
// above. An unscriptable model id would otherwise close the comment and make
|
||||
// the remainder live top-level code. `emitWorkflowScript` rejects such ids
|
||||
// before reaching this point (see the validation above), which is what makes
|
||||
// interpolating here safe.
|
||||
const provenanceModel = emittableModel(executorModel);
|
||||
if (provenanceModel !== undefined) {
|
||||
lines.push('// model: ' + quoteString(provenanceModel) + ' (resolved for gsd-executor, same source as the inline path)');
|
||||
} else {
|
||||
lines.push('// model: none applied — resolved to "inherit"/empty, so each agent inherits the');
|
||||
lines.push('// orchestrator model (#2517: emitting an empty model 404s on some runtimes).');
|
||||
}
|
||||
lines.push('//');
|
||||
// resumeFromRunId is a Workflow TOOL INPUT parameter, not a script function —
|
||||
// calling it threw "resumeFromRunId is not defined" (#2590). The run id is
|
||||
@@ -517,7 +589,7 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE
|
||||
// parallel() could bound concurrency.
|
||||
lines.push('await parallel([');
|
||||
for (const p of stagePlans) {
|
||||
lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + '),');
|
||||
lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p, executorModel) + '),');
|
||||
}
|
||||
lines.push('])');
|
||||
}
|
||||
@@ -553,6 +625,8 @@ interface ResolveWaveDispatchInput {
|
||||
waves: Wave[];
|
||||
runId: string;
|
||||
budgetTokens?: number;
|
||||
/** #2686 — forwarded verbatim to `emitWorkflowScript`. */
|
||||
executorModel?: string;
|
||||
}
|
||||
|
||||
interface ResolveWaveDispatchInline {
|
||||
@@ -615,6 +689,7 @@ function resolveWaveDispatch(input: ResolveWaveDispatchInput | null | undefined)
|
||||
waves: input.waves,
|
||||
runId: input.runId,
|
||||
budgetTokens: input.budgetTokens,
|
||||
executorModel: input.executorModel,
|
||||
});
|
||||
|
||||
if (!emitted.ok) {
|
||||
|
||||
Reference in New Issue
Block a user