Skip to content

Commit d1d8b49

Browse files
authored
feat: graduation machinery wave B — external execution (ak run drives an admitted host) (ADR-0031 P2) (#155)
* feat(adapters): manifest execution block + hook-runner stdin/cwd (ADR-0031 P2, schema half) Manifest gains an optional execution.run hook, coupled to canRouteActivities (else refused 'execution-not-routable'); it is part of the hashed content. runAdapterHook gains stdin (EPIPE-safe), an absolute-validated cwd, and separate stdoutText/stderrText captures — the substrate the derived execution adapter needs to run a hook without inheriting ak's cwd or promoting stderr into a downstream prompt. * feat(execution): ak run drives an admitted external host as a supervised subprocess (ADR-0031 P2) executionAdapterFor falls through to a manifest-derived subprocess adapter for an admitted host; routing is overlay-aware via a lazy effectiveRoutableHostIds(). No third-party code runs in-process — the hook is one subprocess ak owns. Security-hardened across an adversarial review (blockers + regressions all re-verified closed): - hooks spawn with cwd pinned to the adapter's own resolved directory, never the operator's cwd; a relative hook on a remote (unanchorable) source is refused ('execution-unanchored'). The consent hash still pins the manifest text; resolution is a pure function of it plus the pinned source. - AK_WORKER_CWD carries the target repo without reopening that pin - an unresolved-launch cancellation reports non-escalating 'orphaned', never an escalatable 'timed_out' (no double-run) - the structured payload is parsed from stdout alone, so a stray stderr line never collapses a worker's handoff or leaks logs into a cross-vendor prompt - reserved hook exit codes 77/78 express permission_required/auth_required - a self-declared provider is stamped 'inferred', handoff data is redacted from public results, and driving.surfaces must include cli-subprocess (no downgrade) The black-box conformance test now drives the real command resolver end-to-end: a genuinely spawned subprocess, cwd anchored by production code. * docs(adr): ADR-0029 cli-subprocess execution contract; ADR-0031 external-execution row Working Records the command-resolution policy, reserved exit codes, and the no-trust-laundering rules settled while wiring external execution, plus the honest boundary of the remote-source anchorability screen.
1 parent f54e841 commit d1d8b49

15 files changed

Lines changed: 1546 additions & 54 deletions

docs/adr/0029-host-adapter-extension-point.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,45 @@ names:
141141
An adapter manifest declaring `acp` or `mcp` today fails admission with an explicit "surface not yet
142142
supported" diagnostic. It is never silently downgraded to `cli-subprocess` — a silent downgrade
143143
would run a hook the adapter author never tested against that surface, exactly the kind of guessed
144-
success ADR-0016 §5 and ADR-0023 already forbid elsewhere.
144+
success ADR-0016 §5 and ADR-0023 already forbid elsewhere. An admitted host is given a
145+
`cli-subprocess` **execution** adapter only when it declares that surface; a manifest missing it is
146+
refused (`surface-unsupported`) rather than downgraded.
147+
148+
**`cli-subprocess` execution details** (settled while wiring [ADR-0031](0031-capability-graduation-and-upstream-requests.md)'s
149+
external-execution row, after an adversarial review of the surface):
150+
151+
- **Command resolution is anchored, not ambient.** A hook subprocess runs with its working
152+
directory pinned to the adapter's own resolved directory (a file-sourced manifest's `realpath`
153+
directory), never `ak`'s current working directory — so a manifest declaring
154+
`["node", "run-hook.mjs"]` runs *the adapter's* `run-hook.mjs`, and a file planted in the
155+
operator's cwd is unreachable. A remote-sourced manifest (`npm:`/`https://`) has no persistent
156+
local directory, so a *relative* hook command from such a source is refused
157+
(`execution-unanchored`) rather than resolved against an ambient path; a bare PATH binary
158+
(`node`, `hermes`) stays legal. The consent hash still pins the manifest text verbatim; the
159+
resolution is a pure function of that text plus the (already-pinned) source, so it cannot drift
160+
without the hash changing.
161+
- *Boundary of the anchorability check for remote sources.* When a remote-sourced adapter has no
162+
local directory to anchor to, its hook command spawns in the repository `ak run` was invoked in
163+
(which the operator already runs at full trust, per ADR-0018), and the `execution-unanchored`
164+
refusal is a **best-effort** screen for path-shaped tokens (separators, script extensions, flag
165+
values), not a complete one: an *extensionless, separator-free* relative token
166+
(`["node", "runhook"]`) is indistinguishable by inspection from an ordinary positional argument
167+
(`["hermes-run", "build"]`), so it is not refused and would resolve against the repo. A complete
168+
rule would have to reject every non-absolute, non-flag argument, which would also reject
169+
legitimate positional arguments — a false-positive cost this contract does not pay by default.
170+
The exposure is bounded on every axis that matters: it requires a remote (`npm:`/`https://`)
171+
source, a consented manifest the operator hash-pinned with that exact relative token, and write
172+
access to the operator's repo. A **file-sourced** adapter — the fixture, and every adapter that
173+
ships a bundle — is fully anchored and unaffected. A remote-sourced adapter should declare
174+
absolute paths or PATH binaries; a future contract revision may make that a hard requirement.
175+
- **Reserved exit codes carry consent/auth boundaries.** Hook exit `77` maps to
176+
`permission_required` (a blocked, never-escalated result — escalating around a consent boundary
177+
is the safety violation ADR-0019 already forbids) and `78` to `auth_required`. This gives an
178+
external host an honest way to say "I refused" or "I am not logged in" instead of a bare
179+
non-zero exit that would be re-run on another host.
180+
- **Results never launder trust.** Exit code is the sole authority for success; a self-declared
181+
`provider` in hook stdout is stamped `inferred`, never `observed`; stderr is never promoted into
182+
a downstream worker's prompt; and handoff data is redacted from public `WorkerResult`s.
145183

