agent-relay-runner 0.122.1 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/claude.ts +5 -2
- package/src/codex-version.ts +63 -0
- package/src/config.ts +7 -0
- package/src/launch-assembly.ts +175 -9
- package/src/profile-projection.ts +19 -0
- package/src/provisioning.ts +59 -5
- package/src/relay-injection-events.ts +3 -0
- package/src/runner-helpers.ts +1 -1
- package/src/version.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.123.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-providers": "0.104.
|
|
24
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-providers": "0.104.4",
|
|
24
|
+
"agent-relay-sdk": "0.2.115",
|
|
25
25
|
"callmux": "0.23.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
package/src/adapters/claude.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { LivenessSignal } from "agent-relay-sdk";
|
|
|
12
12
|
import { collectClaudeSessionEvents } from "./claude-transcript";
|
|
13
13
|
import type { SessionEvent } from "../session-insights";
|
|
14
14
|
import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
|
|
15
|
-
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
|
|
15
|
+
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs, stripSettingsArgs } from "../launch-assembly";
|
|
16
16
|
import { claudeProviderMessageText } from "./claude-delivery";
|
|
17
17
|
import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
|
|
18
18
|
import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
|
|
@@ -294,10 +294,13 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
294
294
|
const providerArgs = config.headless
|
|
295
295
|
? claudeLaunchArgs(defaultArgs, config.providerArgs, config.approvalMode)
|
|
296
296
|
: [...defaultArgs, ...config.providerArgs];
|
|
297
|
+
// #1096 — when the assembler merged the caller's own `--settings` into relay's (so the
|
|
298
|
+
// baseline hooks/projectSettings/statusLine survive), drop the caller's now-redundant
|
|
299
|
+
// `--settings` here so exactly ONE reaches the launch (relay's merged copy in assembled.args).
|
|
297
300
|
const args = [
|
|
298
301
|
...rigPrefix,
|
|
299
302
|
...assembled.args,
|
|
300
|
-
...providerArgs,
|
|
303
|
+
...(assembled.callerSettingsConsumed ? stripSettingsArgs(providerArgs) : providerArgs),
|
|
301
304
|
];
|
|
302
305
|
if (config.model) args.push("--model", config.model);
|
|
303
306
|
// #352: seed the prompt as Claude's positional arg for ALL launches (headless included) —
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// #1096 review, finding 4 — Codex hooks (CODEX_HOME/hooks.json with Claude-compatible
|
|
2
|
+
// event names) are only consumed by Codex >= 0.124. An older Codex silently ignores the
|
|
3
|
+
// file, so writing it and reporting `provisioning.hooks: applied` would be a lie. This
|
|
4
|
+
// module probes the installed Codex version once and answers whether hook delivery is
|
|
5
|
+
// actually honored, so the assembler can emit a truthful projection (a LOUD warning when
|
|
6
|
+
// the guard cannot fire) instead of a silent absence.
|
|
7
|
+
//
|
|
8
|
+
// TODO(#1096): a post-deploy LIVE check is still owed — assert that a Codex >= 0.124 actually
|
|
9
|
+
// executes CODEX_HOME/hooks.json for these event names (Stop/PreToolUse/…), not just that the
|
|
10
|
+
// version string clears the gate. The version probe is necessary but not sufficient.
|
|
11
|
+
|
|
12
|
+
import { codexVersionOverrideFromEnv } from "./config";
|
|
13
|
+
|
|
14
|
+
// Codex releases from this version on ship stable hooks with Claude-compatible events.
|
|
15
|
+
const CODEX_HOOKS_MIN_VERSION = [0, 124, 0] as const;
|
|
16
|
+
|
|
17
|
+
let cachedProbe: boolean | undefined;
|
|
18
|
+
|
|
19
|
+
export function codexVersionSupportsHooks(version: string): boolean {
|
|
20
|
+
const parts = version.split(".").map((n) => parseInt(n, 10));
|
|
21
|
+
const major = Number.isFinite(parts[0]) ? parts[0]! : 0;
|
|
22
|
+
const minor = Number.isFinite(parts[1]) ? parts[1]! : 0;
|
|
23
|
+
const patch = Number.isFinite(parts[2]) ? parts[2]! : 0;
|
|
24
|
+
const [rMajor, rMinor, rPatch] = CODEX_HOOKS_MIN_VERSION;
|
|
25
|
+
if (major !== rMajor) return major > rMajor;
|
|
26
|
+
if (minor !== rMinor) return minor > rMinor;
|
|
27
|
+
return patch >= rPatch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseCodexVersion(output: string): string | null {
|
|
31
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
32
|
+
return match ? match[0] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function probeCodexVersion(command: string): string | null {
|
|
36
|
+
try {
|
|
37
|
+
const result = Bun.spawnSync([command, "--version"], { stdout: "pipe", stderr: "ignore" });
|
|
38
|
+
if (result.exitCode !== 0) return null;
|
|
39
|
+
return parseCodexVersion(result.stdout.toString());
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Whether the installed Codex actually consumes managed hooks. An explicit
|
|
46
|
+
// AGENT_RELAY_CODEX_VERSION override wins (deterministic tests + operator escape hatch);
|
|
47
|
+
// otherwise the real `codex --version` is probed once and cached. An UNKNOWN version (probe
|
|
48
|
+
// failed / Codex not installed) is treated as supported: we neither block launches nor cry
|
|
49
|
+
// wolf on a machine that can't be inspected — we only claim "not applied" when we can PROVE
|
|
50
|
+
// the version is too old.
|
|
51
|
+
export function codexHooksSupported(command = "codex"): boolean {
|
|
52
|
+
const override = codexVersionOverrideFromEnv();
|
|
53
|
+
if (override !== undefined) return override.trim() === "" ? true : codexVersionSupportsHooks(override.trim());
|
|
54
|
+
if (cachedProbe !== undefined) return cachedProbe;
|
|
55
|
+
const version = probeCodexVersion(command);
|
|
56
|
+
cachedProbe = version ? codexVersionSupportsHooks(version) : true;
|
|
57
|
+
return cachedProbe;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Test-only: drop the memoized probe so a suite can re-probe under a changed environment.
|
|
61
|
+
export function resetCodexHooksProbeCache(): void {
|
|
62
|
+
cachedProbe = undefined;
|
|
63
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -82,6 +82,13 @@ function relayUrlFromEnv(): string | undefined {
|
|
|
82
82
|
return process.env.AGENT_RELAY_URL;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// #1096 — override for the Codex hooks version gate (finding 4). An explicit version string
|
|
86
|
+
// bypasses the `codex --version` probe: deterministic in tests, and an operator escape hatch
|
|
87
|
+
// when the probe can't run. Empty string means "assume supported".
|
|
88
|
+
export function codexVersionOverrideFromEnv(): string | undefined {
|
|
89
|
+
return process.env.AGENT_RELAY_CODEX_VERSION;
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
function textEnvOrFile(name: string): string | undefined {
|
|
86
93
|
const inline = process.env[name];
|
|
87
94
|
if (inline) return inline;
|
package/src/launch-assembly.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
|
-
import type { AgentProfile, AgentProfileBase, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
|
|
3
|
+
import type { AgentProfile, AgentProfileBase, ProvisioningHookSet, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
|
|
4
4
|
import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
5
5
|
import {
|
|
6
6
|
profileAllowsRelayFeature,
|
|
@@ -17,17 +17,21 @@ import {
|
|
|
17
17
|
providerProvisioningHomePathFor,
|
|
18
18
|
} from "./profile-home";
|
|
19
19
|
import { getManifest } from "agent-relay-providers";
|
|
20
|
+
import { codexHooksSupported } from "./codex-version";
|
|
20
21
|
import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
|
|
21
22
|
import {
|
|
22
23
|
materializeResolvedAssets,
|
|
24
|
+
renderProvisionedHookSet,
|
|
23
25
|
resolveClaudeProjectSkills,
|
|
24
26
|
resolveClaudeHostSkills,
|
|
25
27
|
resolveProjectClaudeSettings,
|
|
26
28
|
resolveClaudeProvisionedPlugins,
|
|
27
29
|
resolveClaudeProvisionedSkills,
|
|
28
30
|
resolveCodexProvisionedSkills,
|
|
31
|
+
resolveProvisionedHooks,
|
|
29
32
|
resolveProjectMcpServers,
|
|
30
33
|
profileProvisioning,
|
|
34
|
+
writeCodexHooksJson,
|
|
31
35
|
} from "./provisioning";
|
|
32
36
|
import {
|
|
33
37
|
claudeMcpLaunchArgs,
|
|
@@ -134,6 +138,20 @@ export interface AssembledLaunch {
|
|
|
134
138
|
hostSkills: AssembledAsset[];
|
|
135
139
|
/** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
|
|
136
140
|
projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
|
|
141
|
+
/** Relay-provisioned hooks materialized for this launch. Empty when hooks were resolved
|
|
142
|
+
* but could not be delivered — see `hooksSuppressed` (the anti-lie invariant, #1096). */
|
|
143
|
+
hooks: AssembledAsset[];
|
|
144
|
+
/** #1096 review — set when this launch RESOLVED provisioned hooks (incl. a baseline safety
|
|
145
|
+
* guard) that could NOT be delivered: caller `--settings` was unmergeable (Claude), a
|
|
146
|
+
* host-base Codex agent has no managed CODEX_HOME, or the installed Codex is too old to
|
|
147
|
+
* consume hooks.json. Drives a LOUD projection warning so a baseline guard is never
|
|
148
|
+
* SILENTLY absent while the report says "applied". */
|
|
149
|
+
hooksSuppressed?: { reason: string; names: string[] };
|
|
150
|
+
/** #1096 review — Claude only: true when the caller's own `--settings` was folded into the
|
|
151
|
+
* relay-emitted `--settings` (relay hooks/projectSettings/statusLine merged in). The adapter
|
|
152
|
+
* must then STRIP the caller's `--settings` from the provider args so exactly one reaches the
|
|
153
|
+
* launch. False when relay emitted its own settings standalone or added none. */
|
|
154
|
+
callerSettingsConsumed: boolean;
|
|
137
155
|
/** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
|
|
138
156
|
plugins: AssembledAsset[];
|
|
139
157
|
/** Host/global provider plugin dirs explicitly inherited without enabling host MCP. */
|
|
@@ -196,17 +214,115 @@ function claudeStatusLineProbePath(): string {
|
|
|
196
214
|
return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
|
|
197
215
|
}
|
|
198
216
|
|
|
217
|
+
export interface ClaudeSettingsResolution {
|
|
218
|
+
args: string[];
|
|
219
|
+
/** The caller's own --settings was merged in and must be stripped from the provider args. */
|
|
220
|
+
callerSettingsConsumed: boolean;
|
|
221
|
+
/** Provisioned hook names that could NOT be delivered (caller --settings unmergeable). */
|
|
222
|
+
suppressedHookNames: string[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// #1096 review, finding 1 — when the caller (spawn providerArgs / provider defaultArgs) already
|
|
226
|
+
// carries `--settings`, DO NOT silently drop relay's settings (which used to skip the baseline
|
|
227
|
+
// safety hooks + projectSettings + statusLine while projection still claimed "applied"). Merge
|
|
228
|
+
// relay's settings into the caller's INLINE JSON so the guard survives, and signal the adapter to
|
|
229
|
+
// strip the caller's now-redundant `--settings`. If the caller's `--settings` is a FILE PATH or
|
|
230
|
+
// malformed JSON (unmergeable), relay's provisioned hooks are reported as SUPPRESSED (a loud
|
|
231
|
+
// projection warning) rather than a silent absence with an "applied" lie.
|
|
199
232
|
function claudeSettingsArgs(
|
|
200
233
|
projectSettings: Record<string, unknown>,
|
|
234
|
+
provisionedHooks: ProvisioningHookSet,
|
|
235
|
+
hookNames: string[],
|
|
201
236
|
includeStatusLine: boolean,
|
|
202
237
|
...argLists: string[][]
|
|
203
|
-
):
|
|
204
|
-
|
|
205
|
-
const settings = {
|
|
238
|
+
): ClaudeSettingsResolution {
|
|
239
|
+
const relaySettings: Record<string, unknown> = {
|
|
206
240
|
...projectSettings,
|
|
241
|
+
...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
|
|
207
242
|
...(includeStatusLine ? relayStatusLineSettings() : {}),
|
|
208
243
|
};
|
|
209
|
-
|
|
244
|
+
const hasProvisionedHooks = Object.keys(provisionedHooks).length > 0;
|
|
245
|
+
const caller = extractCallerSettings(argLists);
|
|
246
|
+
if (!caller) {
|
|
247
|
+
return {
|
|
248
|
+
args: Object.keys(relaySettings).length ? ["--settings", JSON.stringify(relaySettings)] : [],
|
|
249
|
+
callerSettingsConsumed: false,
|
|
250
|
+
suppressedHookNames: [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (caller.settings) {
|
|
254
|
+
const merged = mergeClaudeSettings(caller.settings, relaySettings);
|
|
255
|
+
return { args: ["--settings", JSON.stringify(merged)], callerSettingsConsumed: true, suppressedHookNames: [] };
|
|
256
|
+
}
|
|
257
|
+
// Unmergeable caller --settings (a file path or malformed JSON): relay cannot fold its
|
|
258
|
+
// settings in without clobbering the caller's file, so the provisioned hooks don't reach
|
|
259
|
+
// the launch. Report them suppressed (loud) — never claim "applied".
|
|
260
|
+
return { args: [], callerSettingsConsumed: false, suppressedHookNames: hasProvisionedHooks ? hookNames : [] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The caller's `--settings` value, parsed as inline JSON when possible. `settings` is null for a
|
|
264
|
+
// file-path / malformed value (an inline object is required to merge relay settings in).
|
|
265
|
+
function extractCallerSettings(argLists: string[][]): { settings: Record<string, unknown> | null } | null {
|
|
266
|
+
for (const list of argLists) {
|
|
267
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
268
|
+
const arg = list[i];
|
|
269
|
+
if (arg === "--settings") return { settings: parseInlineSettings(list[i + 1]) };
|
|
270
|
+
if (arg?.startsWith("--settings=")) return { settings: parseInlineSettings(arg.slice("--settings=".length)) };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseInlineSettings(value: string | undefined): Record<string, unknown> | null {
|
|
277
|
+
if (!value) return null;
|
|
278
|
+
const trimmed = value.trim();
|
|
279
|
+
if (!trimmed.startsWith("{")) return null; // a settings file path, not inline JSON
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(trimmed);
|
|
282
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Merge relay settings into the caller's inline settings. The safety-critical `hooks` map is
|
|
289
|
+
// UNIONED per event (caller entries first, relay's baseline guard appended) so both fire; every
|
|
290
|
+
// other relay key is fill-if-absent (the caller's explicit value wins), preserving the historical
|
|
291
|
+
// "caller --settings wins" spirit for non-safety settings.
|
|
292
|
+
function mergeClaudeSettings(caller: Record<string, unknown>, relay: Record<string, unknown>): Record<string, unknown> {
|
|
293
|
+
const out: Record<string, unknown> = { ...caller };
|
|
294
|
+
for (const [key, value] of Object.entries(relay)) {
|
|
295
|
+
if (key === "hooks") out.hooks = mergeHookSets(caller.hooks, value);
|
|
296
|
+
else if (!(key in out)) out[key] = value;
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function mergeHookSets(caller: unknown, relay: unknown): ProvisioningHookSet {
|
|
302
|
+
const base: ProvisioningHookSet = isHookSet(caller) ? { ...caller } : {};
|
|
303
|
+
if (!isHookSet(relay)) return base;
|
|
304
|
+
for (const [event, entries] of Object.entries(relay)) {
|
|
305
|
+
base[event] = [...(base[event] ?? []), ...entries];
|
|
306
|
+
}
|
|
307
|
+
return base;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isHookSet(value: unknown): value is ProvisioningHookSet {
|
|
311
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Strip every `--settings <json>` / `--settings=<json>` occurrence from a provider arg list. The
|
|
315
|
+
// adapter applies this to the caller args once the assembler has folded them into its own merged
|
|
316
|
+
// `--settings` (callerSettingsConsumed), so exactly one `--settings` reaches the launch (#1096).
|
|
317
|
+
export function stripSettingsArgs(args: string[]): string[] {
|
|
318
|
+
const result: string[] = [];
|
|
319
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
320
|
+
const arg = args[i];
|
|
321
|
+
if (arg === "--settings") { i += 1; continue; }
|
|
322
|
+
if (arg?.startsWith("--settings=")) continue;
|
|
323
|
+
if (arg !== undefined) result.push(arg);
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
210
326
|
}
|
|
211
327
|
|
|
212
328
|
// ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
|
|
@@ -255,6 +371,24 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
255
371
|
: [];
|
|
256
372
|
const projectSettings = resolveProjectClaudeSettings(config);
|
|
257
373
|
const provisionedPlugins = assetHome ? resolveClaudeProvisionedPlugins(assetHome, config) : [];
|
|
374
|
+
const hookHome = configHome ?? assetHome;
|
|
375
|
+
const provisionedHooks = hookHome ? resolveProvisionedHooks(hookHome, config) : [];
|
|
376
|
+
const provisionedHookSet = renderProvisionedHookSet(provisionedHooks);
|
|
377
|
+
const settings = claudeSettingsArgs(
|
|
378
|
+
projectSettings.settings,
|
|
379
|
+
provisionedHookSet,
|
|
380
|
+
provisionedHooks.map((h) => h.name),
|
|
381
|
+
statusLine,
|
|
382
|
+
hostDefaultArgs,
|
|
383
|
+
config.providerArgs,
|
|
384
|
+
);
|
|
385
|
+
// The anti-lie invariant (#1096): only report hooks as materialized when they actually reach
|
|
386
|
+
// the launch. If the caller's --settings suppressed them, empty the reported set and surface a
|
|
387
|
+
// loud suppression reason instead of an "applied" report.
|
|
388
|
+
const hooksSuppressed = settings.suppressedHookNames.length
|
|
389
|
+
? { reason: "caller-provided --settings could not be merged (file path or malformed JSON); relay hooks were not injected", names: settings.suppressedHookNames }
|
|
390
|
+
: undefined;
|
|
391
|
+
const deliveredHooks = hooksSuppressed ? [] : provisionedHooks;
|
|
258
392
|
|
|
259
393
|
// Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
|
|
260
394
|
// relay-bundled plugin → only when relay.plugins (carries the relay monitor +
|
|
@@ -332,7 +466,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
332
466
|
hostServers: hostMcp,
|
|
333
467
|
strict: strictMcp,
|
|
334
468
|
}),
|
|
335
|
-
...
|
|
469
|
+
...settings.args,
|
|
336
470
|
...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
|
|
337
471
|
];
|
|
338
472
|
|
|
@@ -355,6 +489,9 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
355
489
|
ignoredKeys: projectSettings.ignoredKeys,
|
|
356
490
|
...(projectSettings.file ? { file: projectSettings.file } : {}),
|
|
357
491
|
},
|
|
492
|
+
hooks: deliveredHooks.map((h) => ({ name: h.name, dir: h.dir })),
|
|
493
|
+
...(hooksSuppressed ? { hooksSuppressed } : {}),
|
|
494
|
+
callerSettingsConsumed: settings.callerSettingsConsumed,
|
|
358
495
|
plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
|
|
359
496
|
hostPluginDirs,
|
|
360
497
|
mcp: {
|
|
@@ -367,7 +504,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
367
504
|
};
|
|
368
505
|
}
|
|
369
506
|
|
|
370
|
-
function assembleCodex(config: RunnerSpawnConfig,
|
|
507
|
+
function assembleCodex(config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
|
|
371
508
|
const configHome = providerHomePathFor("codex", config);
|
|
372
509
|
const assetHome = providerProvisioningHomePathFor("codex", config);
|
|
373
510
|
const vanilla = profileIsVanillaBase(config);
|
|
@@ -375,6 +512,22 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
375
512
|
const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
|
|
376
513
|
|
|
377
514
|
const provisionedSkills = assetHome ? resolveCodexProvisionedSkills(assetHome, config) : [];
|
|
515
|
+
// #1096 review, findings 3 & 4 — Codex delivers hooks via CODEX_HOME/hooks.json, so a launch
|
|
516
|
+
// can only carry them when it has an isolated CODEX_HOME (host-base profiles reuse the real
|
|
517
|
+
// ~/.codex, which relay must not clobber) AND the installed Codex is new enough to consume the
|
|
518
|
+
// file. Resolve the profile's hook definitions to decide whether there was anything to deliver;
|
|
519
|
+
// any absence is reported LOUDLY (hooksSuppressed) instead of a silent "not-applicable".
|
|
520
|
+
const resolvedHookDefs = profileProvisioning(config).hooks ?? [];
|
|
521
|
+
const hooksSupported = codexHooksSupported(providerConfig.command);
|
|
522
|
+
const provisionedHooks = configHome && hooksSupported ? resolveProvisionedHooks(configHome, config) : [];
|
|
523
|
+
const hooksSuppressed = resolvedHookDefs.length && provisionedHooks.length === 0
|
|
524
|
+
? {
|
|
525
|
+
reason: !configHome
|
|
526
|
+
? "Codex host-base profile has no managed CODEX_HOME; relay will not overwrite the host ~/.codex/hooks.json, so the baseline guard cannot be delivered"
|
|
527
|
+
: "installed Codex is older than 0.124 and ignores CODEX_HOME/hooks.json (set AGENT_RELAY_CODEX_VERSION to override the probe)",
|
|
528
|
+
names: resolvedHookDefs.map((h) => h.name),
|
|
529
|
+
}
|
|
530
|
+
: undefined;
|
|
378
531
|
const bundledSkillDirs = !config.agentProfile && relaySkills ? bundledCodexSkillDirs() : [];
|
|
379
532
|
const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
|
|
380
533
|
|
|
@@ -429,6 +582,9 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
429
582
|
hostSkills: [],
|
|
430
583
|
projectSkills: [],
|
|
431
584
|
projectSettings: { applied: false, keys: [], ignoredKeys: [] },
|
|
585
|
+
hooks: provisionedHooks.map((h) => ({ name: h.name, dir: h.dir })),
|
|
586
|
+
...(hooksSuppressed ? { hooksSuppressed } : {}),
|
|
587
|
+
callerSettingsConsumed: false,
|
|
432
588
|
plugins: [],
|
|
433
589
|
hostPluginDirs: [],
|
|
434
590
|
mcp: {
|
|
@@ -472,13 +628,23 @@ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
|
|
|
472
628
|
const hostSkills = resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)));
|
|
473
629
|
materializeResolvedAssets(hostSkills);
|
|
474
630
|
materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name))));
|
|
631
|
+
materializeResolvedAssets(resolveProvisionedHooks(configHome, config));
|
|
475
632
|
}
|
|
476
633
|
if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
|
|
634
|
+
if (!configHome && assetHome) materializeResolvedAssets(resolveProvisionedHooks(assetHome, config));
|
|
477
635
|
}
|
|
478
636
|
|
|
479
637
|
function materializeCodexAssets(config: RunnerSpawnConfig): void {
|
|
480
638
|
const assetHome = providerProvisioningHomePathFor("codex", config);
|
|
481
639
|
if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
|
|
640
|
+
const configHome = providerHomePathFor("codex", config);
|
|
641
|
+
// #1096 review, finding 4 — don't write CODEX_HOME/hooks.json for a Codex that would ignore it;
|
|
642
|
+
// keep disk == report (the assembler reports these hooks suppressed for such a launch).
|
|
643
|
+
if (configHome && codexHooksSupported(config.providerConfig.command)) {
|
|
644
|
+
const hooks = resolveProvisionedHooks(configHome, config);
|
|
645
|
+
materializeResolvedAssets(hooks);
|
|
646
|
+
writeCodexHooksJson(configHome, renderProvisionedHookSet(hooks));
|
|
647
|
+
}
|
|
482
648
|
}
|
|
483
649
|
|
|
484
650
|
/**
|
|
@@ -494,8 +660,8 @@ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfi
|
|
|
494
660
|
}
|
|
495
661
|
|
|
496
662
|
/**
|
|
497
|
-
* Write the provisioned assets the assembly describes (
|
|
498
|
-
*
|
|
663
|
+
* Write the provisioned assets the assembly describes (skills/plugins/hooks and Codex
|
|
664
|
+
* hooks.json). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
|
|
499
665
|
* report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
|
|
500
666
|
* for a host-base profile.
|
|
501
667
|
*/
|
|
@@ -33,6 +33,7 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
|
|
|
33
33
|
add(hooksEntry(assembled));
|
|
34
34
|
add(provisioningSkillsEntry(assembled));
|
|
35
35
|
add(provisioningPluginsEntry(assembled));
|
|
36
|
+
add(provisioningHooksEntry(assembled));
|
|
36
37
|
add(hostSkillsEntry(assembled));
|
|
37
38
|
add(hostPluginsEntry(assembled));
|
|
38
39
|
add(provisioningMcpEntry(assembled));
|
|
@@ -168,6 +169,24 @@ function provisioningPluginsEntry(a: AssembledLaunch): AgentProfileProjectionEnt
|
|
|
168
169
|
return applied("provisioning.plugins", `${count}`, `Relay-provisioned plugins passed to ${label} via the plugin dir mechanism${names}.`);
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
function provisioningHooksEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
173
|
+
// #1096 review — the anti-lie invariant: hooks that RESOLVED but did NOT reach the launch
|
|
174
|
+
// (baseline safety guard included) are reported LOUDLY as a warning, never "applied" and never
|
|
175
|
+
// a benign "not-applicable". `hooksSuppressed` is set only when there were hooks to deliver.
|
|
176
|
+
if (a.hooksSuppressed?.names.length) {
|
|
177
|
+
return partial(
|
|
178
|
+
"provisioning.hooks",
|
|
179
|
+
"suppressed",
|
|
180
|
+
`Relay-provisioned hooks were NOT applied (${a.hooksSuppressed.names.join(", ")}): ${a.hooksSuppressed.reason}. A baseline safety guard is absent for this launch.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const count = a.hooks.length;
|
|
184
|
+
if (!count) return notApplicable("provisioning.hooks", "none", "No relay-provisioned hooks materialized for this launch.");
|
|
185
|
+
const names = ` (${a.hooks.map((h) => h.name).join(", ")})`;
|
|
186
|
+
const label = getManifest(a.provider)?.label ?? a.provider;
|
|
187
|
+
return applied("provisioning.hooks", `${count}`, `Relay-provisioned hooks materialized for ${label} from the managed provider config home${names}.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
function hostSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
172
191
|
const optedIn = Boolean(a.profile?.hostAssets?.skills);
|
|
173
192
|
if (!optedIn) return notApplicable("hostAssets.skills", "off", "Host/global skill inheritance is off for this resolved profile.");
|
package/src/provisioning.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
4
|
-
import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
|
|
4
|
+
import type { ProvisioningAssetFile, ProvisioningHookSet, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
|
|
5
5
|
import type { RunnerSpawnConfig } from "./adapter";
|
|
6
6
|
import { profileAllowsHostAssets, providerSourceHome } from "./profile-home";
|
|
7
7
|
|
|
@@ -17,12 +17,12 @@ import { profileAllowsHostAssets, providerSourceHome } from "./profile-home";
|
|
|
17
17
|
// materialize* writers consume the same resolved entries, so what's written can never
|
|
18
18
|
// drift from what's reported either. One resolution, three consumers.
|
|
19
19
|
|
|
20
|
-
const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], mcpServers: {} };
|
|
20
|
+
const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], hooks: [], mcpServers: {} };
|
|
21
21
|
|
|
22
22
|
export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfile">): ResolvedProvisioning {
|
|
23
23
|
const p = config.agentProfile?.provisioning;
|
|
24
24
|
if (!p) return EMPTY;
|
|
25
|
-
return { skills: p.skills ?? [], plugins: p.plugins ?? [], mcpServers: p.mcpServers ?? {}, ...(p.skipped?.length ? { skipped: p.skipped } : {}) };
|
|
25
|
+
return { skills: p.skills ?? [], plugins: p.plugins ?? [], hooks: p.hooks ?? [], mcpServers: p.mcpServers ?? {}, ...(p.skipped?.length ? { skipped: p.skipped } : {}) };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// The `mcpServers` keys a Claude `.mcp.json` entry may carry; coerced into the
|
|
@@ -194,6 +194,10 @@ export interface ResolvedAsset {
|
|
|
194
194
|
source: ResolvedAssetSource;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
export interface ResolvedHookAsset extends ResolvedAsset {
|
|
198
|
+
hooks: ProvisioningHookSet;
|
|
199
|
+
}
|
|
200
|
+
|
|
197
201
|
function assetDirName(name: string): string {
|
|
198
202
|
return sanitizeFsName(name, { replacement: "-", collapse: false, maxLen: 120, fallback: "asset" });
|
|
199
203
|
}
|
|
@@ -211,9 +215,11 @@ function hasRootSkillManifest(files: ProvisioningAssetFile[]): boolean {
|
|
|
211
215
|
// registry entry. Shared by every files-source materialization.
|
|
212
216
|
function writeInlineFiles(dir: string, files: ProvisioningAssetFile[]): void {
|
|
213
217
|
mkdirSync(dir, { recursive: true });
|
|
218
|
+
const root = resolve(dir);
|
|
214
219
|
for (const file of files) {
|
|
215
220
|
const dest = resolve(dir, file.path);
|
|
216
|
-
|
|
221
|
+
const rel = relative(root, dest);
|
|
222
|
+
if (rel.startsWith("..") || isAbsolute(rel)) continue;
|
|
217
223
|
mkdirSync(dirname(dest), { recursive: true });
|
|
218
224
|
writeFileSync(dest, file.content, { mode: 0o600 });
|
|
219
225
|
}
|
|
@@ -303,6 +309,54 @@ export function resolveCodexProvisionedSkills(codexHome: string, config: RunnerS
|
|
|
303
309
|
return resolved;
|
|
304
310
|
}
|
|
305
311
|
|
|
312
|
+
export function resolveProvisionedHooks(home: string, config: RunnerSpawnConfig): ResolvedHookAsset[] {
|
|
313
|
+
const { hooks } = profileProvisioning(config);
|
|
314
|
+
const parent = join(home, "relay-provisioned-hooks");
|
|
315
|
+
const resolved: ResolvedHookAsset[] = [];
|
|
316
|
+
for (const hook of hooks ?? []) {
|
|
317
|
+
const dir = join(parent, assetDirName(hook.name));
|
|
318
|
+
if (hook.files?.length) {
|
|
319
|
+
resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "files", files: hook.files } });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (hook.dir) {
|
|
323
|
+
const source = resolveExistingDir(hook.dir, config.cwd);
|
|
324
|
+
if (source) resolved.push({ name: hook.name, dir: source, hooks: hook.hooks, source: { kind: "inPlace" } });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "inPlace" } });
|
|
328
|
+
}
|
|
329
|
+
return resolved;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function renderProvisionedHookSet(hooks: ResolvedHookAsset[]): ProvisioningHookSet {
|
|
333
|
+
const out: ProvisioningHookSet = {};
|
|
334
|
+
for (const hook of hooks) {
|
|
335
|
+
for (const [event, entries] of Object.entries(hook.hooks)) {
|
|
336
|
+
out[event] = [
|
|
337
|
+
...(out[event] ?? []),
|
|
338
|
+
...entries.map((entry) => ({
|
|
339
|
+
...entry,
|
|
340
|
+
hooks: entry.hooks.map((command) => ({
|
|
341
|
+
...command,
|
|
342
|
+
command: command.command.replaceAll("${HOOK_DIR}", hook.dir),
|
|
343
|
+
})),
|
|
344
|
+
})),
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function writeCodexHooksJson(codexHome: string, hooks: ProvisioningHookSet): void {
|
|
352
|
+
mkdirSync(codexHome, { recursive: true });
|
|
353
|
+
if (!Object.keys(hooks).length) {
|
|
354
|
+
rmSync(join(codexHome, "hooks.json"), { force: true });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
writeFileSync(join(codexHome, "hooks.json"), JSON.stringify({ hooks }, null, 2), { mode: 0o600 });
|
|
358
|
+
}
|
|
359
|
+
|
|
306
360
|
// Write the resolved assets to disk; returns the dirs that materialized successfully,
|
|
307
361
|
// in resolution order. Idempotent — safe to re-run against an existing home.
|
|
308
362
|
export function materializeResolvedAssets(assets: ResolvedAsset[]): string[] {
|
|
@@ -36,6 +36,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
36
36
|
if (!isSpawnProvider(input.provider)) return events;
|
|
37
37
|
const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
|
|
38
38
|
const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
|
|
39
|
+
+ assembled.hooks.length
|
|
39
40
|
+ assembled.projectSkills.length
|
|
40
41
|
+ assembled.mcp.projectServers.length
|
|
41
42
|
+ (assembled.projectSettings.applied ? 1 : 0)
|
|
@@ -52,6 +53,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
52
53
|
assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
|
|
53
54
|
assembled.projectSkills.length ? `${assembled.projectSkills.length} project skill${assembled.projectSkills.length === 1 ? "" : "s"}` : "",
|
|
54
55
|
assembled.projectSettings.applied ? "project settings" : "",
|
|
56
|
+
assembled.hooks.length ? `${assembled.hooks.length} hook${assembled.hooks.length === 1 ? "" : "s"}` : "",
|
|
55
57
|
assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
|
|
56
58
|
assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
|
|
57
59
|
assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
|
|
@@ -73,6 +75,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
73
75
|
skills: assembled.skills,
|
|
74
76
|
projectSkills: assembled.projectSkills,
|
|
75
77
|
projectSettings: assembled.projectSettings,
|
|
78
|
+
hooks: assembled.hooks,
|
|
76
79
|
plugins: assembled.plugins,
|
|
77
80
|
mcp: assembled.mcp,
|
|
78
81
|
},
|
package/src/runner-helpers.ts
CHANGED
|
@@ -356,7 +356,7 @@ export function appliedAgentProfileMetadata(
|
|
|
356
356
|
skills: profile.skills.filter((item) => item.enabled).length,
|
|
357
357
|
plugins: profile.plugins.filter((item) => item.enabled).length,
|
|
358
358
|
mcp: { mode: profile.mcp.mode },
|
|
359
|
-
hooks: { mode: profile.hooks.mode },
|
|
359
|
+
hooks: { mode: profile.hooks.mode, refs: profile.hooks.refs?.filter((item) => item.enabled).length ?? 0 },
|
|
360
360
|
permissions: profile.permissions,
|
|
361
361
|
projection,
|
|
362
362
|
};
|
package/src/version.ts
CHANGED
|
@@ -38,6 +38,7 @@ export function runtimeMetadata(provider?: string) {
|
|
|
38
38
|
...RUNTIME_CAPABILITIES,
|
|
39
39
|
...(manifest?.provisioning?.bundledPlugin ? { bundledPlugin: true } : {}),
|
|
40
40
|
...(manifest?.provisioning?.bundledSkills ? { bundledSkills: true } : {}),
|
|
41
|
+
...(manifest?.provisioning?.bundledHooks?.length ? { bundledHooks: true } : {}),
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
}
|