agent-relay-runner 0.122.1 → 0.123.1
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 +208 -9
- package/src/profile-projection.ts +19 -0
- package/src/provisioning.ts +74 -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.1",
|
|
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,22 @@ 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,
|
|
35
|
+
writeClaudeSettingsJson,
|
|
31
36
|
} from "./provisioning";
|
|
32
37
|
import {
|
|
33
38
|
claudeMcpLaunchArgs,
|
|
@@ -134,6 +139,20 @@ export interface AssembledLaunch {
|
|
|
134
139
|
hostSkills: AssembledAsset[];
|
|
135
140
|
/** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
|
|
136
141
|
projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
|
|
142
|
+
/** Relay-provisioned hooks materialized for this launch. Empty when hooks were resolved
|
|
143
|
+
* but could not be delivered — see `hooksSuppressed` (the anti-lie invariant, #1096). */
|
|
144
|
+
hooks: AssembledAsset[];
|
|
145
|
+
/** #1096 review — set when this launch RESOLVED provisioned hooks (incl. a baseline safety
|
|
146
|
+
* guard) that could NOT be delivered: caller `--settings` was unmergeable (Claude), a
|
|
147
|
+
* host-base Codex agent has no managed CODEX_HOME, or the installed Codex is too old to
|
|
148
|
+
* consume hooks.json. Drives a LOUD projection warning so a baseline guard is never
|
|
149
|
+
* SILENTLY absent while the report says "applied". */
|
|
150
|
+
hooksSuppressed?: { reason: string; names: string[] };
|
|
151
|
+
/** #1096 review — Claude only: true when the caller's own `--settings` was folded into the
|
|
152
|
+
* relay-emitted `--settings` (relay hooks/projectSettings/statusLine merged in). The adapter
|
|
153
|
+
* must then STRIP the caller's `--settings` from the provider args so exactly one reaches the
|
|
154
|
+
* launch. False when relay emitted its own settings standalone or added none. */
|
|
155
|
+
callerSettingsConsumed: boolean;
|
|
137
156
|
/** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
|
|
138
157
|
plugins: AssembledAsset[];
|
|
139
158
|
/** Host/global provider plugin dirs explicitly inherited without enabling host MCP. */
|
|
@@ -196,17 +215,137 @@ function claudeStatusLineProbePath(): string {
|
|
|
196
215
|
return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
|
|
197
216
|
}
|
|
198
217
|
|
|
218
|
+
export interface ClaudeSettingsResolution {
|
|
219
|
+
args: string[];
|
|
220
|
+
/** The caller's own --settings was merged in and must be stripped from the provider args. */
|
|
221
|
+
callerSettingsConsumed: boolean;
|
|
222
|
+
/** Provisioned hook names that could NOT be delivered (caller --settings unmergeable). */
|
|
223
|
+
suppressedHookNames: string[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// #1096 review, finding 1 — when the caller (spawn providerArgs / provider defaultArgs) already
|
|
227
|
+
// carries `--settings`, DO NOT silently drop relay's settings (which used to skip the baseline
|
|
228
|
+
// safety hooks + projectSettings + statusLine while projection still claimed "applied"). Merge
|
|
229
|
+
// relay's settings into the caller's INLINE JSON so the guard survives, and signal the adapter to
|
|
230
|
+
// strip the caller's now-redundant `--settings`. If the caller's `--settings` is a FILE PATH or
|
|
231
|
+
// malformed JSON (unmergeable), relay's provisioned hooks are reported as SUPPRESSED (a loud
|
|
232
|
+
// projection warning) rather than a silent absence with an "applied" lie.
|
|
199
233
|
function claudeSettingsArgs(
|
|
200
234
|
projectSettings: Record<string, unknown>,
|
|
235
|
+
provisionedHooks: ProvisioningHookSet,
|
|
236
|
+
hookNames: string[],
|
|
201
237
|
includeStatusLine: boolean,
|
|
202
238
|
...argLists: string[][]
|
|
203
|
-
):
|
|
204
|
-
|
|
205
|
-
const settings = {
|
|
239
|
+
): ClaudeSettingsResolution {
|
|
240
|
+
const relaySettings: Record<string, unknown> = {
|
|
206
241
|
...projectSettings,
|
|
242
|
+
...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
|
|
207
243
|
...(includeStatusLine ? relayStatusLineSettings() : {}),
|
|
208
244
|
};
|
|
209
|
-
|
|
245
|
+
const hasProvisionedHooks = Object.keys(provisionedHooks).length > 0;
|
|
246
|
+
const caller = extractCallerSettings(argLists);
|
|
247
|
+
if (!caller) {
|
|
248
|
+
return {
|
|
249
|
+
args: Object.keys(relaySettings).length ? ["--settings", JSON.stringify(relaySettings)] : [],
|
|
250
|
+
callerSettingsConsumed: false,
|
|
251
|
+
suppressedHookNames: [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (caller.settings) {
|
|
255
|
+
const merged = mergeClaudeSettings(caller.settings, relaySettings);
|
|
256
|
+
return { args: ["--settings", JSON.stringify(merged)], callerSettingsConsumed: true, suppressedHookNames: [] };
|
|
257
|
+
}
|
|
258
|
+
// Unmergeable caller --settings (a file path or malformed JSON): relay cannot fold its
|
|
259
|
+
// settings in without clobbering the caller's file, so the provisioned hooks don't reach
|
|
260
|
+
// the launch. Report them suppressed (loud) — never claim "applied".
|
|
261
|
+
return { args: [], callerSettingsConsumed: false, suppressedHookNames: hasProvisionedHooks ? hookNames : [] };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The caller's `--settings` value, parsed as inline JSON when possible. `settings` is null for a
|
|
265
|
+
// file-path / malformed value (an inline object is required to merge relay settings in).
|
|
266
|
+
function extractCallerSettings(argLists: string[][]): { settings: Record<string, unknown> | null } | null {
|
|
267
|
+
for (const list of argLists) {
|
|
268
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
269
|
+
const arg = list[i];
|
|
270
|
+
if (arg === "--settings") return { settings: parseInlineSettings(list[i + 1]) };
|
|
271
|
+
if (arg?.startsWith("--settings=")) return { settings: parseInlineSettings(arg.slice("--settings=".length)) };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseInlineSettings(value: string | undefined): Record<string, unknown> | null {
|
|
278
|
+
if (!value) return null;
|
|
279
|
+
const trimmed = value.trim();
|
|
280
|
+
if (!trimmed.startsWith("{")) return null; // a settings file path, not inline JSON
|
|
281
|
+
try {
|
|
282
|
+
const parsed = JSON.parse(trimmed);
|
|
283
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Merge relay settings into the caller's inline settings. The safety-critical `hooks` map is
|
|
290
|
+
// UNIONED per event (caller entries first, relay's baseline guard appended) so both fire; every
|
|
291
|
+
// other relay key is fill-if-absent (the caller's explicit value wins), preserving the historical
|
|
292
|
+
// "caller --settings wins" spirit for non-safety settings.
|
|
293
|
+
function mergeClaudeSettings(caller: Record<string, unknown>, relay: Record<string, unknown>): Record<string, unknown> {
|
|
294
|
+
const out: Record<string, unknown> = { ...caller };
|
|
295
|
+
for (const [key, value] of Object.entries(relay)) {
|
|
296
|
+
if (key === "hooks") out.hooks = mergeHookSets(caller.hooks, value);
|
|
297
|
+
else if (!(key in out)) out[key] = value;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function mergeHookSets(caller: unknown, relay: unknown): ProvisioningHookSet {
|
|
303
|
+
const base: ProvisioningHookSet = isHookSet(caller) ? { ...caller } : {};
|
|
304
|
+
if (!isHookSet(relay)) return base;
|
|
305
|
+
for (const [event, entries] of Object.entries(relay)) {
|
|
306
|
+
base[event] = [...(base[event] ?? []), ...entries];
|
|
307
|
+
}
|
|
308
|
+
return base;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isHookSet(value: unknown): value is ProvisioningHookSet {
|
|
312
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Strip every `--settings <json>` / `--settings=<json>` occurrence from a provider arg list. The
|
|
316
|
+
// adapter applies this to the caller args once the assembler has folded them into its own merged
|
|
317
|
+
// `--settings` (callerSettingsConsumed), so exactly one `--settings` reaches the launch (#1096).
|
|
318
|
+
export function stripSettingsArgs(args: string[]): string[] {
|
|
319
|
+
const result: string[] = [];
|
|
320
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
321
|
+
const arg = args[i];
|
|
322
|
+
if (arg === "--settings") { i += 1; continue; }
|
|
323
|
+
if (arg?.startsWith("--settings=")) continue;
|
|
324
|
+
if (arg !== undefined) result.push(arg);
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// #1096 — the settings.json relay WRITES into an isolated managed Claude home so the provisioned
|
|
330
|
+
// hooks (baseline guard included) land in the DOCUMENTED hook source Claude honors
|
|
331
|
+
// ($CLAUDE_CONFIG_DIR/settings.json — loaded under `--setting-sources "user"` and the default),
|
|
332
|
+
// rather than riding ONLY the inline `--settings` CLI flag. Claude does not document `--settings`
|
|
333
|
+
// as a hook source, and — unlike a file written straight to disk — that flag's JSON must survive
|
|
334
|
+
// shell/tmux launcher transit and passes Claude's "silently ignore settings that fail validation"
|
|
335
|
+
// gate; a written settings.json bypasses both, so the baseline guard can't be silently absent
|
|
336
|
+
// (the #1096 inert-at-runtime symptom). Returns null when:
|
|
337
|
+
// • there is NO isolated managed home (host base reuses the real ~/.claude — relay must not
|
|
338
|
+
// clobber it; that path keeps the inline --settings delivery), or
|
|
339
|
+
// • a caller --settings is present (the merge/suppression path owns hook delivery inline; see
|
|
340
|
+
// claudeSettingsArgs) — so the file and inline channels never DOUBLE-register the same hook.
|
|
341
|
+
// Both assembleClaude (to keep these hooks OUT of the inline --settings) and materializeClaudeAssets
|
|
342
|
+
// (to write the file) call this, so the launch and the disk agree.
|
|
343
|
+
export function claudeManagedHookSettings(config: RunnerSpawnConfig, configHome: string | undefined): { hooks: ProvisioningHookSet } | null {
|
|
344
|
+
if (!configHome) return null;
|
|
345
|
+
const hostDefaultArgs = profileUsesProviderHostGlobals(config) ? config.providerConfig.defaultArgs : [];
|
|
346
|
+
if (hasSettingsArg(hostDefaultArgs) || hasSettingsArg(config.providerArgs)) return null;
|
|
347
|
+
const hookSet = renderProvisionedHookSet(resolveProvisionedHooks(configHome, config));
|
|
348
|
+
return Object.keys(hookSet).length ? { hooks: hookSet } : null;
|
|
210
349
|
}
|
|
211
350
|
|
|
212
351
|
// ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
|
|
@@ -255,6 +394,30 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
255
394
|
: [];
|
|
256
395
|
const projectSettings = resolveProjectClaudeSettings(config);
|
|
257
396
|
const provisionedPlugins = assetHome ? resolveClaudeProvisionedPlugins(assetHome, config) : [];
|
|
397
|
+
const hookHome = configHome ?? assetHome;
|
|
398
|
+
const provisionedHooks = hookHome ? resolveProvisionedHooks(hookHome, config) : [];
|
|
399
|
+
const provisionedHookSet = renderProvisionedHookSet(provisionedHooks);
|
|
400
|
+
// #1096 — for an isolated managed home with no caller --settings, the provisioned hooks are
|
|
401
|
+
// delivered through the managed settings.json (the documented, transit-safe hook source), so
|
|
402
|
+
// keep them OUT of the inline --settings to avoid double-registering the same guard. Host-base
|
|
403
|
+
// and caller-`--settings` launches keep inline delivery (see claudeManagedHookSettings).
|
|
404
|
+
const managedHookSettings = claudeManagedHookSettings(config, configHome);
|
|
405
|
+
const inlineHookSet = managedHookSettings ? {} : provisionedHookSet;
|
|
406
|
+
const settings = claudeSettingsArgs(
|
|
407
|
+
projectSettings.settings,
|
|
408
|
+
inlineHookSet,
|
|
409
|
+
provisionedHooks.map((h) => h.name),
|
|
410
|
+
statusLine,
|
|
411
|
+
hostDefaultArgs,
|
|
412
|
+
config.providerArgs,
|
|
413
|
+
);
|
|
414
|
+
// The anti-lie invariant (#1096): only report hooks as materialized when they actually reach
|
|
415
|
+
// the launch. If the caller's --settings suppressed them, empty the reported set and surface a
|
|
416
|
+
// loud suppression reason instead of an "applied" report.
|
|
417
|
+
const hooksSuppressed = settings.suppressedHookNames.length
|
|
418
|
+
? { reason: "caller-provided --settings could not be merged (file path or malformed JSON); relay hooks were not injected", names: settings.suppressedHookNames }
|
|
419
|
+
: undefined;
|
|
420
|
+
const deliveredHooks = hooksSuppressed ? [] : provisionedHooks;
|
|
258
421
|
|
|
259
422
|
// Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
|
|
260
423
|
// relay-bundled plugin → only when relay.plugins (carries the relay monitor +
|
|
@@ -332,7 +495,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
332
495
|
hostServers: hostMcp,
|
|
333
496
|
strict: strictMcp,
|
|
334
497
|
}),
|
|
335
|
-
...
|
|
498
|
+
...settings.args,
|
|
336
499
|
...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
|
|
337
500
|
];
|
|
338
501
|
|
|
@@ -355,6 +518,9 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
355
518
|
ignoredKeys: projectSettings.ignoredKeys,
|
|
356
519
|
...(projectSettings.file ? { file: projectSettings.file } : {}),
|
|
357
520
|
},
|
|
521
|
+
hooks: deliveredHooks.map((h) => ({ name: h.name, dir: h.dir })),
|
|
522
|
+
...(hooksSuppressed ? { hooksSuppressed } : {}),
|
|
523
|
+
callerSettingsConsumed: settings.callerSettingsConsumed,
|
|
358
524
|
plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
|
|
359
525
|
hostPluginDirs,
|
|
360
526
|
mcp: {
|
|
@@ -367,7 +533,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
367
533
|
};
|
|
368
534
|
}
|
|
369
535
|
|
|
370
|
-
function assembleCodex(config: RunnerSpawnConfig,
|
|
536
|
+
function assembleCodex(config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
|
|
371
537
|
const configHome = providerHomePathFor("codex", config);
|
|
372
538
|
const assetHome = providerProvisioningHomePathFor("codex", config);
|
|
373
539
|
const vanilla = profileIsVanillaBase(config);
|
|
@@ -375,6 +541,22 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
375
541
|
const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
|
|
376
542
|
|
|
377
543
|
const provisionedSkills = assetHome ? resolveCodexProvisionedSkills(assetHome, config) : [];
|
|
544
|
+
// #1096 review, findings 3 & 4 — Codex delivers hooks via CODEX_HOME/hooks.json, so a launch
|
|
545
|
+
// can only carry them when it has an isolated CODEX_HOME (host-base profiles reuse the real
|
|
546
|
+
// ~/.codex, which relay must not clobber) AND the installed Codex is new enough to consume the
|
|
547
|
+
// file. Resolve the profile's hook definitions to decide whether there was anything to deliver;
|
|
548
|
+
// any absence is reported LOUDLY (hooksSuppressed) instead of a silent "not-applicable".
|
|
549
|
+
const resolvedHookDefs = profileProvisioning(config).hooks ?? [];
|
|
550
|
+
const hooksSupported = codexHooksSupported(providerConfig.command);
|
|
551
|
+
const provisionedHooks = configHome && hooksSupported ? resolveProvisionedHooks(configHome, config) : [];
|
|
552
|
+
const hooksSuppressed = resolvedHookDefs.length && provisionedHooks.length === 0
|
|
553
|
+
? {
|
|
554
|
+
reason: !configHome
|
|
555
|
+
? "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"
|
|
556
|
+
: "installed Codex is older than 0.124 and ignores CODEX_HOME/hooks.json (set AGENT_RELAY_CODEX_VERSION to override the probe)",
|
|
557
|
+
names: resolvedHookDefs.map((h) => h.name),
|
|
558
|
+
}
|
|
559
|
+
: undefined;
|
|
378
560
|
const bundledSkillDirs = !config.agentProfile && relaySkills ? bundledCodexSkillDirs() : [];
|
|
379
561
|
const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
|
|
380
562
|
|
|
@@ -429,6 +611,9 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
429
611
|
hostSkills: [],
|
|
430
612
|
projectSkills: [],
|
|
431
613
|
projectSettings: { applied: false, keys: [], ignoredKeys: [] },
|
|
614
|
+
hooks: provisionedHooks.map((h) => ({ name: h.name, dir: h.dir })),
|
|
615
|
+
...(hooksSuppressed ? { hooksSuppressed } : {}),
|
|
616
|
+
callerSettingsConsumed: false,
|
|
432
617
|
plugins: [],
|
|
433
618
|
hostPluginDirs: [],
|
|
434
619
|
mcp: {
|
|
@@ -472,13 +657,27 @@ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
|
|
|
472
657
|
const hostSkills = resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)));
|
|
473
658
|
materializeResolvedAssets(hostSkills);
|
|
474
659
|
materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name))));
|
|
660
|
+
materializeResolvedAssets(resolveProvisionedHooks(configHome, config));
|
|
661
|
+
// #1096 — land the provisioned hooks in the managed home's settings.json (the documented
|
|
662
|
+
// hook source), matching the assembler's decision to keep them out of the inline --settings.
|
|
663
|
+
const managedHookSettings = claudeManagedHookSettings(config, configHome);
|
|
664
|
+
if (managedHookSettings) writeClaudeSettingsJson(configHome, managedHookSettings);
|
|
475
665
|
}
|
|
476
666
|
if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
|
|
667
|
+
if (!configHome && assetHome) materializeResolvedAssets(resolveProvisionedHooks(assetHome, config));
|
|
477
668
|
}
|
|
478
669
|
|
|
479
670
|
function materializeCodexAssets(config: RunnerSpawnConfig): void {
|
|
480
671
|
const assetHome = providerProvisioningHomePathFor("codex", config);
|
|
481
672
|
if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
|
|
673
|
+
const configHome = providerHomePathFor("codex", config);
|
|
674
|
+
// #1096 review, finding 4 — don't write CODEX_HOME/hooks.json for a Codex that would ignore it;
|
|
675
|
+
// keep disk == report (the assembler reports these hooks suppressed for such a launch).
|
|
676
|
+
if (configHome && codexHooksSupported(config.providerConfig.command)) {
|
|
677
|
+
const hooks = resolveProvisionedHooks(configHome, config);
|
|
678
|
+
materializeResolvedAssets(hooks);
|
|
679
|
+
writeCodexHooksJson(configHome, renderProvisionedHookSet(hooks));
|
|
680
|
+
}
|
|
482
681
|
}
|
|
483
682
|
|
|
484
683
|
/**
|
|
@@ -494,8 +693,8 @@ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfi
|
|
|
494
693
|
}
|
|
495
694
|
|
|
496
695
|
/**
|
|
497
|
-
* Write the provisioned assets the assembly describes (
|
|
498
|
-
*
|
|
696
|
+
* Write the provisioned assets the assembly describes (skills/plugins/hooks and Codex
|
|
697
|
+
* hooks.json). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
|
|
499
698
|
* report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
|
|
500
699
|
* for a host-base profile.
|
|
501
700
|
*/
|
|
@@ -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,69 @@ 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
|
+
|
|
360
|
+
// #1096 — write relay-owned settings into an ISOLATED Claude managed home's `settings.json`
|
|
361
|
+
// (the DOCUMENTED hook source Claude honors under `--setting-sources "user"` or the default,
|
|
362
|
+
// unlike the inline `--settings` CLI flag). The managed home is relay-owned and freshly keyed
|
|
363
|
+
// per spawn, so an unconditional write is safe (profile-home seeds only `.claude.json`/`CLAUDE.md`,
|
|
364
|
+
// never `settings.json`). Removes the file when there is nothing relay-owned to deliver so disk
|
|
365
|
+
// stays == report. Never called for a host-base launch (which reuses the real `~/.claude`).
|
|
366
|
+
export function writeClaudeSettingsJson(claudeHome: string, settings: Record<string, unknown>): void {
|
|
367
|
+
mkdirSync(claudeHome, { recursive: true });
|
|
368
|
+
if (!Object.keys(settings).length) {
|
|
369
|
+
rmSync(join(claudeHome, "settings.json"), { force: true });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
writeFileSync(join(claudeHome, "settings.json"), JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
373
|
+
}
|
|
374
|
+
|
|
306
375
|
// Write the resolved assets to disk; returns the dirs that materialized successfully,
|
|
307
376
|
// in resolution order. Idempotent — safe to re-run against an existing home.
|
|
308
377
|
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
|
}
|