146184
### 3. Capability caps are schema-structural, not runtime-checked
147185

docs/adr/0031-capability-graduation-and-upstream-requests.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,11 @@ Per the ADR discipline this repository adopted (a dated, self-graded table befor
149149
rests on delivery): the **governance decision** is accepted; the **machinery** is staged and mostly
150150
unbuilt. This table is the source of truth for what is real.
151151

152-
| Piece | Status (2026-08-16) | Note |
152+
| Piece | Status | Note |
153153
| ----- | ------------------- | ---- |
154154
| Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) |
155155
| `ak host adapters trust` CLI (records consent/grants) | **Working** (2026-08-16, wave A) | `list`/`trust`/`revoke` + `--expect-hash` pinning; disclosure prints the full validated manifest (control-char-safe); mirrors every pre-hash admission refusal; `revoke` works with the flag off (fail-safe) |
156-
| External execution (`ak run` drives an admitted host) | **Proposed — not built** | The seam is a comment-only lookup today |
156+
| External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` |
157157
| External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized |
158158
| Tiered conformance harness (`session-driving``statusline`) | **Proposed — not built** | Extends the single conformance kit |
159159
| Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave |

src/lib/adapters/admission.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,36 @@
77
// entry or the built-in registries (try/caught per entry, in admitOne AND as
88
// a belt-and-suspenders net in admitAdapters).
99
import { createHash } from 'node:crypto';
10+
import fs from 'node:fs';
11+
import path from 'node:path';
1012
import { HOST_REGISTRY } from './registries.mjs';
1113
import { validateAdapterManifest } from './manifest.mjs';
1214

1315
export const SUPPORTED_CONTRACT = 1;
1416

17+
/** The adapter's own directory (F-1, ADR-0031): where its execution/lifecycle
18+
* hooks resolve a relative command FROM, never the operator's process.cwd()
19+
* when `ak run` was invoked. A file-sourced manifest anchors to its own
20+
* directory — `fs.realpathSync` so a symlinked manifest can't relocate that
21+
* pin out from under consent. An npm/https source has no persistent local
22+
* bundle (resolved, hashed, and discarded per admission pass — sources.mjs)
23+
* so there is nothing to anchor to: `null`. buildAdmittedExecutionAdapter
24+
* (execution/admitted.mjs) then refuses a relative hook command outright
25+
* for a `null` baseDir rather than guessing a cwd. An unreadable/vanished
26+
* file source also resolves to `null` — the same honest refusal, not a
27+
* silent fallback to process.cwd(). */
28+
function baseDirForSource(source) {
29+
if (typeof source !== 'string' || !source
30+
|| source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) {
31+
return null;
32+
}
33+
try {
34+
return path.dirname(fs.realpathSync(source));
35+
} catch {
36+
return null;
37+
}
38+
}
39+
1540
/** Deterministic, key-sorted JSON — same stable-stringify shape used
1641
* elsewhere in this codebase (e.g. opencode.mjs's deepEqual) so two manifests
1742
* that differ only in key order or incidental whitespace hash identically.
@@ -193,6 +218,47 @@ export async function bootstrapHostAdapters({
193218
if (admitted.length) {
194219
const { applyAdmitted } = await import('./admitted.mjs');
195220
applyAdmitted(admitted);
221+
222+
// P2 (ADR-0031): an admitted manifest declaring both an execution block
223+
// and host.capabilities.canRouteActivities gets its execution adapter
224+
// derived and registered here, so `ak run` can route to it. Same
225+
// guarded, non-fatal posture as the rest of bootstrap: one adapter's
226+
// registration failure never blocks the others or the admission result.
227+
const executionCandidates = admitted.filter((result) => (
228+
result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true
229+
));
230+
if (executionCandidates.length) {
231+
// name -> the cfg entry's own declared source, for F-1's baseDir
232+
// derivation below (admitted results carry the validated manifest, not
233+
// the raw cfg entry that named where it came from).
234+
const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source]));
235+
try {
236+
const { registerAdmittedExecution } = await import('../execution/admitted.mjs');
237+
for (const result of executionCandidates) {
238+
// F-5 (ADR-0029 §2): a manifest that never declared the
239+
// cli-subprocess driving surface gets no cli-subprocess execution
240+
// adapter — refused with its own reason, before even attempting
241+
// registration (buildAdmittedExecutionAdapter re-checks this too,
242+
// defence-in-depth for any caller that bypasses this filter).
243+
if (!result.manifest?.driving?.surfaces?.includes('cli-subprocess')) {
244+
warnings.push({
245+
name: result.name, reason: 'surface-unsupported',
246+
detail: `'${result.name}' declares an execution block but not driving.surfaces including 'cli-subprocess'`,
247+
});
248+
continue;
249+
}
250+
try {
251+
registerAdmittedExecution(result.manifest, { baseDir: baseDirForSource(sourceByName.get(result.name)) });
252+
} catch (error) {
253+
warnings.push({ name: result.name, reason: error?.reason ?? 'execution-registration-failed', detail: error?.message ?? String(error) });
254+
}
255+
}
256+
} catch (error) {
257+
for (const result of executionCandidates) {
258+
warnings.push({ name: result.name, reason: 'execution-registration-failed', detail: error?.message ?? String(error) });
259+
}
260+
}
261+
}
196262
}
197263

198264
return { active: true, admitted, warnings };

src/lib/adapters/admitted.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,15 @@ export function effectiveHostRegistry() {
6262
if (!applied || admittedEntries.length === 0) return HOST_REGISTRY;
6363
return Object.freeze([...HOST_REGISTRY, ...admittedEntries]);
6464
}
65+
66+
/** Built-ins ∪ admitted hosts whose manifest declares
67+
* capabilities.canRouteActivities — the LAZY set every routing VALIDATION
68+
* path (routing.mjs's isRoutableHost, validateRoute, materializeRunPlan)
69+
* must consult (P2, ADR-0031). routing.mjs's `HOSTS` constant stays frozen
70+
* at import time and built-ins-only — it is display strings only now, never
71+
* a validation source. Fresh on every call, like admittedHostIds() above. */
72+
export function effectiveRoutableHostIds() {
73+
return effectiveHostRegistry()
74+
.filter((host) => host.capabilities.canRouteActivities === true)
75+
.map((host) => host.id);
76+
}

src/lib/adapters/hook-runner.mjs

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// summary capture, no graceful-then-forced two-step shutdown. A timed-out
99
// adapter hook gets no cleanup grace period; it already spent its budget.
1010
import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process';
11+
import { isAbsolute as pathIsAbsolute } from 'node:path';
1112

1213
const DEFAULT_TIMEOUT_MS = 30_000;
1314
const OUTPUT_CAP_BYTES = 256 * 1024;
@@ -88,6 +89,15 @@ function mergeCapture(stdout, stderr) {
8889
return `${kept}${TRUNCATION_MARKER}`;
8990
}
9091

92+
/** stderr alone, with the same per-stream truncation marker `mergeCapture`
93+
* would append — never folded into `stdout`. F-4: a caller that parses
94+
* `stdout` as a structured payload (e.g. the admitted execution adapter)
95+
* must never see raw stderr promoted into that parse; this is the field it
96+
* reads instead when it needs the process's diagnostic chatter. */
97+
function boundedText({ text, truncated }) {
98+
return truncated ? `${text}${TRUNCATION_MARKER}` : text;
99+
}
100+
91101
function describeFailure(hostId, verb, error) {
92102
const reason = error?.code ? `${error.code} (${error.message ?? 'no message'})` : (error?.message ?? String(error));
93103
return `${hostId}:${verb} adapter hook failed to start: ${reason}`;
@@ -104,7 +114,12 @@ function raceTimeout(promise, ms) {
104114
* child — a timed-out adapter hook may have spawned descendants of its own.
105115
* POSIX: the child was spawned detached so its pid is also its process group
106116
* id; signalling `-pid` reaches the whole group. Windows has no portable
107-
* signal for arbitrary console trees, so `taskkill /T /F` owns it there. */
117+
* signal for arbitrary console trees, so `taskkill /T /F` owns it there.
118+
* F-2: this cannot PROVE a double-forked or re-`setsid`'d grandchild died —
119+
* a signal sent is not a death confirmed. Callers that need that proof (the
120+
* admitted execution adapter's cancel path) must treat an unresolved launch
121+
* as honestly unproven (`orphaned`), not assume this function's return means
122+
* the tree is gone. */
108123
async function killGroup(child) {
109124
if (!Number.isInteger(child?.pid)) return;
110125
if (isWindows) {
@@ -127,38 +142,71 @@ async function killGroup(child) {
127142
* (missing/invalid `hook`, `hostId`, or `verb`) throw synchronously.
128143
*
129144
* @param {{hook:{command:string[], timeoutMs?:number}, hostId:string,
130-
* verb:string, timeoutMs?:number, env?:Record<string,string>}} options
131-
* @returns {Promise<{ok:boolean, stdout:string, exitCode:number|null, detail:string|null}>}
145+
* verb:string, timeoutMs?:number, env?:Record<string,string>, stdin?:string,
146+
* cwd?:string}} options
147+
* @returns {Promise<{ok:boolean, stdout:string, stdoutText:string, stderrText:string,
148+
* exitCode:number|null, detail:string|null}>}
132149
*/
133-
export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @type {any} */ ({})) {
150+
export async function runAdapterHook({
151+
hook, hostId, verb, timeoutMs, env, stdin, cwd,
152+
} = /** @type {any} */ ({})) {
134153
if (!hook || !Array.isArray(hook.command) || hook.command.length === 0
135154
|| !hook.command.every((part) => typeof part === 'string' && part.length > 0)) {
136155
throw new TypeError('runAdapterHook requires hook.command as a non-empty array of non-empty strings');
137156
}
138157
if (typeof hostId !== 'string' || !hostId) throw new TypeError('runAdapterHook requires a hostId');
139158
if (typeof verb !== 'string' || !verb) throw new TypeError('runAdapterHook requires a verb');
159+
// F-1: a relative cwd would resolve against wherever the ak process
160+
// happens to be running, defeating the whole point of pinning the child to
161+
// the adapter's own directory — refused synchronously, same class as the
162+
// arg-shape checks above, never silently reinterpreted as "inherit".
163+
if (cwd !== undefined && (typeof cwd !== 'string' || !cwd || !pathIsAbsolute(cwd))) {
164+
throw new TypeError('runAdapterHook requires cwd to be an absolute path when provided');
165+
}
140166

141167
const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs);
142168
const [argv0, ...args] = hook.command;
143169
const childEnv = minimalEnv(env);
144170

171+
const wantsStdin = typeof stdin === 'string';
145172
let child;
146173
try {
147174
child = nodeSpawn(argv0, args, {
148175
env: childEnv,
149176
shell: false,
150-
stdio: ['ignore', 'pipe', 'pipe'],
177+
stdio: [wantsStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
151178
detached: !isWindows,
179+
// Absent cwd falls through to Node's own default (inherit
180+
// process.cwd()) — today's behavior for callers that don't pass one
181+
// yet (B2 threads the real adapter-base-dir cwd through this wave).
182+
...(cwd === undefined ? {} : { cwd }),
152183
});
153184
} catch (error) {
154-
return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) };
185+
return {
186+
ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, error),
187+
};
155188
}
156189

157190
const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES);
158191
const stderrCollector = boundedCollector(OUTPUT_CAP_BYTES);
159192
child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk));
160193
child.stderr?.on('data', (chunk) => stderrCollector.write(chunk));
161194

195+
if (wantsStdin) {
196+
// A child that exits before (or without) reading stdin makes the pipe
197+
// write EPIPE — that is a normal outcome (the process's own exit code
198+
// already reports what happened), never a reason to crash or reject
199+
// runAdapterHook's promise. The 'close' handler below still fires and
200+
// resolves the race normally regardless of whether this write lands.
201+
child.stdin?.on('error', () => {});
202+
try {
203+
child.stdin?.end(stdin);
204+
} catch {
205+
// Synchronous throw from an already-closed stream — same non-fatal
206+
// treatment as the async 'error' event above.
207+
}
208+
}
209+
162210
let settled = false;
163211
let spawnError = null;
164212
const closeResult = new Promise((resolve) => {
@@ -175,27 +223,40 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /*
175223
if (raced === TIMEOUT_SENTINEL) {
176224
await killGroup(child);
177225
await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused
226+
const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() };
227+
const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() };
178228
return {
179229
ok: false,
180230
exitCode: null,
181-
stdout: mergeCapture(
182-
{ text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
183-
{ text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
184-
),
231+
stdout: mergeCapture(stdoutCaptured, stderrCaptured),
232+
stdoutText: boundedText(stdoutCaptured),
233+
stderrText: boundedText(stderrCaptured),
185234
detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`,
186235
};
187236
}
188237

189238
if (spawnError) {
190-
return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError) };
239+
return {
240+
ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError),
241+
};
191242
}
192243

193244
const { code } = raced;
194-
const stdout = mergeCapture(
195-
{ text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
196-
{ text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
197-
);
245+
// F-4/R-1: stdout stays the combined stream for diagnostics/back-compat,
246+
// but a caller parsing stdout as a structured payload (the admitted
247+
// execution adapter) must read stdoutText instead — stdout has stderr
248+
// folded in after a separator, which breaks JSON.parse the instant the
249+
// hook writes anything to stderr at all. stderrText is diagnostics-only.
250+
const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() };
251+
const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() };
252+
const stdout = mergeCapture(stdoutCaptured, stderrCaptured);
253+
const stdoutText = boundedText(stdoutCaptured);
254+
const stderrText = boundedText(stderrCaptured);
198255
return code === 0
199-
? { ok: true, stdout, exitCode: 0, detail: null }
200-
: { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` };
256+
? {
257+
ok: true, stdout, stdoutText, stderrText, exitCode: 0, detail: null,
258+
}
259+
: {
260+
ok: false, stdout, stdoutText, stderrText, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`,
261+
};
201262
}

0 commit comments

Comments
 (0)