@humwick/runner 0.0.0-stage → 0.130.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/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +46 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { providerIntegrationFiles, projectClaudePermissionRules } from "./provider-integration";
|
|
2
|
+
import { runnerMcpGeneration } from "./relay-mcp";
|
|
3
|
+
import { writeChildEnv } from "@humwick/sdk/env";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { sanitizeFsName } from "@humwick/sdk/fs-name";
|
|
9
|
+
import { RETIRED_SHARED_MCP_SERVER_NAMES, isRelayMcpServerName, mcpServerInfo, wireToolName, type ToolGeneration, coerceProjectMcpServer, fffMcpBinaryPath, PROJECT_MCP_FILE, profileProjectSourceEnabled } from "@humwick/sdk";
|
|
10
|
+
import { getManifest } from "@humwick/providers";
|
|
11
|
+
import type { ProvisioningAssetFile, ProvisioningHookSet, ProvisioningMcpServer, ResolvedProvisioning } from "@humwick/sdk";
|
|
12
|
+
import type { RunnerSpawnConfig } from "./adapter";
|
|
13
|
+
import { humwickHome } from "./config";
|
|
14
|
+
import { preflightProvisionedMcpServers } from "./mcp-preflight";
|
|
15
|
+
import { tomlInlineValue, tomlString } from "./relay-mcp";
|
|
16
|
+
import { profileAllowsHostGlobalInstructions, providerSourceHome } from "./profile-home";
|
|
17
|
+
import { hostSourcesFor, skillDirsUnder } from "./host-asset-plan";
|
|
18
|
+
|
|
19
|
+
// Runner-side materialization of the relay-resolved provisioning bundle
|
|
20
|
+
// (#554/#555/#556). The relay resolves a profile's refs against the DB registry and
|
|
21
|
+
// attaches the concrete definitions to profile.provisioning; here we turn those into
|
|
22
|
+
// real on-disk dirs (skills/plugins). MCP is handled in relay-mcp.ts.
|
|
23
|
+
//
|
|
24
|
+
// #557 — split into PURE RESOLUTION (resolve* → where each asset lands, no writes) and
|
|
25
|
+
// MATERIALIZATION (materialize* → the disk writes). The single launch assembler
|
|
26
|
+
// (launch-assembly.ts) calls the resolvers to build BOTH the launch args and the
|
|
27
|
+
// projection report, so what's reported can never drift from what's written; the
|
|
28
|
+
// materialize* writers consume the same resolved entries, so what's written can never
|
|
29
|
+
// drift from what's reported either. One resolution, three consumers.
|
|
30
|
+
|
|
31
|
+
const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], hooks: [], mcpServers: {} };
|
|
32
|
+
|
|
33
|
+
export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfile">): ResolvedProvisioning {
|
|
34
|
+
const p = config.agentProfile?.provisioning;
|
|
35
|
+
if (!p) return EMPTY;
|
|
36
|
+
return { skills: p.skills ?? [], plugins: p.plugins ?? [], hooks: p.hooks ?? [], mcpServers: p.mcpServers ?? {}, ...(p.skipped?.length ? { skipped: p.skipped } : {}) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The fff search-redirect hook is bundled per provider manifest; it only needs the binary path in env.
|
|
40
|
+
function providerBundlesFffRedirect(provider: RunnerSpawnConfig["provider"]): boolean {
|
|
41
|
+
return Boolean(getManifest(provider)?.provisioning?.bundledHooks?.some((hook) => hook.ref === "agent-relay-fff-search-redirect"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// #2729 — provisioned stdio commands authored on another host are swapped for this host's
|
|
45
|
+
// provisioned descriptor or dropped here, never emitted broken. #2809 — the fff path rewrite
|
|
46
|
+
// lives here too so every provider launch gets it before the preflight judges the path.
|
|
47
|
+
export function launchProvisionedMcpServers(
|
|
48
|
+
config: Pick<RunnerSpawnConfig, "agentProfile">,
|
|
49
|
+
servers: Record<string, ProvisioningMcpServer> = profileProvisioning(config).mcpServers,
|
|
50
|
+
): Record<string, ProvisioningMcpServer> {
|
|
51
|
+
const fff = servers.fff;
|
|
52
|
+
const binary = fff?.command && !fff.url ? fffMcpBinaryPath(humwickHome()) : undefined;
|
|
53
|
+
return preflightProvisionedMcpServers(binary ? { ...servers, fff: { ...fff, command: binary } } : servers, {
|
|
54
|
+
sharedListener: Boolean(config.agentProfile?.sharedMcp),
|
|
55
|
+
// The fff binary is downloaded after launch resolution, so its (rewritten) path is exempt.
|
|
56
|
+
skip: binary ? ["fff"] : [],
|
|
57
|
+
home: humwickHome(),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fffMcpHookEnv(config: Pick<RunnerSpawnConfig, "agentProfile" | "provider">): Record<string, string> {
|
|
62
|
+
return writeChildEnv({}, { AGENT_RELAY_FFF_MCP_BINARY: providerBundlesFffRedirect(config.provider)
|
|
63
|
+
? launchProvisionedMcpServers(config).fff?.command ?? "" : "" });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The resolved source matrix decides project MCP/skill projection (see profileProjectSourceEnabled). */
|
|
67
|
+
export function projectSourceEnabled(
|
|
68
|
+
config: Pick<RunnerSpawnConfig, "agentProfile">,
|
|
69
|
+
capability: "mcp" | "skills",
|
|
70
|
+
): boolean {
|
|
71
|
+
return profileProjectSourceEnabled(config.agentProfile, capability);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Project settings are currently the `projectSettings`/`localSettings` sibling of the source
|
|
76
|
+
* matrix (see ProfileCellSiblings), rather than a Project capability cell. It is nevertheless
|
|
77
|
+
* resolved profile policy: the runner must not require, or consult, a spawn-time override.
|
|
78
|
+
*/
|
|
79
|
+
export function projectSettingsEnabled(config: Pick<RunnerSpawnConfig, "agentProfile">): boolean {
|
|
80
|
+
return Boolean(config.agentProfile?.projectSettings);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// #667 — project `.mcp.json` MCP projection. OPT-IN via the resolved profile's `projectMcp`
|
|
84
|
+
// (set per-spawn or by profile, relay-side). Reads the spawn cwd's COMMITTED `.mcp.json` and
|
|
85
|
+
// returns its `mcpServers` as a provider-neutral record, so the launch assembler injects them
|
|
86
|
+
// through the CONTROLLED `--mcp-config` (Claude) / `-c mcp_servers.*` (Codex) path that
|
|
87
|
+
// survives `--strict-mcp-config` — the project's own servers travel with the repo like they do
|
|
88
|
+
// for a human who clones it, WITHOUT enabling Claude's ambient project-discovery trust modal
|
|
89
|
+
// (which wedges headless agents, the reason #662 made every managed launch strict). The runner
|
|
90
|
+
// is the filesystem authority for the spawn cwd (relay may run on another host), so the read
|
|
91
|
+
// lands here. Best-effort: a missing/malformed file (or one with no `mcpServers`) yields {} and
|
|
92
|
+
// never aborts the launch. Returns {} when projection is OFF.
|
|
93
|
+
export function resolveProjectMcpServers(
|
|
94
|
+
config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
|
|
95
|
+
): Record<string, ProvisioningMcpServer> {
|
|
96
|
+
const profile = config.agentProfile;
|
|
97
|
+
if (!projectSourceEnabled(config, "mcp")) return {};
|
|
98
|
+
// N7 — a stamped source matrix is the only trust-gated project MCP path. The
|
|
99
|
+
// server decides roots/pinned/any against its registry and carries the result
|
|
100
|
+
// through the spawn-scoped profile. Missing/denied decisions fail closed.
|
|
101
|
+
if (profile?.sources?.project && !profile.projectGates?.mcp?.allowed) return {};
|
|
102
|
+
// #3043 — a project role's entries were read at one commit; the working tree never applies.
|
|
103
|
+
if (profile?.pinnedProjectMcp) return { ...profile.pinnedProjectMcp.servers };
|
|
104
|
+
const file = join(config.cwd, PROJECT_MCP_FILE);
|
|
105
|
+
if (!existsSync(file)) return {};
|
|
106
|
+
let parsed: unknown;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.warn(`project .mcp.json projection: failed to parse ${file}: ${(err as Error).message}`);
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
const servers = (parsed as { mcpServers?: unknown } | null)?.mcpServers;
|
|
114
|
+
if (!servers || typeof servers !== "object") return {};
|
|
115
|
+
const out: Record<string, ProvisioningMcpServer> = {};
|
|
116
|
+
for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
|
|
117
|
+
const server = coerceProjectMcpServer(raw);
|
|
118
|
+
if (server) out[name] = server;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveClaudeProjectSkills(
|
|
124
|
+
claudeHome: string,
|
|
125
|
+
config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
|
|
126
|
+
reservedNames: ReadonlySet<string> = new Set(),
|
|
127
|
+
): ResolvedAsset[] {
|
|
128
|
+
if (!projectSourceEnabled(config, "skills")) return [];
|
|
129
|
+
return linkedSkillAssets(join(config.cwd, ".claude", "skills"), claudeHome, reservedNames);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Skill dirs (including symlinked ones) under `skillsRoot`, linked into `claudeHome/skills`.
|
|
133
|
+
function linkedSkillAssets(skillsRoot: string, claudeHome: string, reservedNames: ReadonlySet<string>): ResolvedAsset[] {
|
|
134
|
+
const targetRoot = join(claudeHome, "skills");
|
|
135
|
+
return skillDirsUnder(skillsRoot)
|
|
136
|
+
.map((source) => ({ name: basename(source), source }))
|
|
137
|
+
.filter(({ name }) => !reservedNames.has(name))
|
|
138
|
+
.map(({ name, source }) => ({ name, dir: join(targetRoot, assetDirName(name)), source: { kind: "link" as const, source } }))
|
|
139
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveClaudeHostSkills(
|
|
143
|
+
claudeHome: string,
|
|
144
|
+
config: Pick<RunnerSpawnConfig, "agentProfile">,
|
|
145
|
+
reservedNames: ReadonlySet<string> = new Set(),
|
|
146
|
+
provider: string = "claude",
|
|
147
|
+
): ResolvedAsset[] {
|
|
148
|
+
if (!hostSourcesFor(config).skills) return [];
|
|
149
|
+
// #2513 — keyed by provider id so a dedicatedHome claude-family provider (claude-b)
|
|
150
|
+
// resolves host skills from ITS OWN source home, never ~/.claude's.
|
|
151
|
+
return linkedSkillAssets(join(providerSourceHome(provider), "skills"), claudeHome, reservedNames);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ResolvedSourceShadow {
|
|
155
|
+
source: "host" | "project" | "agent-relay";
|
|
156
|
+
capability: "mcp" | "hooks" | "skills" | "plugins";
|
|
157
|
+
name: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ResolvedClaudeSkillSources {
|
|
161
|
+
relay: ResolvedAsset[];
|
|
162
|
+
project: ResolvedAsset[];
|
|
163
|
+
host: ResolvedAsset[];
|
|
164
|
+
shadows: ResolvedSourceShadow[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Resolve the skill union once, preserving the actual lower-precedence collisions. */
|
|
168
|
+
export function resolveClaudeSkillSources(claudeHome: string, config: RunnerSpawnConfig, provider: string = "claude"): ResolvedClaudeSkillSources {
|
|
169
|
+
const relay = resolveClaudeProvisionedSkills(claudeHome, config);
|
|
170
|
+
const relayNames = new Set(relay.map((asset) => asset.name));
|
|
171
|
+
const projectAll = resolveClaudeProjectSkills(claudeHome, config);
|
|
172
|
+
const project = projectAll.filter((asset) => !relayNames.has(asset.name));
|
|
173
|
+
const projectNames = new Set(project.map((asset) => asset.name));
|
|
174
|
+
const hostAll = resolveClaudeHostSkills(claudeHome, config, new Set(), provider);
|
|
175
|
+
const host = hostAll.filter((asset) => !relayNames.has(asset.name) && !projectNames.has(asset.name));
|
|
176
|
+
return {
|
|
177
|
+
relay,
|
|
178
|
+
project,
|
|
179
|
+
host,
|
|
180
|
+
shadows: [
|
|
181
|
+
...projectAll.filter((asset) => relayNames.has(asset.name)).map((asset) => ({ source: "project" as const, capability: "skills" as const, name: asset.name })),
|
|
182
|
+
...hostAll.filter((asset) => relayNames.has(asset.name) || projectNames.has(asset.name)).map((asset) => ({ source: "host" as const, capability: "skills" as const, name: asset.name })),
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resolveMcpSourceShadows(
|
|
188
|
+
projectRaw: Record<string, unknown>,
|
|
189
|
+
provisioned: Record<string, unknown>,
|
|
190
|
+
host: Record<string, unknown>,
|
|
191
|
+
declared: Record<string, unknown>,
|
|
192
|
+
shared: Record<string, unknown>,
|
|
193
|
+
relayEndpoint: boolean,
|
|
194
|
+
): ResolvedSourceShadow[] {
|
|
195
|
+
const wins = new Set([...Object.keys(provisioned), ...Object.keys(shared), ...RETIRED_SHARED_MCP_SERVER_NAMES]);
|
|
196
|
+
return [
|
|
197
|
+
...Object.keys(projectRaw).filter((name) => (isRelayMcpServerName(name) && relayEndpoint) || wins.has(name)).map((name) => ({ source: "project" as const, capability: "mcp" as const, name })),
|
|
198
|
+
...Object.keys(host).filter((name) => (isRelayMcpServerName(name) && relayEndpoint) || name in declared || wins.has(name)).map((name) => ({ source: "host" as const, capability: "mcp" as const, name })),
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const UNSAFE_PROJECT_CLAUDE_SETTINGS = new Set([
|
|
203
|
+
"apiKeyHelper",
|
|
204
|
+
"allowDangerouslySkipPermissions",
|
|
205
|
+
"allowedTools",
|
|
206
|
+
"disabledMcpjsonServers",
|
|
207
|
+
"disallowedTools",
|
|
208
|
+
"enableAllProjectMcpServers",
|
|
209
|
+
"enabledMcpjsonServers",
|
|
210
|
+
"env",
|
|
211
|
+
"mcpServers",
|
|
212
|
+
"permissionMode",
|
|
213
|
+
"permissions",
|
|
214
|
+
"statusLine",
|
|
215
|
+
"tools",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
export interface ResolvedProjectClaudeSettings {
|
|
219
|
+
applied: boolean;
|
|
220
|
+
settings: Record<string, unknown>;
|
|
221
|
+
keys: string[];
|
|
222
|
+
ignoredKeys: string[];
|
|
223
|
+
file?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function resolveProjectClaudeSettings(
|
|
227
|
+
config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
|
|
228
|
+
): ResolvedProjectClaudeSettings {
|
|
229
|
+
if (!projectSettingsEnabled(config)) return { applied: false, settings: {}, keys: [], ignoredKeys: [] };
|
|
230
|
+
const file = join(config.cwd, ".claude", "settings.json");
|
|
231
|
+
if (!existsSync(file)) return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
|
|
232
|
+
let parsed: unknown;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.warn(`project .claude/settings.json projection: failed to parse ${file}: ${(err as Error).message}`);
|
|
237
|
+
return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
|
|
238
|
+
}
|
|
239
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
240
|
+
return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
|
|
241
|
+
}
|
|
242
|
+
const settings: Record<string, unknown> = {};
|
|
243
|
+
const ignoredKeys: string[] = [];
|
|
244
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
245
|
+
if (key === "hooks") {
|
|
246
|
+
if (config.agentProfile?.sources?.project?.hooks?.enabled && config.agentProfile.projectGates?.hooks?.allowed) settings[key] = value;
|
|
247
|
+
else ignoredKeys.push(key);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (UNSAFE_PROJECT_CLAUDE_SETTINGS.has(key)) {
|
|
251
|
+
ignoredKeys.push(key);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
settings[key] = value;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
applied: true,
|
|
258
|
+
settings,
|
|
259
|
+
keys: Object.keys(settings).sort(),
|
|
260
|
+
ignoredKeys: ignoredKeys.sort(),
|
|
261
|
+
file,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
|
|
266
|
+
// (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
|
|
267
|
+
// source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
|
|
268
|
+
export type ResolvedAssetSource =
|
|
269
|
+
| { kind: "files"; files: ProvisioningAssetFile[] }
|
|
270
|
+
| { kind: "link"; source: string }
|
|
271
|
+
| { kind: "inPlace" };
|
|
272
|
+
export interface ResolvedAsset {
|
|
273
|
+
name: string;
|
|
274
|
+
dir: string;
|
|
275
|
+
source: ResolvedAssetSource;
|
|
276
|
+
provenance?: "relay" | "project";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface ResolvedHookAsset extends ResolvedAsset {
|
|
280
|
+
hooks: ProvisioningHookSet;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function assetDirName(name: string): string {
|
|
284
|
+
return sanitizeFsName(name, { replacement: "-", collapse: false, maxLen: 120, fallback: "asset" });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function resolveExistingDir(dir: string, cwd: string): string | null {
|
|
288
|
+
const abs = isAbsolute(dir) ? dir : resolve(cwd, dir);
|
|
289
|
+
return existsSync(abs) ? abs : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hasRootSkillManifest(files: ProvisioningAssetFile[]): boolean {
|
|
293
|
+
return files.some((f) => f.path === "SKILL.md");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Write an inline file set into <dir>, guarding against path escapes from a malformed
|
|
297
|
+
// registry entry. Shared by every files-source materialization.
|
|
298
|
+
function writeInlineFiles(dir: string, files: ProvisioningAssetFile[]): void {
|
|
299
|
+
mkdirSync(dir, { recursive: true });
|
|
300
|
+
const root = resolve(dir);
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
const dest = resolve(dir, file.path);
|
|
303
|
+
const rel = relative(root, dest);
|
|
304
|
+
if (rel.startsWith("..") || isAbsolute(rel)) continue;
|
|
305
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
306
|
+
writeFileSync(dest, file.content, { mode: 0o600 });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Perform the disk write a resolved asset describes. files → write inline; link →
|
|
311
|
+
// (re)create the symlink; inPlace → nothing (the dir already exists at the source).
|
|
312
|
+
// Best-effort for links: a failed link must not abort the launch.
|
|
313
|
+
function materializeAsset(asset: ResolvedAsset): boolean {
|
|
314
|
+
if (asset.source.kind === "files") {
|
|
315
|
+
writeInlineFiles(asset.dir, asset.source.files);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
if (asset.source.kind === "link") {
|
|
319
|
+
try {
|
|
320
|
+
mkdirSync(dirname(asset.dir), { recursive: true });
|
|
321
|
+
if (existsSync(asset.dir)) rmSync(asset.dir, { recursive: true, force: true });
|
|
322
|
+
symlinkSync(asset.source.source, asset.dir);
|
|
323
|
+
return true;
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return existsSync(asset.dir);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// #554 (Claude) — each provisioned skill lands at <claudeHome>/skills/<name> so it's
|
|
332
|
+
// auto-discovered under the launch's `--setting-sources "user"` scope (host skills
|
|
333
|
+
// never bleed). Inline files are written there; a pre-existing dir is symlinked in.
|
|
334
|
+
export function resolveClaudeProvisionedSkills(claudeHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
|
|
335
|
+
const { skills } = profileProvisioning(config);
|
|
336
|
+
const skillsRoot = join(claudeHome, "skills");
|
|
337
|
+
const resolved: ResolvedAsset[] = [];
|
|
338
|
+
for (const skill of skills) {
|
|
339
|
+
const dir = join(skillsRoot, assetDirName(skill.name));
|
|
340
|
+
if (skill.files?.length) {
|
|
341
|
+
resolved.push({ name: skill.name, dir, source: { kind: "files", files: providerIntegrationFiles(skill.name, skill.files, runnerMcpGeneration(config.env)) } });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (skill.dir) {
|
|
345
|
+
const source = resolveExistingDir(skill.dir, config.cwd);
|
|
346
|
+
if (source) resolved.push({ name: skill.name, dir, source: { kind: "link", source } });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return resolved;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// #556 (Claude) — each provisioned plugin resolves to a --plugin-dir. Inline plugins
|
|
353
|
+
// materialize under <claudeHome>/relay-provisioned-plugins/<name>; declared dirs are
|
|
354
|
+
// used in place. The resolved `dir` is appended to the launch's --plugin-dir set.
|
|
355
|
+
export function resolveClaudeProvisionedPlugins(claudeHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
|
|
356
|
+
const { plugins } = profileProvisioning(config);
|
|
357
|
+
const parent = join(claudeHome, "relay-provisioned-plugins");
|
|
358
|
+
const resolved: ResolvedAsset[] = [];
|
|
359
|
+
for (const plugin of plugins) {
|
|
360
|
+
if (plugin.files?.length) {
|
|
361
|
+
resolved.push({ name: plugin.name, dir: join(parent, assetDirName(plugin.name)), source: { kind: "files", files: providerIntegrationFiles(plugin.name, plugin.files, runnerMcpGeneration(config.env)) } });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (plugin.dir) {
|
|
365
|
+
const source = resolveExistingDir(plugin.dir, config.cwd);
|
|
366
|
+
if (source) resolved.push({ name: plugin.name, dir: source, source: { kind: "inPlace" } });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return resolved;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// #554 (Codex) — each provisioned skill resolves to a dir for `-c skills.config`.
|
|
373
|
+
// Inline skills materialize under <codexHome>/relay-provisioned-skills/<name>; declared
|
|
374
|
+
// dirs are used in place. Codex requires a SKILL.md at the dir root to register it, so
|
|
375
|
+
// inline sets without one and dirs missing it are dropped at resolution time.
|
|
376
|
+
export function resolveCodexProvisionedSkills(codexHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
|
|
377
|
+
const { skills } = profileProvisioning(config);
|
|
378
|
+
const parent = join(codexHome, "relay-provisioned-skills");
|
|
379
|
+
const resolved: ResolvedAsset[] = [];
|
|
380
|
+
for (const skill of skills) {
|
|
381
|
+
if (skill.files?.length) {
|
|
382
|
+
if (!hasRootSkillManifest(skill.files)) continue;
|
|
383
|
+
resolved.push({ name: skill.name, dir: join(parent, assetDirName(skill.name)), source: { kind: "files", files: providerIntegrationFiles(skill.name, skill.files, runnerMcpGeneration(config.env)) } });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (skill.dir) {
|
|
387
|
+
const source = resolveExistingDir(skill.dir, config.cwd);
|
|
388
|
+
if (source && existsSync(join(source, "SKILL.md"))) resolved.push({ name: skill.name, dir: source, source: { kind: "inPlace" } });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return resolved;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function resolveProvisionedHooks(home: string, config: RunnerSpawnConfig): ResolvedHookAsset[] {
|
|
395
|
+
const { hooks } = profileProvisioning(config);
|
|
396
|
+
const parent = join(home, "relay-provisioned-hooks");
|
|
397
|
+
const resolved: ResolvedHookAsset[] = [];
|
|
398
|
+
for (const hook of hooks ?? []) {
|
|
399
|
+
const dir = join(parent, assetDirName(hook.name));
|
|
400
|
+
if (hook.files?.length) {
|
|
401
|
+
resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "files", files: hook.files } });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (hook.dir) {
|
|
405
|
+
const source = resolveExistingDir(hook.dir, config.cwd);
|
|
406
|
+
if (source) resolved.push({ name: hook.name, dir: source, hooks: hook.hooks, source: { kind: "inPlace" } });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "inPlace" } });
|
|
410
|
+
}
|
|
411
|
+
return resolved;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function renderProvisionedHookSet(hooks: ResolvedHookAsset[]): ProvisioningHookSet {
|
|
415
|
+
const out: ProvisioningHookSet = {};
|
|
416
|
+
for (const hook of hooks) {
|
|
417
|
+
for (const [event, entries] of Object.entries(hook.hooks)) {
|
|
418
|
+
out[event] = [
|
|
419
|
+
...(out[event] ?? []),
|
|
420
|
+
...entries.map((entry) => ({
|
|
421
|
+
...entry,
|
|
422
|
+
hooks: entry.hooks.map((command) => ({
|
|
423
|
+
...command,
|
|
424
|
+
command: command.command.replaceAll("${HOOK_DIR}", hook.dir),
|
|
425
|
+
})),
|
|
426
|
+
})),
|
|
427
|
+
];
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function writeCodexHooksJson(codexHome: string, hooks: ProvisioningHookSet): void {
|
|
434
|
+
mkdirSync(codexHome, { recursive: true });
|
|
435
|
+
if (!Object.keys(hooks).length) {
|
|
436
|
+
rmSync(join(codexHome, "hooks.json"), { force: true });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
writeFileSync(join(codexHome, "hooks.json"), JSON.stringify({ hooks }, null, 2), { mode: 0o600 });
|
|
440
|
+
writeCodexHookTrustState(codexHome, hooks);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const CODEX_HOOK_EVENT_STATE_KEYS: Record<string, string> = {
|
|
444
|
+
PreToolUse: "pre_tool_use",
|
|
445
|
+
PermissionRequest: "permission_request",
|
|
446
|
+
PostToolUse: "post_tool_use",
|
|
447
|
+
PreCompact: "pre_compact",
|
|
448
|
+
PostCompact: "post_compact",
|
|
449
|
+
SessionStart: "session_start",
|
|
450
|
+
UserPromptSubmit: "user_prompt_submit",
|
|
451
|
+
SubagentStart: "subagent_start",
|
|
452
|
+
SubagentStop: "subagent_stop",
|
|
453
|
+
Stop: "stop",
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
interface CodexHookTrustEntry {
|
|
457
|
+
key: string;
|
|
458
|
+
trustedHash: string;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function codexHookTrustEntries(codexHome: string, hooks: ProvisioningHookSet): CodexHookTrustEntry[] {
|
|
462
|
+
// Codex keys hooks.state on the canonicalized CODEX_HOME; a symlinked ancestor (macOS
|
|
463
|
+
// /var → /private/var) would leave a lexical-only key untrusted and the hook skipped.
|
|
464
|
+
return hookTrustEntries([...new Set([codexHome, realpathOr(codexHome)])].map((home) => join(home, "hooks.json")), hooks);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// #3025 — Codex also reads TOML hooks and their trust state from the `-c` session-flags layer,
|
|
468
|
+
// keyed on this synthetic source, so a launch can carry hooks while keeping any CODEX_HOME.
|
|
469
|
+
const CODEX_SESSION_FLAGS_SOURCE = "/<session-flags>/config.toml";
|
|
470
|
+
|
|
471
|
+
export function codexSessionHookArgs(hooks: ProvisioningHookSet): string[] {
|
|
472
|
+
const state = Object.fromEntries(hookTrustEntries([CODEX_SESSION_FLAGS_SOURCE], hooks).map((entry) => [entry.key, { trusted_hash: entry.trustedHash }]));
|
|
473
|
+
return ["-c", `hooks=${tomlInlineValue({ ...hooks, state })}`];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function hookTrustEntries(sourcePaths: string[], hooks: ProvisioningHookSet): CodexHookTrustEntry[] {
|
|
477
|
+
const entries: CodexHookTrustEntry[] = [];
|
|
478
|
+
for (const [eventName, groups] of Object.entries(hooks)) {
|
|
479
|
+
const eventKey = CODEX_HOOK_EVENT_STATE_KEYS[eventName];
|
|
480
|
+
if (!eventKey) continue;
|
|
481
|
+
groups.forEach((group, groupIndex) => {
|
|
482
|
+
group.hooks.forEach((handler, handlerIndex) => {
|
|
483
|
+
if (handler.type !== "command") return;
|
|
484
|
+
const trustedHash = codexCommandHookHash(eventKey, group.matcher, group, handler);
|
|
485
|
+
for (const sourcePath of sourcePaths) {
|
|
486
|
+
entries.push({ key: `${sourcePath}:${eventKey}:${groupIndex}:${handlerIndex}`, trustedHash });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return entries;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function realpathOr(path: string): string {
|
|
495
|
+
try {
|
|
496
|
+
return realpathSync(path);
|
|
497
|
+
} catch {
|
|
498
|
+
return path;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function writeCodexHookTrustState(codexHome: string, hooks: ProvisioningHookSet): void {
|
|
503
|
+
const entries = codexHookTrustEntries(codexHome, hooks);
|
|
504
|
+
if (!entries.length) return;
|
|
505
|
+
const configPath = join(codexHome, "config.toml");
|
|
506
|
+
let config = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
config = upsertCodexHookTrustEntry(config, entry);
|
|
509
|
+
}
|
|
510
|
+
writeFileSync(configPath, config, { mode: 0o600 });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function upsertCodexHookTrustEntry(config: string, entry: CodexHookTrustEntry): string {
|
|
514
|
+
const header = `[hooks.state.${tomlString(entry.key)}]`;
|
|
515
|
+
const lines = config.length ? config.split(/\n/) : [];
|
|
516
|
+
const headerIndex = lines.findIndex((line) => line.trim() === header);
|
|
517
|
+
if (headerIndex < 0) {
|
|
518
|
+
const prefix = config.endsWith("\n") || config.length === 0 ? config : `${config}\n`;
|
|
519
|
+
return `${prefix}\n${header}\ntrusted_hash = ${tomlString(entry.trustedHash)}\n`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let nextHeaderIndex = lines.length;
|
|
523
|
+
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
|
524
|
+
if (/^\s*\[.*\]\s*$/.test(lines[i] ?? "")) {
|
|
525
|
+
nextHeaderIndex = i;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const trustedHashLine = `trusted_hash = ${tomlString(entry.trustedHash)}`;
|
|
531
|
+
const existingIndex = lines
|
|
532
|
+
.slice(headerIndex + 1, nextHeaderIndex)
|
|
533
|
+
.findIndex((line) => /^\s*trusted_hash\s*=/.test(line));
|
|
534
|
+
if (existingIndex >= 0) {
|
|
535
|
+
lines[headerIndex + 1 + existingIndex] = trustedHashLine;
|
|
536
|
+
} else {
|
|
537
|
+
lines.splice(nextHeaderIndex, 0, trustedHashLine);
|
|
538
|
+
}
|
|
539
|
+
return lines.join("\n");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function codexCommandHookHash(
|
|
543
|
+
eventKey: string,
|
|
544
|
+
matcher: string | undefined,
|
|
545
|
+
group: ProvisioningHookSet[string][number],
|
|
546
|
+
handler: ProvisioningHookSet[string][number]["hooks"][number],
|
|
547
|
+
): string {
|
|
548
|
+
// Mirrors Codex's command_hook_hash: hash the normalized hook identity, not the source file.
|
|
549
|
+
const normalizedHandler: Record<string, unknown> = {
|
|
550
|
+
type: "command",
|
|
551
|
+
command: handler.command,
|
|
552
|
+
async: false,
|
|
553
|
+
timeout: Math.max(1, handler.timeout ?? 600),
|
|
554
|
+
};
|
|
555
|
+
if (handler.statusMessage !== undefined) normalizedHandler.statusMessage = handler.statusMessage;
|
|
556
|
+
|
|
557
|
+
const identity: Record<string, unknown> = {
|
|
558
|
+
event_name: eventKey,
|
|
559
|
+
hooks: [normalizedHandler],
|
|
560
|
+
};
|
|
561
|
+
if (matcher !== undefined) identity.matcher = matcher;
|
|
562
|
+
|
|
563
|
+
const canonical = canonicalJson(identity);
|
|
564
|
+
const hash = createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
565
|
+
return `sha256:${hash}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function canonicalJson(value: unknown): unknown {
|
|
569
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
570
|
+
if (value && typeof value === "object") {
|
|
571
|
+
return Object.fromEntries(
|
|
572
|
+
Object.entries(value as Record<string, unknown>)
|
|
573
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
574
|
+
.map(([key, val]) => [key, canonicalJson(val)]),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// #1096 — write relay-owned settings into an ISOLATED Claude managed home's `settings.json`
|
|
581
|
+
// (the DOCUMENTED hook source Claude honors under `--setting-sources "user"` or the default,
|
|
582
|
+
// unlike the inline `--settings` CLI flag). The managed home is relay-owned and freshly keyed
|
|
583
|
+
// per spawn, so an unconditional write is safe (profile-home seeds only `.claude.json`/`CLAUDE.md`,
|
|
584
|
+
// never `settings.json`). Removes the file when there is nothing relay-owned to deliver so disk
|
|
585
|
+
// stays == report. Never called for a host-base launch (which reuses the real `~/.claude`).
|
|
586
|
+
export function writeClaudeSettingsJson(claudeHome: string, settings: Record<string, unknown>): void {
|
|
587
|
+
mkdirSync(claudeHome, { recursive: true });
|
|
588
|
+
const path = join(claudeHome, "settings.json");
|
|
589
|
+
if (!Object.keys(settings).length) {
|
|
590
|
+
rmSync(path, { force: true });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// Claude can read this file while a replacement starts. Publish a complete JSON snapshot
|
|
594
|
+
// in one rename so it never observes a truncated or half-written settings file.
|
|
595
|
+
const pending = `${path}.relay-${process.pid}-${randomUUID()}.tmp`;
|
|
596
|
+
try {
|
|
597
|
+
writeFileSync(pending, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
598
|
+
renameSync(pending, path);
|
|
599
|
+
} finally {
|
|
600
|
+
rmSync(pending, { force: true });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// T3182 — merge a permissions.deny entry into a managed Claude settings payload WITHOUT
|
|
605
|
+
// clobbering permissions the payload already carries (deny lists union, everything else passes
|
|
606
|
+
// through). Pure — the caller composes and writes.
|
|
607
|
+
export function mergeClaudeSettingsDeny(settings: Record<string, unknown>, deny: string[]): Record<string, unknown> {
|
|
608
|
+
const permissions = typeof settings.permissions === "object" && settings.permissions !== null && !Array.isArray(settings.permissions)
|
|
609
|
+
? settings.permissions as Record<string, unknown>
|
|
610
|
+
: {};
|
|
611
|
+
const existing = Array.isArray(permissions.deny) ? permissions.deny : [];
|
|
612
|
+
return { ...settings, permissions: { ...permissions, deny: [...new Set([...existing, ...deny])] } };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Merge Relay-owned guarded control-plane grants without replacing caller-authored permissions. */
|
|
616
|
+
export function mergeClaudeSettingsAllow(settings: Record<string, unknown>, allow: string[]): Record<string, unknown> {
|
|
617
|
+
const permissions = typeof settings.permissions === "object" && settings.permissions !== null && !Array.isArray(settings.permissions)
|
|
618
|
+
? settings.permissions as Record<string, unknown>
|
|
619
|
+
: {};
|
|
620
|
+
const existing = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
621
|
+
return { ...settings, permissions: { ...permissions, allow: [...new Set([...existing, ...allow])] } };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export const CLAUDE_GUARDED_RELAY_TOOLS = [
|
|
625
|
+
"mcp__agent-relay__relay_send_message",
|
|
626
|
+
"mcp__agent-relay__relay_reply",
|
|
627
|
+
"mcp__agent-relay__relay_task_set_status",
|
|
628
|
+
"mcp__agent-relay__relay_task_comment",
|
|
629
|
+
"mcp__agent-relay__relay_task_deliver",
|
|
630
|
+
];
|
|
631
|
+
|
|
632
|
+
function guardedRelayTools(generation: ToolGeneration): string[] {
|
|
633
|
+
return CLAUDE_GUARDED_RELAY_TOOLS.map(name => `mcp__${mcpServerInfo(generation).name}__${wireToolName(name.split("__").at(-1)!, generation)}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function claudeGuardedRelaySettings(approvalMode: string | undefined, generation: ToolGeneration = "relay"): Record<string, unknown> {
|
|
637
|
+
return approvalMode === "guarded" ? { permissions: { allow: guardedRelayTools(generation) } } : {};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// #3388 — Claude's project scan loads CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md and .claude/rules/**
|
|
641
|
+
// from EVERY ancestor of the cwd, so host files above the repo (~/.claude/CLAUDE.md, ~/CLAUDE.md,
|
|
642
|
+
// ~/projects/CLAUDE.md) reach an agent despite the isolated CLAUDE_CONFIG_DIR. Host instructions
|
|
643
|
+
// `ignore` excludes them through the supported `claudeMdExcludes` setting. Rule: the repo root
|
|
644
|
+
// (nearest ancestor holding .git, else the cwd) and below are repo instructions and keep loading;
|
|
645
|
+
// everything strictly above it is host-level. The walk starts from the physical cwd, which is what
|
|
646
|
+
// the provider process sees.
|
|
647
|
+
export function claudeHostInstructionExcludeSettings(config: { agentProfile?: RunnerSpawnConfig["agentProfile"]; cwd: string }): Record<string, unknown> {
|
|
648
|
+
if (profileAllowsHostGlobalInstructions(config)) return {};
|
|
649
|
+
const excludes: string[] = [];
|
|
650
|
+
for (const home of new Set([homedir(), realpathOr(homedir())])) excludes.push(...claudeUserDirPatterns(home));
|
|
651
|
+
for (let dir = dirname(repoRootOf(realpathOr(resolve(config.cwd)))); ; dir = dirname(dir)) {
|
|
652
|
+
excludes.push(...claudeUserDirPatterns(dir), join(globLiteral(dir), "CLAUDE.md"), join(globLiteral(dir), "CLAUDE.local.md"));
|
|
653
|
+
if (dirname(dir) === dir) break;
|
|
654
|
+
}
|
|
655
|
+
return { claudeMdExcludes: [...new Set(excludes)] };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// claudeMdExcludes entries are picomatch globs, and Claude rewrites every backslash to "/" before
|
|
659
|
+
// matching, so backslash escapes break: wrap each metacharacter in a bracket literal instead.
|
|
660
|
+
function globLiteral(path: string): string {
|
|
661
|
+
return path.replace(/[*?[\](){}!+@]/g, "[$&]");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function claudeUserDirPatterns(dir: string): string[] {
|
|
665
|
+
const claudeDir = join(globLiteral(dir), ".claude");
|
|
666
|
+
return [join(claudeDir, "CLAUDE.md"), join(claudeDir, "rules", "**")];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function repoRootOf(cwd: string): string {
|
|
670
|
+
const start = resolve(cwd);
|
|
671
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
672
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
673
|
+
if (dirname(dir) === dir) return start;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Claude Code 2.1.282 persists bypass acceptance in user settings.json. */
|
|
678
|
+
export function claudeBypassPermissionSettings(approvalMode: string | undefined): Record<string, unknown> {
|
|
679
|
+
return approvalMode === "open" ? { skipDangerousModePermissionPrompt: true } : {};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function mergeClaudeRelayPermissions(settings: Record<string, unknown>, relayPermissions: unknown): Record<string, unknown> {
|
|
683
|
+
const candidate = typeof relayPermissions === "object" && relayPermissions !== null && !Array.isArray(relayPermissions)
|
|
684
|
+
? relayPermissions as Record<string, unknown>
|
|
685
|
+
: {};
|
|
686
|
+
const allow = Array.isArray(candidate.allow) ? candidate.allow.filter((item): item is string => typeof item === "string") : [];
|
|
687
|
+
return mergeClaudeSettingsAllow(settings, allow);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function withManagedClaudePermissions(settings: Record<string, unknown>, approvalMode: string | undefined, generation: ToolGeneration = "relay"): Record<string, unknown> {
|
|
691
|
+
const guarded = approvalMode === "guarded" ? mergeClaudeSettingsAllow(settings, guardedRelayTools(generation)) : settings;
|
|
692
|
+
return projectClaudePermissionRules(mergeClaudeSettingsDeny({ ...guarded, ...claudeBypassPermissionSettings(approvalMode), idleCompaction: false }, ["SendFeedback"]), generation);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Write the resolved assets to disk; returns the dirs that materialized successfully,
|
|
696
|
+
// in resolution order. Idempotent — safe to re-run against an existing home.
|
|
697
|
+
export function materializeResolvedAssets(assets: ResolvedAsset[]): string[] {
|
|
698
|
+
const dirs: string[] = [];
|
|
699
|
+
for (const asset of assets) {
|
|
700
|
+
if (materializeAsset(asset)) dirs.push(asset.dir);
|
|
701
|
+
}
|
|
702
|
+
return dirs;
|
|
703
|
+
}
|