@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,80 @@
|
|
|
1
|
+
import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
|
|
2
|
+
// #2947 — the orchestrator hands the runner the project's toolchain PATH plus shell hooks that re-lead
|
|
3
|
+
// it after the user's shell init. The provider process keeps the host PATH; only its shell tool re-leads.
|
|
4
|
+
// The same hooks keep the agent's private TMPDIR when a login profile resets it (#3084).
|
|
5
|
+
// A hook the user already configured is chained behind ours, never dropped; an inherited Relay hook never is (#3084).
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { browserToolsBinDir, isRecord, PROJECT_ENV_FILE_ENV, PROJECT_PATH_ENV, PROJECT_ZDOTDIR_ENV, RELAY_BIN_PATH_ENV } from "@humwick/sdk";
|
|
8
|
+
import { AGENT_TMPDIR_ENV } from "@humwick/sdk/host-tmpdir";
|
|
9
|
+
import { providerScratchEnv } from "@humwick/sdk/provider-scratch";
|
|
10
|
+
import { composeShellHooks, PROJECT_ENV_SCRIPT, PROJECT_ZDOTDIR } from "@humwick/sdk/shell-hooks";
|
|
11
|
+
import { tomlString } from "../relay-mcp";
|
|
12
|
+
import { humwickHome } from "../config";
|
|
13
|
+
import { providerSourceHome } from "../profile-home";
|
|
14
|
+
import { readCodexConfig } from "./codex-effective-config";
|
|
15
|
+
import { serviceAccessEnvKeys } from "./service-access";
|
|
16
|
+
|
|
17
|
+
/** #3084 — every launch, orchestrator-spawned or direct: the agent's private TMPDIR plus the hooks that keep
|
|
18
|
+
* it (and #2947's toolchain PATH) after shell init. */
|
|
19
|
+
export async function agentShellLaunchEnv(agentId: string, operatorEnv?: Record<string, string>): Promise<Record<string, string>> {
|
|
20
|
+
return writeChildEnv({}, { ...await providerScratchEnv(agentId, undefined, operatorEnv), [PROJECT_ENV_FILE_ENV]: PROJECT_ENV_SCRIPT, [PROJECT_ZDOTDIR_ENV]: PROJECT_ZDOTDIR, [RELAY_BIN_PATH_ENV]: relayBinPath() });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** #3489 — this runner's own `agent-relay-browser` and the host's provisioned `playwright-cli`: a login shell's
|
|
24
|
+
* PATH (an older global install) can never pick which Relay release or browser tools an agent runs. */
|
|
25
|
+
export function relayBinPath(): string {
|
|
26
|
+
return [resolve(import.meta.dir, "../../agent-bin"), browserToolsBinDir(humwickHome())].join(":");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Claude Code sources CLAUDE_ENV_FILE before every Bash tool command, after the user's shell init. */
|
|
30
|
+
export function claudeProjectShellEnv(env: Record<string, string>): Record<string, string> {
|
|
31
|
+
const file = relayEnv(env, PROJECT_ENV_FILE_ENV);
|
|
32
|
+
if (!shellHookNeeded(env) || !file) return {};
|
|
33
|
+
return composeShellHooks((key) => relayEnv(env, key), { CLAUDE_ENV_FILE: file });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Codex runs tool commands as `<login shell> -lc`: bash reads BASH_ENV, zsh reads its startup files from
|
|
37
|
+
* ZDOTDIR. `shell_environment_policy.set` reaches those commands only, never the app-server. The user's
|
|
38
|
+
* own values come from that same policy (config.toml, then `configArgs`), else the inherited env. */
|
|
39
|
+
export function codexProjectShellConfigArgs(env: Record<string, string>, configArgs: string[] = [], codexHome?: string): string[] {
|
|
40
|
+
const path = relayEnv(env, PROJECT_PATH_ENV);
|
|
41
|
+
if (!shellHookNeeded(env)) return [];
|
|
42
|
+
const configured = codexShellPolicySet(configArgs, codexHome || providerSourceHome("codex"));
|
|
43
|
+
if (relayEnv(env, "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE")) {
|
|
44
|
+
for (const key of serviceAccessEnvKeys(env)) if (relayEnv(configured, key) !== undefined && relayEnv(configured, key) !== relayEnv(env, key)) throw new Error("vault_env_conflict:shell:" + key);
|
|
45
|
+
const policy = codexShellPolicy(configArgs, codexHome || providerSourceHome("codex"));
|
|
46
|
+
if ((Array.isArray(policy.exclude) && policy.exclude.length) || (Array.isArray(policy.include_only) && policy.include_only.length) || (policy.inherit && policy.inherit !== "all")) throw new Error("vault_env_conflict:shell_environment_policy");
|
|
47
|
+
}
|
|
48
|
+
const user = (key: string) => typeof relayEnv(configured, key) === "string" ? relayEnv(configured, key) as string : relayEnv(env, key);
|
|
49
|
+
const set: Record<string, string | undefined> = writeChildEnv({}, {
|
|
50
|
+
[PROJECT_PATH_ENV]: path,
|
|
51
|
+
[RELAY_BIN_PATH_ENV]: relayEnv(env, RELAY_BIN_PATH_ENV),
|
|
52
|
+
...composeShellHooks(user, { BASH_ENV: relayEnv(env, PROJECT_ENV_FILE_ENV), ZDOTDIR: relayEnv(env, PROJECT_ZDOTDIR_ENV) }),
|
|
53
|
+
});
|
|
54
|
+
// An empty chain variable is set too: it clears a stale one the app-server env would hand down.
|
|
55
|
+
return [...(relayEnv(env, "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE") ? ["-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true"] : []),
|
|
56
|
+
...Object.entries(set).flatMap(([key, value]) => value === undefined ? [] : ["-c", `shell_environment_policy.set.${key}=${tomlString(value)}`])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** OpenCode has no tool-only env: its server env reaches every shell it runs. */
|
|
60
|
+
export function openCodeProjectShellEnv(env: Record<string, string>): Record<string, string> {
|
|
61
|
+
const file = relayEnv(env, PROJECT_ENV_FILE_ENV), zdotdir = relayEnv(env, PROJECT_ZDOTDIR_ENV);
|
|
62
|
+
if (!shellHookNeeded(env) || !file) return {};
|
|
63
|
+
return composeShellHooks((key) => relayEnv(env, key), { BASH_ENV: file, ...(zdotdir ? { ZDOTDIR: zdotdir } : {}) });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shellHookNeeded(env: Record<string, string>): boolean {
|
|
67
|
+
return Boolean(relayEnv(env, PROJECT_PATH_ENV) || relayEnv(env, AGENT_TMPDIR_ENV) || relayEnv(env, "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Codex's own `shell_environment_policy.set` without us: CODEX_HOME/config.toml, then `-c` overrides in order. */
|
|
71
|
+
function codexShellPolicySet(configArgs: string[], codexHome: string): Record<string, unknown> {
|
|
72
|
+
const policy = codexShellPolicy(configArgs, codexHome);
|
|
73
|
+
return isRecord(policy.set) ? policy.set : {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function codexShellPolicy(configArgs: string[], codexHome: string): Record<string, unknown> {
|
|
77
|
+
const config = readCodexConfig(configArgs, codexHome);
|
|
78
|
+
const policy = config.shell_environment_policy;
|
|
79
|
+
return isRecord(policy) ? policy : {};
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SpawnProvider } from "@humwick/sdk";
|
|
2
|
+
import type { ProviderConfig, RunnerSpawnConfig, SpawnArgs } from "../adapter";
|
|
3
|
+
import { primaryProviderInvocation, type ProviderInvocation } from "../provider-double";
|
|
4
|
+
import { buildCodexTuiSpawnArgs } from "./codex-launch-config";
|
|
5
|
+
|
|
6
|
+
/** Provider-owned supplemental process roles derived from the production launch configuration. */
|
|
7
|
+
export function buildSupplementalProviderInvocations(
|
|
8
|
+
provider: SpawnProvider,
|
|
9
|
+
config: RunnerSpawnConfig,
|
|
10
|
+
providerConfig: ProviderConfig,
|
|
11
|
+
primary: SpawnArgs,
|
|
12
|
+
): ProviderInvocation[] {
|
|
13
|
+
if (provider !== "codex") return [];
|
|
14
|
+
const tui = buildCodexTuiSpawnArgs(
|
|
15
|
+
config,
|
|
16
|
+
providerConfig,
|
|
17
|
+
primary.env.CODEX_APP_SERVER_URL ?? "",
|
|
18
|
+
primary.env,
|
|
19
|
+
);
|
|
20
|
+
return tui ? [{ ...primaryProviderInvocation(provider, tui), role: "tui" }] : [];
|
|
21
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { writeChildEnv } from "@humwick/sdk/env";
|
|
2
|
+
import type { QuotaState, HumwickHttpClient } from "@humwick/sdk";
|
|
3
|
+
import { getManifest } from "@humwick/providers";
|
|
4
|
+
import { quotaStateFromProbeMetrics } from "@humwick/sdk/context-probe";
|
|
5
|
+
import { claudeQuotaGeneration, claudeQuotaUnavailableWindows } from "@humwick/sdk/provider-quota-order";
|
|
6
|
+
import { readRunnerContextProbeState } from "../context-probe-state";
|
|
7
|
+
import { providerHostConfigHome } from "../profile-home";
|
|
8
|
+
import { DEFAULT_PROVIDER_QUOTA_CONFIG, normalizeProviderQuotaConfig, resolveClaudeStatuslineQuotaIdentity, type ProviderQuotaIdentity } from "../quota";
|
|
9
|
+
|
|
10
|
+
const PASSIVE_QUOTA_CONFIG_REFRESH_MS = 60_000;
|
|
11
|
+
const PASSIVE_QUOTA_SAMPLE_STALE_MS = 10 * 60_000;
|
|
12
|
+
const PASSIVE_QUOTA_LEASE_MAX_TTL_MS = 10 * 60_000;
|
|
13
|
+
const PASSIVE_QUOTA_LEASE_PADDING_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
interface ProviderQuotaHarvestOptions {
|
|
16
|
+
agentId: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
/** The runner's per-provider config env (central provider-config values), read lazily each publish. */
|
|
19
|
+
providerConfigEnv: () => Record<string, string>;
|
|
20
|
+
http: () => Pick<HumwickHttpClient, "acquireProviderQuotaPublisherLease" | "getProviderQuotaConfig" | "upsertProviderQuota">;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ProviderQuotaHarvest {
|
|
24
|
+
private config = { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
|
|
25
|
+
private configRefreshAt = 0;
|
|
26
|
+
private lastReportAt?: number;
|
|
27
|
+
private lastReportedObservedAt?: number;
|
|
28
|
+
private lease?: { accountKey: string; leaseToken: string; expiresAt: number };
|
|
29
|
+
private nextLeaseAttemptAt?: number;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly options: ProviderQuotaHarvestOptions) {}
|
|
32
|
+
|
|
33
|
+
async publish(): Promise<void> {
|
|
34
|
+
// #2514 — the statusline harvest serves the whole claude FAMILY (claude + claude-b): the
|
|
35
|
+
// manifest's shim kind is the family declaration, so a second-subscription provider reuses
|
|
36
|
+
// this path under its own id without a provider list here.
|
|
37
|
+
if (getManifest(this.options.provider)?.shim?.kind !== "claude-relay") return;
|
|
38
|
+
const probeMetrics = readRunnerContextProbeState(this.options.agentId);
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const quota = probeMetrics ? freshQuotaFromProbeMetrics(probeMetrics, now) : undefined;
|
|
41
|
+
// An idle session re-sends nothing new; skipping lets its lease lapse to an active session (#3082).
|
|
42
|
+
if (!quota || (this.lastReportedObservedAt !== undefined && quota.updatedAt <= this.lastReportedObservedAt)) return;
|
|
43
|
+
const config = await this.refreshConfig();
|
|
44
|
+
if (!config.enabled) return;
|
|
45
|
+
if (this.lastReportAt !== undefined && now - this.lastReportAt < config.pollIntervalMs) return;
|
|
46
|
+
const identity = await this.resolveIdentity();
|
|
47
|
+
if (!identity) return;
|
|
48
|
+
if (!await this.ensurePublisherLease(identity.accountKey, config.pollIntervalMs, now)) return;
|
|
49
|
+
await this.options.http().upsertProviderQuota({
|
|
50
|
+
provider: this.options.provider,
|
|
51
|
+
accountKey: identity.accountKey,
|
|
52
|
+
quota,
|
|
53
|
+
unavailableWindows: claudeQuotaUnavailableWindows(quota),
|
|
54
|
+
lastAttemptAt: now,
|
|
55
|
+
sourceAgentId: this.options.agentId,
|
|
56
|
+
});
|
|
57
|
+
this.lastReportAt = now;
|
|
58
|
+
this.lastReportedObservedAt = quota.updatedAt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// #2514 — quota identity resolves exactly as the LAUNCH authenticates. The primary claude
|
|
62
|
+
// runner keeps the historical ambient-env read byte-for-byte. A claude-family provider with
|
|
63
|
+
// its own subscription (claude-b) must never inherit the ambient primary credential env
|
|
64
|
+
// (CLAUDE_CODE_OAUTH_TOKEN outranks the file credential, so the B quota would be keyed — and
|
|
65
|
+
// folded — under the A account): its identity comes from its OWN dedicated home plus its
|
|
66
|
+
// provider-scoped config env, mirroring the adapter launch scrub and the orchestrator's
|
|
67
|
+
// providerCredentialProbeEnv precedence.
|
|
68
|
+
private async resolveIdentity(): Promise<ProviderQuotaIdentity | undefined> {
|
|
69
|
+
if (this.options.provider === "claude") {
|
|
70
|
+
return resolveClaudeStatuslineQuotaIdentity({ env: process.env });
|
|
71
|
+
}
|
|
72
|
+
const providerEnv = this.options.providerConfigEnv();
|
|
73
|
+
return resolveClaudeStatuslineQuotaIdentity({
|
|
74
|
+
provider: this.options.provider,
|
|
75
|
+
env: writeChildEnv({}, { CLAUDE_CODE_OAUTH_TOKEN: providerEnv.CLAUDE_CODE_OAUTH_TOKEN }),
|
|
76
|
+
configDir: providerHostConfigHome(this.options.provider),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async refreshConfig() {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
if (now < this.configRefreshAt) return this.config;
|
|
83
|
+
this.configRefreshAt = now + PASSIVE_QUOTA_CONFIG_REFRESH_MS;
|
|
84
|
+
try {
|
|
85
|
+
const map = await this.options.http().getProviderQuotaConfig();
|
|
86
|
+
const providerCfg = map[this.options.provider];
|
|
87
|
+
this.config = providerCfg
|
|
88
|
+
? normalizeProviderQuotaConfig(providerCfg)
|
|
89
|
+
: { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
|
|
90
|
+
} catch {
|
|
91
|
+
// Keep the previous config; defaults preserve collection if the relay is older
|
|
92
|
+
// or temporarily unreachable during a liveness tick.
|
|
93
|
+
}
|
|
94
|
+
return this.config;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async ensurePublisherLease(accountKey: string, pollIntervalMs: number, now: number): Promise<boolean> {
|
|
98
|
+
if (this.lease?.accountKey === accountKey && this.lease.expiresAt > now) return true;
|
|
99
|
+
if (this.nextLeaseAttemptAt !== undefined && now < this.nextLeaseAttemptAt) return false;
|
|
100
|
+
const leaseToken = this.lease?.accountKey === accountKey ? this.lease.leaseToken : undefined;
|
|
101
|
+
const result = await this.options.http().acquireProviderQuotaPublisherLease({
|
|
102
|
+
provider: this.options.provider,
|
|
103
|
+
accountKey,
|
|
104
|
+
sourceAgentId: this.options.agentId,
|
|
105
|
+
...(leaseToken ? { leaseToken } : {}),
|
|
106
|
+
ttlMs: Math.min(PASSIVE_QUOTA_LEASE_MAX_TTL_MS, pollIntervalMs + PASSIVE_QUOTA_LEASE_PADDING_MS),
|
|
107
|
+
});
|
|
108
|
+
if (!result.acquired || !result.lease) {
|
|
109
|
+
this.lease = undefined;
|
|
110
|
+
this.nextLeaseAttemptAt = now + Math.min(result.retryAfterMs ?? pollIntervalMs, pollIntervalMs);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
this.lease = { accountKey, leaseToken: result.lease.leaseToken, expiresAt: result.lease.expiresAt };
|
|
114
|
+
this.nextLeaseAttemptAt = undefined;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function freshQuotaFromProbeMetrics(metrics: NonNullable<ReturnType<typeof readRunnerContextProbeState>>, now: number): QuotaState | undefined {
|
|
120
|
+
if (now - (metrics.quotaObservedAt ?? metrics.timestamp) > PASSIVE_QUOTA_SAMPLE_STALE_MS) return undefined;
|
|
121
|
+
const quota = quotaStateFromProbeMetrics(metrics);
|
|
122
|
+
if (!quota) return undefined;
|
|
123
|
+
const windows = quota.windows.filter((window) => window.resetsAt === undefined || window.resetsAt > now);
|
|
124
|
+
const live: QuotaState = { ...quota, windows };
|
|
125
|
+
// Do not turn an expired cached 5h reading into a fresh weekly-only observation (#3087).
|
|
126
|
+
return windows.length > 0 && (claudeQuotaGeneration(live) !== undefined || claudeQuotaUnavailableWindows(quota).length > 0) ? live : undefined;
|
|
127
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { rootCertificates } from "node:tls";
|
|
6
|
+
import type { ProvisioningMcpServer } from "@humwick/sdk";
|
|
7
|
+
import { shellQuote } from "@humwick/sdk/shell-utils";
|
|
8
|
+
import { activeSharedMcpServers, PROJECT_ENV_FILE_ENV, PROJECT_ZDOTDIR_ENV } from "@humwick/sdk";
|
|
9
|
+
import { PROJECT_ENV_SCRIPT, PROJECT_ZDOTDIR } from "@humwick/sdk/shell-hooks";
|
|
10
|
+
import type { RunnerSpawnConfig } from "../adapter";
|
|
11
|
+
import { extractCallerSettings } from "../claude-settings";
|
|
12
|
+
import { codexConfigOverrides, isRecord } from "@humwick/sdk";
|
|
13
|
+
import { projectSettingSources, providerHomePathFor, providerSourceHome } from "../profile-home";
|
|
14
|
+
|
|
15
|
+
export const SERVICE_ACCESS_ENV_FILE = "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE";
|
|
16
|
+
const PROXY_CA_ENV_KEYS = ["SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"] as const;
|
|
17
|
+
export const PROXY_TRUST_ENV_KEYS = ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "NO_PROXY", "no_proxy", "NODE_USE_ENV_PROXY", ...PROXY_CA_ENV_KEYS] as const;
|
|
18
|
+
|
|
19
|
+
function fail(code: string): never { throw new Error(code); }
|
|
20
|
+
|
|
21
|
+
function readLaunchFile(file: string): string {
|
|
22
|
+
const stat = statSync(file);
|
|
23
|
+
if (!stat.isFile() || stat.size > 4 * 1024 * 1024) fail("vault_invalid_launch_file");
|
|
24
|
+
return readFileSync(file, "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serviceAccessEnvKeys(env: Record<string, string>): string[] {
|
|
28
|
+
return [...PROXY_TRUST_ENV_KEYS, SERVICE_ACCESS_ENV_FILE, ...Object.keys(env).filter((key) => relayEnv(env, key) === "__relay_dummy__")];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Runs after profile/provider env merging, before hydration or any child starts. */
|
|
32
|
+
export function prepareServiceAccessConfig(config: RunnerSpawnConfig): RunnerSpawnConfig {
|
|
33
|
+
const access = config.serviceAccess;
|
|
34
|
+
if (!access) {
|
|
35
|
+
const servers = Object.values(config.agentProfile?.provisioning?.mcpServers ?? {});
|
|
36
|
+
if (servers.some((server) => server.serviceAccess?.services.length)) fail("vault_mcp_service_ungranted");
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
if (!access.caPem || !access.caFingerprint || !access.services?.length) fail("vault_missing_trust");
|
|
40
|
+
try {
|
|
41
|
+
const cert = new X509Certificate(access.caPem);
|
|
42
|
+
if (!cert.ca || cert.fingerprint256.replaceAll(":", "").toLowerCase() !== access.caFingerprint || Date.parse(cert.validTo) <= Date.now()) fail("vault_ca_mismatch");
|
|
43
|
+
} catch { fail("vault_ca_mismatch"); }
|
|
44
|
+
if (access.expiresAt <= Date.now()) fail("vault_token_expired");
|
|
45
|
+
const inherited = writeChildEnv({}, { ...config.env, ...config.providerConfig.env });
|
|
46
|
+
if (inherited.NODE_TLS_REJECT_UNAUTHORIZED === "0" || inherited.GIT_SSL_NO_VERIFY === "true") fail("vault_tls_verification_disabled");
|
|
47
|
+
// Ambient routing cannot silently bypass a brokered host, even through ALL_PROXY.
|
|
48
|
+
for (const key of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"]) {
|
|
49
|
+
if (relayEnv(inherited, key)) fail("vault_env_conflict:" + key);
|
|
50
|
+
}
|
|
51
|
+
const proxy = new URL(access.proxyUrl);
|
|
52
|
+
if (!["http:", "https:"].includes(proxy.protocol) || proxy.username || proxy.password) fail("vault_invalid_proxy");
|
|
53
|
+
proxy.username = access.token; proxy.password = access.vault;
|
|
54
|
+
const bypass = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
55
|
+
for (const endpoint of [config.relayUrl, config.relayMcpEndpoint, config.sharedMcpUrl]) {
|
|
56
|
+
if (endpoint) bypass.add(new URL(endpoint).hostname);
|
|
57
|
+
}
|
|
58
|
+
if (access.services.some((service) => bypass.has(service.host))) fail("vault_service_bypass");
|
|
59
|
+
const extraRoots: string[] = [];
|
|
60
|
+
for (const key of PROXY_CA_ENV_KEYS) {
|
|
61
|
+
const value = relayEnv(inherited, key);
|
|
62
|
+
if (!value) continue;
|
|
63
|
+
try { extraRoots.push(readLaunchFile(value)); } catch { fail("vault_missing_trust:" + key); }
|
|
64
|
+
}
|
|
65
|
+
const scratch = inherited.TMPDIR;
|
|
66
|
+
if (!scratch || !isAbsolute(scratch)) fail("vault_missing_private_tmpdir");
|
|
67
|
+
const dummy: Record<string, string> = {};
|
|
68
|
+
for (const service of access.services) for (const [key, value] of Object.entries(service.dummyEnv)) {
|
|
69
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || value !== "__relay_dummy__" || (relayEnv(inherited, key) && relayEnv(inherited, key) !== value) || (PROXY_TRUST_ENV_KEYS as readonly string[]).includes(key)) fail("vault_env_conflict:" + key);
|
|
70
|
+
dummy[key] = value;
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(scratch, { recursive: true, mode: 0o700 });
|
|
73
|
+
const dir = mkdtempSync(join(scratch, "vault-launch-"));
|
|
74
|
+
const bundle = join(dir, "roots.pem"), script = join(dir, "env.sh");
|
|
75
|
+
const systemRoots = ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"].flatMap((file) => {
|
|
76
|
+
try { return [readLaunchFile(file)]; } catch { return []; }
|
|
77
|
+
});
|
|
78
|
+
const combined = [...rootCertificates, ...systemRoots, ...extraRoots, access.caPem].join("\n");
|
|
79
|
+
writeFileSync(bundle, combined, { mode: 0o600 });
|
|
80
|
+
accessSync(bundle, constants.R_OK);
|
|
81
|
+
const env: Record<string, string> = writeChildEnv({}, {
|
|
82
|
+
...dummy, HTTPS_PROXY: proxy.href, HTTP_PROXY: proxy.href, https_proxy: proxy.href, http_proxy: proxy.href,
|
|
83
|
+
NO_PROXY: [...bypass].join(","), no_proxy: [...bypass].join(","),
|
|
84
|
+
...Object.fromEntries(PROXY_CA_ENV_KEYS.map((key) => [key, bundle])), NODE_USE_ENV_PROXY: "1", [SERVICE_ACCESS_ENV_FILE]: script,
|
|
85
|
+
});
|
|
86
|
+
writeFileSync(script, Object.entries(env).map(([key, value]) => `export ${key}=${shellQuote(value)}`).join("\n") + "\n", { mode: 0o600 });
|
|
87
|
+
return { ...config, providerConfig: { ...config.providerConfig, env: writeChildEnv({}, { ...config.providerConfig.env, ...env }) }, serviceAccessEnv: env, env: writeChildEnv({}, { ...config.env, ...env, [PROJECT_ENV_FILE_ENV]: PROJECT_ENV_SCRIPT, [PROJECT_ZDOTDIR_ENV]: PROJECT_ZDOTDIR }) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** References carry the child's env into native MCP config without writing the proxy token. */
|
|
91
|
+
export function serviceAccessMcpServers(config: RunnerSpawnConfig, servers: Record<string, ProvisioningMcpServer>): Record<string, ProvisioningMcpServer> {
|
|
92
|
+
servers = activeSharedMcpServers(servers);
|
|
93
|
+
if (!config.serviceAccessEnv) {
|
|
94
|
+
return servers;
|
|
95
|
+
}
|
|
96
|
+
const keys = Object.keys(config.serviceAccessEnv);
|
|
97
|
+
return Object.fromEntries(Object.entries(servers).map(([name, server]) => {
|
|
98
|
+
const required = server.serviceAccess?.services ?? [];
|
|
99
|
+
if (required.some((service) => !config.serviceAccess?.services?.some((entry) => entry.name === service))) fail("vault_mcp_service_ungranted:" + name);
|
|
100
|
+
if (!server.command || server.url) {
|
|
101
|
+
const referencesDummy = Object.keys(config.serviceAccessEnv!).some((key) => key in (server.env ?? {}) || key in (server.configEnv ?? {}));
|
|
102
|
+
if (required.length || referencesDummy) fail("unsupported_execution_boundary:" + name);
|
|
103
|
+
return [name, server];
|
|
104
|
+
}
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
const value = relayEnv(server.env, key) ?? relayEnv(server.configEnv, key);
|
|
107
|
+
if (value !== undefined && value !== `\${${key}}` && value !== config.serviceAccessEnv![key]) fail("vault_env_conflict:mcp:" + name + ":" + key);
|
|
108
|
+
}
|
|
109
|
+
if (config.provider === "codex") {
|
|
110
|
+
const env = writeChildEnv({}, { ...server.env }), configEnv = writeChildEnv({}, { ...server.configEnv });
|
|
111
|
+
for (const key of keys) { writeChildEnv(env, {}, [key]); writeChildEnv(configEnv, {}, [key]); }
|
|
112
|
+
return [name, { ...server, env, configEnv, env_vars: [...new Set([...(Array.isArray(server.env_vars) ? server.env_vars : Object.keys(server.env_vars ?? {})), ...keys])] }];
|
|
113
|
+
}
|
|
114
|
+
return [name, { ...server, env: writeChildEnv({}, { ...server.env, ...Object.fromEntries(keys.map((key) => [key, `\${${key}}`])) }) }];
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function serviceAccessLaunchEnv(config: RunnerSpawnConfig): Record<string, string> {
|
|
119
|
+
if (config.serviceAccess && !config.serviceAccessEnv) fail("vault_overlay_not_prepared");
|
|
120
|
+
for (const [key, value] of Object.entries(config.serviceAccessEnv ?? {})) {
|
|
121
|
+
if (relayEnv(config.env, key) !== value || (relayEnv(config.providerConfig.env, key) !== undefined && relayEnv(config.providerConfig.env, key) !== value)) fail("vault_env_conflict:" + key);
|
|
122
|
+
}
|
|
123
|
+
if (config.serviceAccessEnv) {
|
|
124
|
+
const caller = extractCallerSettings([config.providerConfig.defaultArgs, config.providerArgs], config.cwd);
|
|
125
|
+
if (caller && !caller.settings) fail("vault_unreadable_settings");
|
|
126
|
+
const settingsEnv = caller?.settings?.env;
|
|
127
|
+
const checkSettings = (settings: unknown) => {
|
|
128
|
+
const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {};
|
|
129
|
+
for (const key of Object.keys(config.serviceAccessEnv!)) if (relayEnv(env, key) !== undefined && relayEnv(env, key) !== config.serviceAccessEnv![key]) fail("vault_env_conflict:settings:" + key);
|
|
130
|
+
if (env.NODE_TLS_REJECT_UNAUTHORIZED === "0") fail("vault_tls_verification_disabled");
|
|
131
|
+
};
|
|
132
|
+
checkSettings({ env: settingsEnv });
|
|
133
|
+
if (config.provider.startsWith("claude")) {
|
|
134
|
+
let sources = projectSettingSources(config) ?? ["user", "project", "local"];
|
|
135
|
+
const args = [...config.providerConfig.defaultArgs, ...config.providerArgs];
|
|
136
|
+
for (let i = 0; i < args.length; i++) {
|
|
137
|
+
if (args[i] === "--setting-sources") sources = (args[++i] ?? "").split(",");
|
|
138
|
+
else if (args[i]!.startsWith("--setting-sources=")) sources = args[i]!.slice("--setting-sources=".length).split(",");
|
|
139
|
+
}
|
|
140
|
+
const files = sources.includes("user") ? [join(providerHomePathFor(config.provider, config) ?? providerSourceHome(config.provider), "settings.json")] : [];
|
|
141
|
+
for (let dir = config.cwd; ; dir = dirname(dir)) {
|
|
142
|
+
if (sources.includes("project")) files.push(join(dir, ".claude/settings.json"));
|
|
143
|
+
if (sources.includes("local")) files.push(join(dir, ".claude/settings.local.json"));
|
|
144
|
+
if (dirname(dir) === dir) break;
|
|
145
|
+
}
|
|
146
|
+
for (const file of files) if (existsSync(file)) checkSettings(JSON.parse(readLaunchFile(file)));
|
|
147
|
+
}
|
|
148
|
+
if (config.provider === "codex") for (const override of codexConfigOverrides([config.providerConfig.defaultArgs, config.providerArgs].flat())) {
|
|
149
|
+
if (/^mcp_servers\./.test(override) && /\.(?:env|env_vars)(?:\.|\s*=)/.test(override)) fail("vault_env_conflict:codex_mcp_override");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return config.serviceAccessEnv ?? {};
|
|
153
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Off-loop tmux subprocess transport (#1838 Wave 2, rows 12/14/15/16 — first real
|
|
2
|
+
// adoption of the #1809/#1822 offload primitive).
|
|
3
|
+
//
|
|
4
|
+
// Every tmux subprocess the runner spawns from a HOT PATH — the 2s pane watcher, the 4s
|
|
5
|
+
// busy reconciler, the 1.5s monitorless turn watch, and the per-payload input transport —
|
|
6
|
+
// runs through the shared `SubprocessRunner` created here so it executes as an async
|
|
7
|
+
// `Bun.spawn`, bounded by a hard deadline (SIGTERM → SIGKILL of the whole process group
|
|
8
|
+
// after a grace window), instead of a synchronous `Bun.spawnSync` that pins the runner's
|
|
9
|
+
// single event loop for the entire 10s deadline while a wedged tmux socket hangs (the
|
|
10
|
+
// #1760 class; #1815 findings 1/6/7/10). A blocked tmux call here would otherwise freeze
|
|
11
|
+
// this agent's bus connection, control server, heartbeat, and every timer at once.
|
|
12
|
+
//
|
|
13
|
+
// AT-MOST-ONCE — a property of the backend, not a per-call flag. The offload envelope's
|
|
14
|
+
// `retrySafe` flag is a WorkerPool-only knob (only the CPU worker pool ever re-dispatches
|
|
15
|
+
// after an ambiguous worker death); the SubprocessRunner NEVER re-runs a spawn under any
|
|
16
|
+
// circumstance. So every tmux command routed through here is at-most-once by construction.
|
|
17
|
+
// That is exactly what the SIDE-EFFECTING sites require — a silently re-run `send-keys` or
|
|
18
|
+
// `paste-buffer` would double-type into the live pane — and it is equally fine for the
|
|
19
|
+
// READ-ONLY sites (capture-pane / has-session / display-message), whose next poll tick
|
|
20
|
+
// simply re-reads. No site here is idempotent-and-expensive enough to want retry, so none
|
|
21
|
+
// passes `retrySafe`; the default at-most-once behaviour is correct for all of them.
|
|
22
|
+
import { createSubprocessRunner, type SubprocessResult, type SubprocessRunner } from "@humwick/offload";
|
|
23
|
+
import { tmuxCommand } from "@humwick/sdk/tmux-utils";
|
|
24
|
+
import { SYNC_SUBPROCESS_TIMEOUT_MS } from "../bounded-spawn-sync";
|
|
25
|
+
|
|
26
|
+
let runner: SubprocessRunner | undefined;
|
|
27
|
+
|
|
28
|
+
// Lazily created so merely importing this module spawns nothing. concurrency > 1 keeps the
|
|
29
|
+
// three independent pollers plus an occasional input transport from head-of-line blocking
|
|
30
|
+
// each other behind a single slot, while still bounding fan-out onto the one tmux server a
|
|
31
|
+
// runner process talks to. Each run is bounded by the same 10s deadline the old sync
|
|
32
|
+
// wrapper (`spawnSyncBounded`) used, so timeout semantics are unchanged — only the blocking
|
|
33
|
+
// is gone.
|
|
34
|
+
function tmuxRunner(): SubprocessRunner {
|
|
35
|
+
return (runner ??= createSubprocessRunner({ concurrency: 4, defaultTimeoutMs: SYNC_SUBPROCESS_TIMEOUT_MS }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Test/teardown seam: cancel in-flight tmux spawns and drop the shared runner. Not wired
|
|
39
|
+
// into adapter shutdown (the runner is process-scoped and shared across adapter instances;
|
|
40
|
+
// a live child is SIGKILLed by the offload module's process `exit` hook regardless), but
|
|
41
|
+
// tests that want a clean slate can await this.
|
|
42
|
+
export async function destroyTmuxRunner(): Promise<void> {
|
|
43
|
+
const current = runner;
|
|
44
|
+
runner = undefined;
|
|
45
|
+
if (current) await current.destroy();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Per-call bounding for a read that sits inside a caller's OWN deadline loop (#1838 round-3
|
|
49
|
+
* MED): pass the REMAINING outer budget as `timeoutMs` so the subprocess deadline shrinks with
|
|
50
|
+
* the loop, and/or a `signal` the caller aborts at its absolute deadline. Without this each run
|
|
51
|
+
* would get the runner's full ~10s default and blow the caller's (e.g. 500ms) deadline. */
|
|
52
|
+
export interface TmuxReadOpts { signal?: AbortSignal; timeoutMs?: number }
|
|
53
|
+
|
|
54
|
+
async function runTmux(
|
|
55
|
+
taskName: string,
|
|
56
|
+
argv: string[],
|
|
57
|
+
opts: { okExitCodes?: readonly number[] | "any" } & TmuxReadOpts = {},
|
|
58
|
+
): Promise<SubprocessResult> {
|
|
59
|
+
return tmuxRunner().run(taskName, { command: argv, okExitCodes: opts.okExitCodes }, { signal: opts.signal, timeoutMs: opts.timeoutMs });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Off-loop `tmux capture-pane` (read-only, idempotent). Throws on a non-zero exit / timeout
|
|
64
|
+
* / abort exactly like the synchronous `captureTmuxPane` it replaces on the hot paths, so
|
|
65
|
+
* the existing `try { … } catch { … }` call sites are unchanged apart from the `await`.
|
|
66
|
+
*/
|
|
67
|
+
export async function captureTmuxPaneAsync(sessionName: string, socketName?: string, opts: TmuxReadOpts = {}): Promise<string> {
|
|
68
|
+
const result = await runTmux("tmux-capture-pane", tmuxCapturePaneArgv(sessionName, socketName, TMUX_CAPTURE_SCROLLBACK_LINES), opts);
|
|
69
|
+
return result.stdout;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Lines of tmux HISTORY the scrollback capture reaches back over, on top of the visible screen. */
|
|
73
|
+
export const TMUX_CAPTURE_SCROLLBACK_LINES = 80;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `tmux capture-pane` argv, with the scrollback window as the single explicit knob.
|
|
77
|
+
* `scrollbackLines` omitted/0 ⇒ the VISIBLE SCREEN ONLY (no `-S`). Exported so the two capture
|
|
78
|
+
* scopes are assertable in a unit test — the #2261 regression was exactly a caller reading the
|
|
79
|
+
* wrong one.
|
|
80
|
+
*/
|
|
81
|
+
export function tmuxCapturePaneArgv(sessionName: string, socketName?: string, scrollbackLines = 0, styled = false): string[] {
|
|
82
|
+
const style = styled ? ["-e"] : [];
|
|
83
|
+
return scrollbackLines > 0
|
|
84
|
+
? tmuxCommand(socketName, "capture-pane", "-p", ...style, "-t", sessionName, "-S", `-${scrollbackLines}`)
|
|
85
|
+
: tmuxCommand(socketName, "capture-pane", "-p", ...style, "-t", sessionName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Off-loop `tmux capture-pane` of the VISIBLE SCREEN ONLY — no `-S` scrollback window.
|
|
90
|
+
*
|
|
91
|
+
* #2261: the difference matters for anything that reads a repaint as evidence. Claude Code's
|
|
92
|
+
* `/clear` repaints only the visible screen (`ESC[H` + a per-line `ESC[2K`; it never emits the
|
|
93
|
+
* `ESC[3J` that would drop tmux's history), so the pre-clear conversation SURVIVES verbatim in
|
|
94
|
+
* scrollback. A `-S -80` capture therefore still contains the wiped conversation long after the
|
|
95
|
+
* clear committed — which made the clear-commit barrier's "the anchor is gone" test unsatisfiable
|
|
96
|
+
* and turned every successful self-resume clear on a scrolled pane into a false negative. Detectors
|
|
97
|
+
* that ask "did the TUI repaint this away?" must read this window, not the scrollback one.
|
|
98
|
+
*/
|
|
99
|
+
export async function captureTmuxVisiblePaneAsync(sessionName: string, socketName?: string, opts: TmuxReadOpts & { styled?: boolean } = {}): Promise<string> {
|
|
100
|
+
const { styled, ...readOpts } = opts;
|
|
101
|
+
// `styled` keeps SGR escapes (`-e`) for readers that must tell dim placeholder text from input (#2664).
|
|
102
|
+
const result = await runTmux("tmux-capture-pane", tmuxCapturePaneArgv(sessionName, socketName, 0, styled), readOpts);
|
|
103
|
+
return result.stdout;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Off-loop `tmux has-session` outcome. `unreadable` is NOT `absent` — see below. */
|
|
107
|
+
export type TmuxSessionPresence = "present" | "absent" | "unreadable";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Off-loop `tmux has-session` (read-only, idempotent), as a TRI-STATE (#1838 review HIGH-2).
|
|
111
|
+
*
|
|
112
|
+
* A boolean `present`/`!present` collapses "the session is gone" together with "I could not
|
|
113
|
+
* find out", so a wedged-but-LIVE tmux server (has-session SIGKILLed at its deadline) would
|
|
114
|
+
* read as death and let a watcher emit `offline` / abandon live work. The states are kept
|
|
115
|
+
* distinct here and only the caller decides what a non-`present` reading means:
|
|
116
|
+
* - `present` — tmux exited 0: the session exists.
|
|
117
|
+
* - `absent` — tmux exited 1: the ONLY positive proof of death. `has-session` exits 1
|
|
118
|
+
* both for a missing session and for "no server running", and a dead server
|
|
119
|
+
* genuinely took all its sessions with it, so both are real absence.
|
|
120
|
+
* - `unreadable` — timeout / abort / spawn error / any exit ≥ 2: a fact about the READ, never
|
|
121
|
+
* about the session. The caller MUST hold its current state and retry, never
|
|
122
|
+
* treat this as death.
|
|
123
|
+
* `okExitCodes: [0, 1]` lets the two real answers through without a throw/strike; everything
|
|
124
|
+
* else (including the deadline SIGKILL that bounds a wedged socket) rejects and maps to
|
|
125
|
+
* `unreadable`.
|
|
126
|
+
*/
|
|
127
|
+
export async function tmuxSessionPresence(sessionName: string, socketName?: string, opts: TmuxReadOpts = {}): Promise<TmuxSessionPresence> {
|
|
128
|
+
try {
|
|
129
|
+
const result = await runTmux("tmux-has-session", tmuxCommand(socketName, "has-session", "-t", sessionName), { okExitCodes: [0, 1], ...opts });
|
|
130
|
+
return result.exitCode === 0 ? "present" : "absent";
|
|
131
|
+
} catch {
|
|
132
|
+
return "unreadable";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Off-loop resolve of a tmux session's active-pane pid via `display-message` (read-only,
|
|
138
|
+
* idempotent). Returns the direct pid untouched, or undefined when the session is gone /
|
|
139
|
+
* unreadable / the query times out. The launcher `exec`s claude, so the pane pid IS the claude
|
|
140
|
+
* pid. `okExitCodes: "any"` so a non-zero exit is undefined rather than a throw+strike.
|
|
141
|
+
*/
|
|
142
|
+
export async function resolveClaudePanePidAsync(directPid: number | undefined, tmuxSession?: string, tmuxSocket?: string): Promise<number | undefined> {
|
|
143
|
+
if (typeof directPid === "number" && directPid > 0) return directPid;
|
|
144
|
+
if (!tmuxSession) return undefined;
|
|
145
|
+
try {
|
|
146
|
+
const result = await runTmux("tmux-pane-pid", tmuxCommand(tmuxSocket, "display-message", "-p", "-t", tmuxSession, "-F", "#{pane_pid}"), { okExitCodes: "any" });
|
|
147
|
+
if (result.exitCode !== 0) return undefined;
|
|
148
|
+
const pid = Number.parseInt(result.stdout.trim(), 10);
|
|
149
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Off-loop, side-effecting tmux command (send-keys / paste-buffer / delete-buffer). Throws
|
|
157
|
+
* on a non-zero exit / timeout / abort. At-most-once by backend (see the module header): a
|
|
158
|
+
* failure is never silently re-run, so a delivered keystroke can never be double-typed.
|
|
159
|
+
*/
|
|
160
|
+
export async function runTmuxInput(taskName: string, argv: string[], signal?: AbortSignal): Promise<SubprocessResult> {
|
|
161
|
+
return runTmux(taskName, argv, { signal });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Off-loop `tmux load-buffer -b <name> -`, streaming the payload on STDIN (#1838 row 16).
|
|
166
|
+
*
|
|
167
|
+
* This replaces the old `set-buffer -b <name> <text>` whose payload rode in argv — an
|
|
168
|
+
* ARG_MAX exposure for a large delivered message (up to the platform's arg limit, beyond
|
|
169
|
+
* which the spawn fails outright). load-buffer reading from stdin has no such bound.
|
|
170
|
+
*
|
|
171
|
+
* The offload `SubprocessRunner` hard-codes `stdin: "ignore"`, so a stdin-bearing spawn
|
|
172
|
+
* cannot go through it; this is a directly-managed async `Bun.spawn` bounded by the same
|
|
173
|
+
* deadline (SIGKILL on timeout) so it is off-loop and cannot outlive its bound either. It is
|
|
174
|
+
* side-effecting (it mutates a tmux buffer) and, like the runner-backed sends, never retried.
|
|
175
|
+
*/
|
|
176
|
+
export async function loadTmuxBufferFromStdin(
|
|
177
|
+
bufferName: string,
|
|
178
|
+
text: string,
|
|
179
|
+
socketName?: string,
|
|
180
|
+
timeoutMs = SYNC_SUBPROCESS_TIMEOUT_MS,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const proc = Bun.spawn(tmuxCommand(socketName, "load-buffer", "-b", bufferName, "-"), {
|
|
183
|
+
stdin: new TextEncoder().encode(text),
|
|
184
|
+
stdout: "ignore",
|
|
185
|
+
stderr: "pipe",
|
|
186
|
+
});
|
|
187
|
+
// Drain stderr concurrently so a chatty failure can never wedge the child on a full pipe.
|
|
188
|
+
// `.catch` keeps this floating drain from surfacing as an unhandled rejection on the success
|
|
189
|
+
// path (where it is never awaited).
|
|
190
|
+
const stderrText = new Response(proc.stderr).text().catch(() => "");
|
|
191
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
192
|
+
const timedOut = await Promise.race([
|
|
193
|
+
proc.exited.then(() => false),
|
|
194
|
+
new Promise<true>((resolve) => { killTimer = setTimeout(() => resolve(true), timeoutMs); }),
|
|
195
|
+
]);
|
|
196
|
+
if (killTimer) clearTimeout(killTimer);
|
|
197
|
+
if (timedOut) {
|
|
198
|
+
try { proc.kill("SIGKILL"); } catch { /* already gone */ }
|
|
199
|
+
await proc.exited.catch(() => {});
|
|
200
|
+
throw new Error(`tmux load-buffer timed out after ${timeoutMs}ms`);
|
|
201
|
+
}
|
|
202
|
+
const exitCode = await proc.exited;
|
|
203
|
+
if (exitCode !== 0) {
|
|
204
|
+
const stderr = (await stderrText).trim();
|
|
205
|
+
throw new Error(`tmux load-buffer failed: ${stderr || `exit code ${exitCode}`}`);
|
|
206
|
+
}
|
|
207
|
+
}
|