harnery 0.8.0 → 0.9.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/README.md +1 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +38 -0
- package/dist/commands/grep.d.ts +35 -2
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +427 -139
- package/dist/commands/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- package/dist/commands/workflow.d.ts +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +89 -0
- package/dist/core/agents/cli.js +10 -3
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +26 -1
- package/dist/core/agents/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +13 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +21 -4
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +19 -0
- package/dist/core/hooks/cli.js +29 -3
- package/dist/core/hooks/events/schema.d.ts +4 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +1 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- package/dist/core/workflow/billing.d.ts +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +102 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +340 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +30 -0
- package/dist/core/workflow/spawn-claude.d.ts +22 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +82 -0
- package/dist/core/workflow/spawn-codex.d.ts +19 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +66 -0
- package/dist/core/workflow/spawn-cursor.d.ts +26 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +72 -0
- package/dist/core/workflow/types.d.ts +145 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- package/package.json +1 -1
- package/src/commander.ts +2 -0
- package/src/commands/doctor.ts +45 -0
- package/src/commands/grep.ts +535 -142
- package/src/commands/init.ts +138 -17
- package/src/commands/workflow.ts +132 -0
- package/src/core/agents/cli.ts +11 -4
- package/src/core/agents/rules/claim-conflict.ts +26 -1
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/state/heartbeat-projector.ts +13 -1
- package/src/core/agents/state/heartbeat-writer.ts +30 -5
- package/src/core/config.ts +23 -0
- package/src/core/hooks/cli.ts +30 -3
- package/src/core/hooks/events/schema.ts +4 -0
- package/src/core/hooks/harness/events.ts +8 -0
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- package/src/core/workflow/billing.ts +146 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +394 -0
- package/src/core/workflow/harnesses.ts +38 -0
- package/src/core/workflow/spawn-claude.ts +99 -0
- package/src/core/workflow/spawn-codex.ts +74 -0
- package/src/core/workflow/spawn-cursor.ts +89 -0
- package/src/core/workflow/types.ts +153 -0
- package/src/core/workflow/validate.ts +75 -0
package/src/core/hooks/cli.ts
CHANGED
|
@@ -94,6 +94,17 @@ async function readStdin(): Promise<string> {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Env for child agent-coord spawns, with the coord root pinned. The hook
|
|
99
|
+
* process may be running with the session shell's cwd (inside a submodule or
|
|
100
|
+
* scratch dir), and agent-coord resolves its root from cwd unless overridden —
|
|
101
|
+
* without the pin a child could write state to a different .harnery than the
|
|
102
|
+
* one this hook resolved.
|
|
103
|
+
*/
|
|
104
|
+
function childEnv(coordRoot: string): NodeJS.ProcessEnv {
|
|
105
|
+
return { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: coordRoot };
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
function appendDebug(coordRoot: string, entry: Record<string, unknown>): void {
|
|
98
109
|
const path = join(coordRoot, ".harnery", "debug", "agent-hook.ndjson");
|
|
99
110
|
try {
|
|
@@ -143,6 +154,7 @@ function assignNameViaAgentCoord(
|
|
|
143
154
|
const result = spawnSync(binary, ["assign-name", instanceId, kind], {
|
|
144
155
|
encoding: "utf8",
|
|
145
156
|
timeout: 2000,
|
|
157
|
+
env: childEnv(coordRoot),
|
|
146
158
|
});
|
|
147
159
|
if (result.status !== 0 || !result.stdout) return null;
|
|
148
160
|
const parsed = JSON.parse(result.stdout.trim()) as {
|
|
@@ -254,6 +266,12 @@ function buildEventData(
|
|
|
254
266
|
name: assigned?.name,
|
|
255
267
|
kind: "session",
|
|
256
268
|
agent_id: ctx.instanceId,
|
|
269
|
+
// Workflow-child linkage: `workflow run` children carry the run id in
|
|
270
|
+
// env (spawn adapters set it via buildChildEnv), so their sessions and
|
|
271
|
+
// heartbeats join back to the run journal + /workflows web view.
|
|
272
|
+
...(coordEnv("WORKFLOW_CHILD") === "1" && coordEnv("WORKFLOW_RUN_ID")
|
|
273
|
+
? { workflow_run_id: coordEnv("WORKFLOW_RUN_ID") }
|
|
274
|
+
: {}),
|
|
257
275
|
};
|
|
258
276
|
}
|
|
259
277
|
|
|
@@ -519,7 +537,11 @@ async function main(): Promise<number> {
|
|
|
519
537
|
try {
|
|
520
538
|
const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
|
|
521
539
|
if (existsSync(agentCoordBin)) {
|
|
522
|
-
spawnSync(agentCoordBin, ["project"], {
|
|
540
|
+
spawnSync(agentCoordBin, ["project"], {
|
|
541
|
+
encoding: "utf8",
|
|
542
|
+
timeout: 3000,
|
|
543
|
+
env: childEnv(coordRoot),
|
|
544
|
+
});
|
|
523
545
|
spawnSync(
|
|
524
546
|
agentCoordBin,
|
|
525
547
|
[
|
|
@@ -528,7 +550,7 @@ async function main(): Promise<number> {
|
|
|
528
550
|
"--instance",
|
|
529
551
|
owner.instance_id,
|
|
530
552
|
],
|
|
531
|
-
{ encoding: "utf8", timeout: 2000 },
|
|
553
|
+
{ encoding: "utf8", timeout: 2000, env: childEnv(coordRoot) },
|
|
532
554
|
);
|
|
533
555
|
}
|
|
534
556
|
emitSubagentStartContext(coordRoot, owner.instance_id, sessionId, data, harness);
|
|
@@ -551,7 +573,7 @@ async function main(): Promise<number> {
|
|
|
551
573
|
"--instance",
|
|
552
574
|
owner.instance_id,
|
|
553
575
|
],
|
|
554
|
-
{ encoding: "utf8", timeout: 2000 },
|
|
576
|
+
{ encoding: "utf8", timeout: 2000, env: childEnv(coordRoot) },
|
|
555
577
|
);
|
|
556
578
|
}
|
|
557
579
|
} catch (err) {
|
|
@@ -632,6 +654,7 @@ async function main(): Promise<number> {
|
|
|
632
654
|
session_id: sessionId,
|
|
633
655
|
harness,
|
|
634
656
|
bypass: coordEnv("AGENT_COORD_BYPASS_STOP") === "1",
|
|
657
|
+
workflow_child: coordEnv("WORKFLOW_CHILD") === "1",
|
|
635
658
|
});
|
|
636
659
|
if (!verdict.allow) {
|
|
637
660
|
// Harness-aware enforcement channel: Claude Code / Codex honor exit-2 +
|
|
@@ -796,6 +819,7 @@ async function runPreToolUseGuard(
|
|
|
796
819
|
input: verdictReq,
|
|
797
820
|
encoding: "utf8",
|
|
798
821
|
timeout: 3000,
|
|
822
|
+
env: childEnv(coordRoot),
|
|
799
823
|
});
|
|
800
824
|
if (result.status !== 0 || !result.stdout) continue;
|
|
801
825
|
let parsed: { allow?: boolean; reason?: string } = {};
|
|
@@ -893,6 +917,7 @@ function healHeartbeatViaCli(
|
|
|
893
917
|
spawnSync(agentCoordBin, ["heal-heartbeat", instanceId, sessionId, `--harness=${harness}`], {
|
|
894
918
|
encoding: "utf8",
|
|
895
919
|
timeout: 2000,
|
|
920
|
+
env: childEnv(coordRoot),
|
|
896
921
|
});
|
|
897
922
|
}
|
|
898
923
|
|
|
@@ -1000,6 +1025,7 @@ function stampToolActivity(
|
|
|
1000
1025
|
spawnSync(agentCoordBin, ["stamp-tool-activity", instanceId, toolName, target], {
|
|
1001
1026
|
encoding: "utf8",
|
|
1002
1027
|
timeout: 2000,
|
|
1028
|
+
env: childEnv(coordRoot),
|
|
1003
1029
|
});
|
|
1004
1030
|
}
|
|
1005
1031
|
|
|
@@ -1051,6 +1077,7 @@ function releaseClaimOnFailure(
|
|
|
1051
1077
|
spawnSync(agentCoordBin, ["release-claim", instanceId, canonical], {
|
|
1052
1078
|
encoding: "utf8",
|
|
1053
1079
|
timeout: 2000,
|
|
1080
|
+
env: childEnv(coordRoot),
|
|
1054
1081
|
});
|
|
1055
1082
|
}
|
|
1056
1083
|
|
|
@@ -47,6 +47,10 @@ export type SessionStart = EventEnvelope<
|
|
|
47
47
|
cwd: string;
|
|
48
48
|
model?: string;
|
|
49
49
|
pid: number;
|
|
50
|
+
/** Present iff this session is a `workflow run` child: the run id whose
|
|
51
|
+
* journal owns it (child env HARNERY_WORKFLOW_RUN_ID). Optional-field
|
|
52
|
+
* addition per the schema evolution rules (minor bump). */
|
|
53
|
+
workflow_run_id?: string;
|
|
50
54
|
}
|
|
51
55
|
>;
|
|
52
56
|
|
|
@@ -40,6 +40,13 @@ export interface HarnessSpec {
|
|
|
40
40
|
allowedTopLevelKeys?: string[];
|
|
41
41
|
/** Strict hook event keys accepted by this harness, including events harnery does not wire. */
|
|
42
42
|
allowedEventKeys?: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Env var the harness exports to hook processes carrying the project root
|
|
45
|
+
* (e.g. Claude Code's CLAUDE_PROJECT_DIR). When set, `init` anchors the
|
|
46
|
+
* agent-hook path on it so the hook still spawns when the process cwd has
|
|
47
|
+
* wandered (the session shell `cd`ing into a subdirectory or off-repo).
|
|
48
|
+
*/
|
|
49
|
+
projectDirEnv?: string;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/** Claude Code: `.claude/settings.json`. */
|
|
@@ -111,6 +118,7 @@ export const HARNESS_SPECS: Record<HarnessId, HarnessSpec> = {
|
|
|
111
118
|
settingsFile: ".claude/settings.json",
|
|
112
119
|
events: CLAUDE_CODE_EVENTS,
|
|
113
120
|
entryShape: "claude",
|
|
121
|
+
projectDirEnv: "CLAUDE_PROJECT_DIR",
|
|
114
122
|
},
|
|
115
123
|
cursor: {
|
|
116
124
|
settingsFile: ".cursor/hooks.json",
|
|
@@ -5,16 +5,38 @@ import { coordEnv } from "../../../lib/env.ts";
|
|
|
5
5
|
/**
|
|
6
6
|
* Walk up from `start` looking for a directory containing `.harnery/`. The
|
|
7
7
|
* single coord-root resolution so every adapter agrees on the same root.
|
|
8
|
+
*
|
|
9
|
+
* Resolution precedence:
|
|
10
|
+
* 1. HARNERY_COORD_ROOT_OVERRIDE — explicit pin (tests, git hooks).
|
|
11
|
+
* 2. The harness's project dir (CLAUDE_PROJECT_DIR) — hook processes
|
|
12
|
+
* inherit the session's *shell* cwd, which follows `cd` into
|
|
13
|
+
* subdirectories/submodules that may carry a `.harnery/` of their own
|
|
14
|
+
* (or none at all, e.g. a scratch dir under /tmp). The session's
|
|
15
|
+
* coordination home is the project the harness opened, not wherever the
|
|
16
|
+
* shell happens to sit, so a project dir that resolves to a coord root
|
|
17
|
+
* wins over the cwd walk.
|
|
18
|
+
* 3. Walk up from `start` (default cwd).
|
|
8
19
|
*/
|
|
9
20
|
export function findCoordRoot(start: string = process.cwd()): string | null {
|
|
10
|
-
// HARNERY_COORD_ROOT_OVERRIDE:
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
21
|
+
// HARNERY_COORD_ROOT_OVERRIDE: explicit pin matched by agent-coord and the
|
|
22
|
+
// bash side. Lets the sandboxed coord-test suite run against a temp
|
|
23
|
+
// directory rather than the real monorepo root. agent-hook's session.start
|
|
24
|
+
// handler may be the FIRST thing to create .harnery/ in a fresh sandbox,
|
|
25
|
+
// so the override is honored unconditionally (we don't require .harnery/
|
|
26
|
+
// to pre-exist).
|
|
16
27
|
const override = coordEnv("COORD_ROOT_OVERRIDE");
|
|
17
28
|
if (override) return override;
|
|
29
|
+
// Claude Code exports CLAUDE_PROJECT_DIR to every hook process; other
|
|
30
|
+
// harnesses don't set it, so this is inert outside claude-code hooks.
|
|
31
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR;
|
|
32
|
+
if (projectDir) {
|
|
33
|
+
const fromProject = walkUp(projectDir);
|
|
34
|
+
if (fromProject) return fromProject;
|
|
35
|
+
}
|
|
36
|
+
return walkUp(start);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walkUp(start: string): string | null {
|
|
18
40
|
let dir = resolve(start);
|
|
19
41
|
while (true) {
|
|
20
42
|
if (existsSync(join(dir, ".harnery"))) return dir;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing-mode probe: which auth a headless harness child will actually use.
|
|
3
|
+
*
|
|
4
|
+
* The engine never handles credentials — children are plain harness CLIs and
|
|
5
|
+
* authenticate however that CLI does. But the CLIs prefer an exported API key
|
|
6
|
+
* over a stored (subscription) login when both are present, and that override
|
|
7
|
+
* is almost always an accident: a sourced .env or a leftover export silently
|
|
8
|
+
* moves the run from subsidized subscription billing to per-token API billing.
|
|
9
|
+
* This probe classifies the state per harness so the engine can refuse the
|
|
10
|
+
* silent-override case (see decision 0015 addendum) while leaving deliberate
|
|
11
|
+
* key-only hosts (CI boxes with no login) working.
|
|
12
|
+
*
|
|
13
|
+
* Detection is a heuristic over well-known credential locations; when a
|
|
14
|
+
* location can't prove presence or absence (e.g. Claude Code stores its OAuth
|
|
15
|
+
* token in the macOS keychain, not a file), the state is "unknown" and the
|
|
16
|
+
* engine never hard-fails on it — the harness CLI itself is the final
|
|
17
|
+
* authority and will error loudly if truly unauthenticated.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import type { HarnessName } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
/** The env var each harness CLI reads as an API key (preferred over a stored
|
|
26
|
+
* login when set). Shared with the child-env builder's subscription-only
|
|
27
|
+
* scrub. */
|
|
28
|
+
export const API_KEY_VARS: Record<HarnessName, string> = {
|
|
29
|
+
"claude-code": "ANTHROPIC_API_KEY",
|
|
30
|
+
codex: "OPENAI_API_KEY",
|
|
31
|
+
cursor: "CURSOR_API_KEY",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type LoginState = "present" | "absent" | "unknown";
|
|
35
|
+
|
|
36
|
+
export type BillingMode =
|
|
37
|
+
/** No API key exported; the child rides the stored (subscription) login. */
|
|
38
|
+
| "subscription"
|
|
39
|
+
/** API key present, no stored login detected: a deliberate key-only host. */
|
|
40
|
+
| "api-key"
|
|
41
|
+
/** API key present AND a stored login exists: the key silently overrides
|
|
42
|
+
* subsidized auth. The engine refuses this unless explicitly allowed. */
|
|
43
|
+
| "api-key-override";
|
|
44
|
+
|
|
45
|
+
export interface BillingProbe {
|
|
46
|
+
harness: HarnessName;
|
|
47
|
+
/** The env var checked (from API_KEY_VARS), or a note when the key was
|
|
48
|
+
* found in a credential file instead of the environment. */
|
|
49
|
+
apiKeySource: string | null;
|
|
50
|
+
apiKeyPresent: boolean;
|
|
51
|
+
login: LoginState;
|
|
52
|
+
mode: BillingMode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProbeIo {
|
|
56
|
+
env?: NodeJS.ProcessEnv;
|
|
57
|
+
home?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type BillingProber = (harness: HarnessName) => BillingProbe;
|
|
61
|
+
|
|
62
|
+
export function probeBilling(harness: HarnessName, io: ProbeIo = {}): BillingProbe {
|
|
63
|
+
const env = io.env ?? process.env;
|
|
64
|
+
const home = io.home ?? homedir();
|
|
65
|
+
const keyVar = API_KEY_VARS[harness];
|
|
66
|
+
const envKey = Boolean(env[keyVar]?.trim());
|
|
67
|
+
|
|
68
|
+
let login: LoginState;
|
|
69
|
+
let apiKeyPresent = envKey;
|
|
70
|
+
let apiKeySource: string | null = envKey ? keyVar : null;
|
|
71
|
+
|
|
72
|
+
switch (harness) {
|
|
73
|
+
case "claude-code":
|
|
74
|
+
login = probeClaudeLogin(home);
|
|
75
|
+
break;
|
|
76
|
+
case "codex": {
|
|
77
|
+
const codex = probeCodexAuth(env, home);
|
|
78
|
+
login = codex.login;
|
|
79
|
+
// `codex login --api-key` stores the key in auth.json rather than the
|
|
80
|
+
// env; that is still API-key billing and should surface as such.
|
|
81
|
+
if (!envKey && codex.storedApiKey) {
|
|
82
|
+
apiKeyPresent = true;
|
|
83
|
+
apiKeySource = codex.storedApiKeyNote;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "cursor":
|
|
88
|
+
// cursor-agent's stored-login location is not yet verified against a
|
|
89
|
+
// live install (adapter itself is pending verification); never claim
|
|
90
|
+
// presence or absence we can't prove.
|
|
91
|
+
login = "unknown";
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const mode: BillingMode =
|
|
96
|
+
apiKeyPresent && login === "present"
|
|
97
|
+
? "api-key-override"
|
|
98
|
+
: apiKeyPresent
|
|
99
|
+
? "api-key"
|
|
100
|
+
: "subscription";
|
|
101
|
+
|
|
102
|
+
return { harness, apiKeySource, apiKeyPresent, login, mode };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Claude Code: OAuth login lives at ~/.claude/.credentials.json on Linux;
|
|
106
|
+
* macOS stores it in the keychain (dir exists, file doesn't → unknown). */
|
|
107
|
+
function probeClaudeLogin(home: string): LoginState {
|
|
108
|
+
const dir = join(home, ".claude");
|
|
109
|
+
const credFile = join(dir, ".credentials.json");
|
|
110
|
+
if (existsSync(credFile)) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(readFileSync(credFile, "utf8")) as {
|
|
113
|
+
claudeAiOauth?: { accessToken?: string };
|
|
114
|
+
};
|
|
115
|
+
return parsed.claudeAiOauth?.accessToken ? "present" : "absent";
|
|
116
|
+
} catch {
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return existsSync(dir) ? "unknown" : "absent";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** codex: auth lives at $CODEX_HOME/auth.json (default ~/.codex). A `tokens`
|
|
124
|
+
* object means a ChatGPT (subscription) login; an OPENAI_API_KEY field means
|
|
125
|
+
* a stored API key (`codex login --api-key`). */
|
|
126
|
+
function probeCodexAuth(
|
|
127
|
+
env: NodeJS.ProcessEnv,
|
|
128
|
+
home: string,
|
|
129
|
+
): { login: LoginState; storedApiKey: boolean; storedApiKeyNote: string } {
|
|
130
|
+
const authPath = join(env.CODEX_HOME?.trim() || join(home, ".codex"), "auth.json");
|
|
131
|
+
const storedApiKeyNote = `${authPath} (stored key)`;
|
|
132
|
+
if (!existsSync(authPath)) return { login: "absent", storedApiKey: false, storedApiKeyNote };
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as {
|
|
135
|
+
tokens?: unknown;
|
|
136
|
+
OPENAI_API_KEY?: string | null;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
login: parsed.tokens ? "present" : "absent",
|
|
140
|
+
storedApiKey: !parsed.tokens && Boolean(parsed.OPENAI_API_KEY),
|
|
141
|
+
storedApiKeyNote,
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
return { login: "unknown", storedApiKey: false, storedApiKeyNote };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process environment builder shared by every spawn adapter.
|
|
3
|
+
*
|
|
4
|
+
* Rules (the first is a hard-won Phase 1 spike finding):
|
|
5
|
+
* 1. **Delete inherited harness-session vars, never blank them.** A nested
|
|
6
|
+
* harness CLI under a live session inherits vars that make it exit 1 with
|
|
7
|
+
* empty output; an empty-string var still reads as set. Scrub all three
|
|
8
|
+
* families (CLAUDE*, CODEX*, CURSOR*) regardless of which adapter spawns —
|
|
9
|
+
* a codex child launched from inside a Claude Code session must not
|
|
10
|
+
* inherit CLAUDE* either.
|
|
11
|
+
* 2. **The scrub targets SESSION vars, not credentials.** CURSOR_API_KEY
|
|
12
|
+
* matches the CURSOR* prefix but is an auth credential, so it is carved
|
|
13
|
+
* out and re-added — a key-only cursor host must keep working. (The other
|
|
14
|
+
* two key vars, ANTHROPIC_API_KEY and OPENAI_API_KEY, don't collide with
|
|
15
|
+
* the scrub prefixes.)
|
|
16
|
+
* 3. **subscriptionOnly deletes every API-key var** (see billing.ts) so the
|
|
17
|
+
* child can only authenticate via its stored subscription login — the
|
|
18
|
+
* guarantee behind `workflow run --subscription-only`.
|
|
19
|
+
* 4. **Mark the child as a workflow child** (HARNERY_WORKFLOW_CHILD=1): the
|
|
20
|
+
* stop-hook rule exempts it from the human-facing end-of-turn ritual while
|
|
21
|
+
* hooks stay on, keeping heartbeat + event capture.
|
|
22
|
+
* 5. **Stamp the run id** (HARNERY_WORKFLOW_RUN_ID) so the coord layer and
|
|
23
|
+
* web UI can associate child sessions with their workflow run.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { API_KEY_VARS } from "./billing.ts";
|
|
27
|
+
|
|
28
|
+
const SCRUB_PREFIXES = ["CLAUDE", "CODEX", "CURSOR"];
|
|
29
|
+
|
|
30
|
+
export interface ChildEnvOpts {
|
|
31
|
+
/** Delete all API-key vars so children can only use stored logins. */
|
|
32
|
+
subscriptionOnly?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildChildEnv(runId?: string, opts: ChildEnvOpts = {}): Record<string, string> {
|
|
36
|
+
const env: Record<string, string> = {};
|
|
37
|
+
const keyVars = new Set<string>(Object.values(API_KEY_VARS));
|
|
38
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
39
|
+
if (v === undefined) continue;
|
|
40
|
+
if (opts.subscriptionOnly && keyVars.has(k)) continue;
|
|
41
|
+
if (SCRUB_PREFIXES.some((p) => k.startsWith(p)) && !keyVars.has(k)) continue;
|
|
42
|
+
env[k] = v;
|
|
43
|
+
}
|
|
44
|
+
env.HARNERY_WORKFLOW_CHILD = "1";
|
|
45
|
+
if (runId) env.HARNERY_WORKFLOW_RUN_ID = runId;
|
|
46
|
+
return env;
|
|
47
|
+
}
|