@cat-factory/local-server 0.15.0 → 0.16.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.
Files changed (39) hide show
  1. package/dist/LocalContainerRunnerTransport.d.ts +97 -9
  2. package/dist/LocalContainerRunnerTransport.d.ts.map +1 -1
  3. package/dist/LocalContainerRunnerTransport.js +355 -93
  4. package/dist/LocalContainerRunnerTransport.js.map +1 -1
  5. package/dist/LocalProcessRunnerTransport.d.ts +63 -0
  6. package/dist/LocalProcessRunnerTransport.d.ts.map +1 -0
  7. package/dist/LocalProcessRunnerTransport.js +188 -0
  8. package/dist/LocalProcessRunnerTransport.js.map +1 -0
  9. package/dist/NativeRoutingRunnerTransport.d.ts +20 -0
  10. package/dist/NativeRoutingRunnerTransport.d.ts.map +1 -0
  11. package/dist/NativeRoutingRunnerTransport.js +50 -0
  12. package/dist/NativeRoutingRunnerTransport.js.map +1 -0
  13. package/dist/container.d.ts +13 -0
  14. package/dist/container.d.ts.map +1 -1
  15. package/dist/container.js +165 -22
  16. package/dist/container.js.map +1 -1
  17. package/dist/harnessHttp.d.ts +58 -0
  18. package/dist/harnessHttp.d.ts.map +1 -0
  19. package/dist/harnessHttp.js +95 -0
  20. package/dist/harnessHttp.js.map +1 -0
  21. package/dist/runtimes/appleContainerRuntime.d.ts +3 -0
  22. package/dist/runtimes/appleContainerRuntime.d.ts.map +1 -1
  23. package/dist/runtimes/appleContainerRuntime.js +8 -1
  24. package/dist/runtimes/appleContainerRuntime.js.map +1 -1
  25. package/dist/runtimes/containerRuntime.d.ts +30 -1
  26. package/dist/runtimes/containerRuntime.d.ts.map +1 -1
  27. package/dist/runtimes/containerRuntime.js +5 -0
  28. package/dist/runtimes/containerRuntime.js.map +1 -1
  29. package/dist/runtimes/dockerRuntime.d.ts +4 -0
  30. package/dist/runtimes/dockerRuntime.d.ts.map +1 -1
  31. package/dist/runtimes/dockerRuntime.js +21 -2
  32. package/dist/runtimes/dockerRuntime.js.map +1 -1
  33. package/dist/runtimes/index.d.ts.map +1 -1
  34. package/dist/runtimes/index.js +1 -0
  35. package/dist/runtimes/index.js.map +1 -1
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +4 -14
  38. package/dist/server.js.map +1 -1
  39. package/package.json +9 -8
@@ -0,0 +1,63 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { RunnerDispatchKind, RunnerJobRef, RunnerJobView, RunnerTransport } from '@cat-factory/kernel';
3
+ export interface LocalProcessRunnerTransportOptions {
4
+ /**
5
+ * Path to the executor-harness HTTP server entry (its `server.js`/`server.ts`). Spawned
6
+ * as `node <entry>`; with a `.ts` entry, Node's type-stripping (Node 24+) runs it.
7
+ */
8
+ harnessEntry: string;
9
+ /** Node executable to spawn the harness with. Default `process.execPath`. */
10
+ nodePath?: string;
11
+ /** Extra args to pass to node before the entry (e.g. `--experimental-strip-types`). */
12
+ nodeArgs?: string[];
13
+ /** Shared secret injected as `HARNESS_SHARED_SECRET` + sent on every call. Default random. */
14
+ sharedSecret?: string;
15
+ /** Extra env for the harness process (e.g. GITHUB_ALLOWED_HOSTS). */
16
+ env?: Record<string, string>;
17
+ /** Injectable fetch — defaults to the global. */
18
+ fetchImpl?: typeof fetch;
19
+ /** Injectable spawn — defaults to node:child_process.spawn (overridable in tests). */
20
+ spawnImpl?: typeof spawn;
21
+ /** Injectable free-port picker — defaults to an ephemeral OS port (overridable in tests). */
22
+ pickPort?: () => Promise<number>;
23
+ /** How long to wait for the harness `/health` after spawn. Default 30s. */
24
+ readyTimeoutMs?: number;
25
+ /** Per-HTTP-call timeout. Default 30s. */
26
+ requestTimeoutMs?: number;
27
+ }
28
+ export declare class LocalProcessRunnerTransport implements RunnerTransport {
29
+ private readonly harnessEntry;
30
+ private readonly nodePath;
31
+ private readonly nodeArgs;
32
+ private readonly sharedSecret;
33
+ private readonly extraEnv;
34
+ private readonly fetchImpl;
35
+ private readonly spawnImpl;
36
+ private readonly pickPort;
37
+ private readonly readyTimeoutMs;
38
+ private readonly requestTimeoutMs;
39
+ /** The single long-lived harness process, started lazily and reused across all runs. */
40
+ private proc;
41
+ private starting;
42
+ constructor(options: LocalProcessRunnerTransportOptions);
43
+ dispatch(ref: RunnerJobRef, spec: Record<string, unknown>, kind?: RunnerDispatchKind): Promise<void>;
44
+ poll(ref: RunnerJobRef): Promise<RunnerJobView>;
45
+ /**
46
+ * No per-run teardown: the harness host process is long-lived and reused across runs
47
+ * (the harness already removes each job's ephemeral workspace itself). Provided so the
48
+ * port contract is satisfied; kept idempotent.
49
+ */
50
+ release(): Promise<void>;
51
+ /** Stop the harness process (for shutdown / tests). Idempotent. */
52
+ shutdown(): Promise<void>;
53
+ private ensureProcess;
54
+ private startProcess;
55
+ private waitForHealth;
56
+ }
57
+ /**
58
+ * Build a {@link LocalProcessRunnerTransport} from the environment. Requires
59
+ * `LOCAL_HARNESS_ENTRY` (the path to the executor-harness server entry to run as a host
60
+ * process). The native CLIs (`claude` / `codex`) must already be installed on the host.
61
+ */
62
+ export declare function createLocalProcessTransportFromEnv(env: NodeJS.ProcessEnv): LocalProcessRunnerTransport;
63
+ //# sourceMappingURL=LocalProcessRunnerTransport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LocalProcessRunnerTransport.d.ts","sourceRoot":"","sources":["../src/LocalProcessRunnerTransport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAG7D,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,eAAe,EAChB,MAAM,qBAAqB,CAAA;AA0B5B,MAAM,WAAW,kCAAkC;IACjD;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAA;IACpB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,qEAAqE;IACrE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5B,iDAAiD;IACjD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,sFAAsF;IACtF,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IAChC,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,0CAA0C;IAC1C,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAeD,qBAAa,2BAA4B,YAAW,eAAe;IACjE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwB;IACjD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuB;IAChD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IAEzC,wFAAwF;IACxF,OAAO,CAAC,IAAI,CAAoE;IAChF,OAAO,CAAC,QAAQ,CAA6E;IAE7F,YAAY,OAAO,EAAE,kCAAkC,EAWtD;IAEK,QAAQ,CACZ,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,GAAE,kBAA4B,GACjC,OAAO,CAAC,IAAI,CAAC,CAYf;IAEK,IAAI,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAapD;IAED;;;;OAIG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAE7B;IAED,mEAAmE;IAC7D,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAK9B;YAIa,aAAa;YAWb,YAAY;IAoC1B,OAAO,CAAC,aAAa;CAYtB;AAED;;;;GAIG;AACH,wBAAgB,kCAAkC,CAChD,GAAG,EAAE,MAAM,CAAC,UAAU,GACrB,2BAA2B,CAmB7B"}
@@ -0,0 +1,188 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { createServer } from 'node:net';
4
+ import { EVICTION_ERROR, pollHarnessJob, postHarnessJob, waitForHarnessHealth, } from './harnessHttp.js';
5
+ // The NATIVE local runner backend (opt-in via `LOCAL_NATIVE_AGENTS`): instead of a Docker
6
+ // container per run, it runs the SAME executor-harness as a long-lived HOST PROCESS on
7
+ // 127.0.0.1 and drives it through the harness's existing HTTP API. So all the harness
8
+ // machinery — git clone/push/PR, structured-output, watchdogs, the JobRegistry, progress —
9
+ // is reused unchanged; the only difference from the container transport is WHERE the harness
10
+ // runs (a host `node` process vs a container) and that the agent uses the developer's OWN
11
+ // installed `claude` / `codex` CLI with its ambient login (the executor sets `ambientAuth`
12
+ // on the job, so no credential is leased). This bypasses Docker entirely.
13
+ //
14
+ // SECURITY: the agent runs as a plain host subprocess with the developer's full shell/file
15
+ // access and their personal subscription — no container sandbox, no spend metering, no
16
+ // model-locking. Acceptable ONLY because local mode is the developer's own machine; it is
17
+ // therefore opt-in (default off) and reachable only from `buildLocalContainer`.
18
+ /** The harness is always on loopback for the native host-process transport. */
19
+ const endpointFor = (port) => ({ host: '127.0.0.1', port });
20
+ /** An ephemeral free localhost port (best-effort; a tiny TOCTOU window is fine for dev). */
21
+ function ephemeralPort() {
22
+ return new Promise((resolve, reject) => {
23
+ const srv = createServer();
24
+ srv.on('error', reject);
25
+ srv.listen(0, '127.0.0.1', () => {
26
+ const addr = srv.address();
27
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
28
+ srv.close(() => (port ? resolve(port) : reject(new Error('could not pick a free port'))));
29
+ });
30
+ });
31
+ }
32
+ export class LocalProcessRunnerTransport {
33
+ harnessEntry;
34
+ nodePath;
35
+ nodeArgs;
36
+ sharedSecret;
37
+ extraEnv;
38
+ fetchImpl;
39
+ spawnImpl;
40
+ pickPort;
41
+ readyTimeoutMs;
42
+ requestTimeoutMs;
43
+ /** The single long-lived harness process, started lazily and reused across all runs. */
44
+ proc;
45
+ starting;
46
+ constructor(options) {
47
+ this.harnessEntry = options.harnessEntry;
48
+ this.nodePath = options.nodePath ?? process.execPath;
49
+ this.nodeArgs = options.nodeArgs ?? [];
50
+ this.sharedSecret = options.sharedSecret ?? randomBytes(24).toString('hex');
51
+ this.extraEnv = options.env ?? {};
52
+ this.fetchImpl = options.fetchImpl ?? fetch;
53
+ this.spawnImpl = options.spawnImpl ?? spawn;
54
+ this.pickPort = options.pickPort ?? ephemeralPort;
55
+ this.readyTimeoutMs = options.readyTimeoutMs ?? 30_000;
56
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
57
+ }
58
+ async dispatch(ref, spec, kind = 'agent') {
59
+ const proc = await this.ensureProcess();
60
+ // The harness keys jobs by the per-step `ref.jobId` in the body; a re-dispatch
61
+ // (durable-driver replay) re-POSTs, which the JobRegistry treats as a re-attach.
62
+ await postHarnessJob({
63
+ fetchImpl: this.fetchImpl,
64
+ endpoint: endpointFor(proc.port),
65
+ secret: this.sharedSecret,
66
+ body: { ...spec, kind },
67
+ timeoutMs: this.requestTimeoutMs,
68
+ label: 'Native harness',
69
+ });
70
+ }
71
+ async poll(ref) {
72
+ const proc = this.proc;
73
+ // The process died (or was never started) → report an eviction so the run can recover.
74
+ if (!proc || proc.exited)
75
+ return { state: 'failed', error: EVICTION_ERROR };
76
+ return pollHarnessJob({
77
+ fetchImpl: this.fetchImpl,
78
+ endpoint: endpointFor(proc.port),
79
+ jobId: ref.jobId,
80
+ secret: this.sharedSecret,
81
+ timeoutMs: this.requestTimeoutMs,
82
+ label: 'Native harness',
83
+ isDead: () => proc.exited,
84
+ });
85
+ }
86
+ /**
87
+ * No per-run teardown: the harness host process is long-lived and reused across runs
88
+ * (the harness already removes each job's ephemeral workspace itself). Provided so the
89
+ * port contract is satisfied; kept idempotent.
90
+ */
91
+ async release() {
92
+ // intentionally a no-op
93
+ }
94
+ /** Stop the harness process (for shutdown / tests). Idempotent. */
95
+ async shutdown() {
96
+ const proc = this.proc;
97
+ this.proc = undefined;
98
+ this.starting = undefined;
99
+ if (proc && !proc.exited)
100
+ proc.child.kill();
101
+ }
102
+ // --- internals ----------------------------------------------------------
103
+ async ensureProcess() {
104
+ if (this.proc && !this.proc.exited)
105
+ return this.proc;
106
+ this.starting ??= this.startProcess();
107
+ try {
108
+ this.proc = await this.starting;
109
+ return this.proc;
110
+ }
111
+ finally {
112
+ this.starting = undefined;
113
+ }
114
+ }
115
+ async startProcess() {
116
+ const port = await this.pickPort();
117
+ const child = this.spawnImpl(this.nodePath, [...this.nodeArgs, this.harnessEntry], {
118
+ env: {
119
+ ...process.env,
120
+ ...this.extraEnv,
121
+ PORT: String(port),
122
+ HARNESS_SHARED_SECRET: this.sharedSecret,
123
+ // The harness only auto-listens when NODE_ENV !== 'test'.
124
+ NODE_ENV: 'production',
125
+ },
126
+ stdio: 'ignore',
127
+ });
128
+ const handle = { child, port, exited: false };
129
+ // The harness child is long-lived and not detached, but Node does NOT auto-kill a
130
+ // child when the parent exits — without this, every dev restart orphans a `node
131
+ // <harness>` process still bound to its port (and possibly mid-run on the developer's
132
+ // live Claude/Codex login). `shutdown()` covers the graceful path; this `exit` hook is
133
+ // the backstop for SIGTERM/SIGINT/uncaught exits that reach `process.exit` directly.
134
+ const killOnParentExit = () => {
135
+ try {
136
+ child.kill();
137
+ }
138
+ catch {
139
+ // best-effort
140
+ }
141
+ };
142
+ process.once('exit', killOnParentExit);
143
+ child.on('exit', () => {
144
+ handle.exited = true;
145
+ process.removeListener('exit', killOnParentExit);
146
+ if (this.proc === handle)
147
+ this.proc = undefined;
148
+ });
149
+ await this.waitForHealth(port, handle);
150
+ return handle;
151
+ }
152
+ waitForHealth(port, handle) {
153
+ return waitForHarnessHealth({
154
+ fetchImpl: this.fetchImpl,
155
+ endpoint: endpointFor(port),
156
+ readyTimeoutMs: this.readyTimeoutMs,
157
+ requestTimeoutMs: this.requestTimeoutMs,
158
+ intervalMs: 200,
159
+ isDead: () => handle.exited,
160
+ deadError: 'the native harness process exited before becoming healthy',
161
+ timeoutError: `Timed out waiting for the native harness on 127.0.0.1:${port} to become healthy`,
162
+ });
163
+ }
164
+ }
165
+ /**
166
+ * Build a {@link LocalProcessRunnerTransport} from the environment. Requires
167
+ * `LOCAL_HARNESS_ENTRY` (the path to the executor-harness server entry to run as a host
168
+ * process). The native CLIs (`claude` / `codex`) must already be installed on the host.
169
+ */
170
+ export function createLocalProcessTransportFromEnv(env) {
171
+ const harnessEntry = env.LOCAL_HARNESS_ENTRY?.trim();
172
+ if (!harnessEntry) {
173
+ throw new Error('LOCAL_HARNESS_ENTRY is required for native local mode (LOCAL_NATIVE_AGENTS): set it to ' +
174
+ 'the executor-harness server entry path (its built server.js, or src/server.ts run via ' +
175
+ 'Node type-stripping).');
176
+ }
177
+ const nodeArgs = env.LOCAL_HARNESS_NODE_ARGS?.trim()
178
+ ? env.LOCAL_HARNESS_NODE_ARGS.trim().split(/\s+/)
179
+ : undefined;
180
+ const allowedHosts = env.GITHUB_ALLOWED_HOSTS?.trim();
181
+ return new LocalProcessRunnerTransport({
182
+ harnessEntry,
183
+ ...(nodeArgs ? { nodeArgs } : {}),
184
+ sharedSecret: env.HARNESS_SHARED_SECRET?.trim() || undefined,
185
+ ...(allowedHosts ? { env: { GITHUB_ALLOWED_HOSTS: allowedHosts } } : {}),
186
+ });
187
+ }
188
+ //# sourceMappingURL=LocalProcessRunnerTransport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LocalProcessRunnerTransport.js","sourceRoot":"","sources":["../src/LocalProcessRunnerTransport.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ import type { RunnerDispatchKind, RunnerDispatchOptions, RunnerJobRef, RunnerJobView, RunnerTransport } from '@cat-factory/kernel';
2
+ export declare class NativeRoutingRunnerTransport implements RunnerTransport {
3
+ /** Host-process transport for ambient (native CLI) steps — built lazily, cached. */
4
+ private readonly ambient;
5
+ /** Per-run container transport for everything else — built lazily, cached (the
6
+ * container transport resolves its pool config from the DB, so this may be async). */
7
+ private readonly managed;
8
+ /** ref → the transport that handled its dispatch, so poll/release hit the same backend. */
9
+ private readonly routed;
10
+ constructor(
11
+ /** Host-process transport for ambient (native CLI) steps — built lazily, cached. */
12
+ ambient: () => RunnerTransport | Promise<RunnerTransport>,
13
+ /** Per-run container transport for everything else — built lazily, cached (the
14
+ * container transport resolves its pool config from the DB, so this may be async). */
15
+ managed: () => RunnerTransport | Promise<RunnerTransport>);
16
+ dispatch(ref: RunnerJobRef, spec: Record<string, unknown>, kind?: RunnerDispatchKind, options?: RunnerDispatchOptions): Promise<void>;
17
+ poll(ref: RunnerJobRef): Promise<RunnerJobView>;
18
+ release(ref: RunnerJobRef): Promise<void>;
19
+ }
20
+ //# sourceMappingURL=NativeRoutingRunnerTransport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeRoutingRunnerTransport.d.ts","sourceRoot":"","sources":["../src/NativeRoutingRunnerTransport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,aAAa,EACb,eAAe,EAChB,MAAM,qBAAqB,CAAA;AAqB5B,qBAAa,4BAA6B,YAAW,eAAe;IAKhE,oFAAoF;IACpF,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB;0FACsF;IACtF,OAAO,CAAC,QAAQ,CAAC,OAAO;IAR1B,2FAA2F;IAC3F,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqC;IAE5D;IACE,oFAAoF;IACnE,OAAO,EAAE,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAC1E;0FACsF;IACrE,OAAO,EAAE,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,EACxE;IAEE,QAAQ,CACZ,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,GAAE,kBAA4B,EAClC,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAIf;IAEK,IAAI,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAMpD;IAEK,OAAO,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAI9C;CACF"}
@@ -0,0 +1,50 @@
1
+ // NATIVE-MODE transport router (local facade, `LOCAL_NATIVE_AGENTS`): native mode runs the
2
+ // developer's OWN `claude` / `codex` CLI as a host process (no sandbox), which is only
3
+ // appropriate for the steps that actually use that ambient login. The executor flags such a
4
+ // step with `ambientAuth: true` on its job body; everything else (a proxy/`pi` model, or a
5
+ // non-native vendor reusing the claude-code harness) MUST still run in a sandboxed per-run
6
+ // container, exactly as the README promises ("proxy-only models still need the container
7
+ // path"). Previously native mode sent EVERY dispatch to the host process, silently running
8
+ // proxy-model steps unsandboxed.
9
+ //
10
+ // So this routes per JOB (not per run — a single run legitimately mixes an ambient Claude
11
+ // step with a proxy step): `ambientAuth` jobs → the host-process transport; the rest → the
12
+ // container transport (built lazily, so a native deployment that only runs Claude/Codex
13
+ // never needs LOCAL_HARNESS_IMAGE; a proxy step without an image fails loudly there). The
14
+ // chosen transport is remembered per ref so poll/release reach the same backend.
15
+ const EVICTION_ERROR = 'Job not found (container evicted or crashed)';
16
+ const refKey = (ref) => `${ref.runId}:${ref.jobId}`;
17
+ export class NativeRoutingRunnerTransport {
18
+ ambient;
19
+ managed;
20
+ /** ref → the transport that handled its dispatch, so poll/release hit the same backend. */
21
+ routed = new Map();
22
+ constructor(
23
+ /** Host-process transport for ambient (native CLI) steps — built lazily, cached. */
24
+ ambient,
25
+ /** Per-run container transport for everything else — built lazily, cached (the
26
+ * container transport resolves its pool config from the DB, so this may be async). */
27
+ managed) {
28
+ this.ambient = ambient;
29
+ this.managed = managed;
30
+ }
31
+ async dispatch(ref, spec, kind = 'agent', options) {
32
+ const transport = await (spec.ambientAuth === true ? this.ambient() : this.managed());
33
+ this.routed.set(refKey(ref), transport);
34
+ await transport.dispatch(ref, spec, kind, options);
35
+ }
36
+ async poll(ref) {
37
+ // Unknown ref (a fresh process after a durable replay): report an eviction so the
38
+ // sweeper re-drives — the re-dispatch (idempotent) re-populates the routing map.
39
+ const transport = this.routed.get(refKey(ref));
40
+ if (!transport)
41
+ return { state: 'failed', error: EVICTION_ERROR };
42
+ return transport.poll(ref);
43
+ }
44
+ async release(ref) {
45
+ const transport = this.routed.get(refKey(ref));
46
+ this.routed.delete(refKey(ref));
47
+ await transport?.release?.(ref);
48
+ }
49
+ }
50
+ //# sourceMappingURL=NativeRoutingRunnerTransport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeRoutingRunnerTransport.js","sourceRoot":"","sources":["../src/NativeRoutingRunnerTransport.ts"],"names":[],"mappings":"AAQA,2FAA2F;AAC3F,uFAAuF;AACvF,4FAA4F;AAC5F,2FAA2F;AAC3F,2FAA2F;AAC3F,yFAAyF;AACzF,2FAA2F;AAC3F,iCAAiC;AACjC,EAAE;AACF,0FAA0F;AAC1F,2FAA2F;AAC3F,wFAAwF;AACxF,0FAA0F;AAC1F,iFAAiF;AAEjF,MAAM,cAAc,GAAG,8CAA8C,CAAA;AAErE,MAAM,MAAM,GAAG,CAAC,GAAiB,EAAU,EAAE,CAAC,GAAG,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,KAAK,EAAE,CAAA;AAEzE,MAAM,OAAO,4BAA4B;IAMpB,OAAO;IAGP,OAAO;IAR1B,2FAA2F;IAC1E,MAAM,GAAG,IAAI,GAAG,EAA2B,CAAA;IAE5D;IACE,oFAAoF;IACnE,OAAyD;IAC1E;0FACsF;IACrE,OAAyD;uBAHzD,OAAO;uBAGP,OAAO;IACvB,CAAC;IAEJ,KAAK,CAAC,QAAQ,CACZ,GAAiB,EACjB,IAA6B,EAC7B,IAAI,GAAuB,OAAO,EAClC,OAA+B;QAE/B,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,WAAW,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAA;QACrF,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,SAAS,CAAC,CAAA;QACvC,MAAM,SAAS,CAAC,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;IACpD,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,GAAiB;QAC1B,kFAAkF;QAClF,iFAAiF;QACjF,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC9C,IAAI,CAAC,SAAS;YAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,cAAc,EAAE,CAAA;QACjE,OAAO,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAiB;QAC7B,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC9C,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC/B,MAAM,SAAS,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAAA;IACjC,CAAC;CACF"}
@@ -1,4 +1,17 @@
1
1
  import type { NodeContainerOptions } from '@cat-factory/node-server';
2
2
  import type { ServerContainer } from '@cat-factory/server';
3
+ import type { HarnessKind } from '@cat-factory/kernel';
3
4
  export declare function buildLocalContainer(options: NodeContainerOptions): ServerContainer;
5
+ /**
6
+ * Parse `LOCAL_NATIVE_AGENTS` into the set of subscription harnesses to run natively. The
7
+ * documented form is a comma-separated list of harness ids (`claude-code,codex`); `claude`
8
+ * is accepted as an alias for `claude-code`. Blank/unset OR an explicit off value
9
+ * (`false`/`0`/`off`/`no`/`none`/`disabled`) ⇒ off (`[]`) — so disabling native mode never
10
+ * accidentally enables it. An affirmative value naming no harness (`true`/`1`/`on`/…) ⇒ BOTH
11
+ * native harnesses. Only `claude-code` / `codex` are ever native; any other unrecognised
12
+ * token is ignored. A value with neither a recognised harness nor an affirmative keyword
13
+ * (e.g. a typo) ⇒ off, so an unintelligible setting fails safe rather than enabling an
14
+ * unsandboxed, unmetered mode.
15
+ */
16
+ export declare function parseNativeHarnesses(raw: string | undefined): HarnessKind[];
4
17
  //# sourceMappingURL=container.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"container.d.ts","sourceRoot":"","sources":["../src/container.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAA;AAGpE,OAAO,KAAK,EAAqC,eAAe,EAAE,MAAM,qBAAqB,CAAA;AA8B7F,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,eAAe,CAiLlF"}
1
+ {"version":3,"file":"container.d.ts","sourceRoot":"","sources":["../src/container.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAA;AAIpE,OAAO,KAAK,EAAqC,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAG7F,OAAO,KAAK,EAAE,WAAW,EAAmB,MAAM,qBAAqB,CAAA;AAuCvE,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,eAAe,CA2RlF;AAOD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,WAAW,EAAE,CAc3E"}
package/dist/container.js CHANGED
@@ -1,22 +1,31 @@
1
- import { CryptoIdGenerator, DrizzleGitHubInstallationRepository, DrizzleRunnerPoolConnectionRepository, ProvisioningLogRecorder, SystemClock, buildNodeContainer, buildNodeResolveTransport, createDrizzleRepositories, loadNodeConfig, withProvisioningLog, } from '@cat-factory/node-server';
1
+ import { CryptoIdGenerator, DrizzleGitHubInstallationRepository, DrizzleLocalSettingsRepository, DrizzleRunnerPoolConnectionRepository, ProvisioningLogRecorder, SystemClock, buildNodeContainer, buildNodeResolveTransport, createDrizzleRepositories, loadNodeConfig, withProvisioningLog, } from '@cat-factory/node-server';
2
2
  import { ConflictError } from '@cat-factory/kernel';
3
3
  import { WorkspaceSettingsService } from '@cat-factory/orchestration';
4
+ import { logger } from '@cat-factory/server';
5
+ import { LocalSettingsService } from '@cat-factory/integrations';
6
+ import { NativeRoutingRunnerTransport } from './NativeRoutingRunnerTransport.js';
4
7
  import { applyLocalDefaults } from './config.js';
5
8
  import { createLocalGitHubClient, fetchPatAccount, githubPatCreationUrl } from './github.js';
6
9
  import { AutoProvisioningInstallationRepository } from './installations.js';
7
10
  import { createLocalContainerTransportFromEnv, } from './LocalContainerRunnerTransport.js';
11
+ import { createLocalProcessTransportFromEnv, } from './LocalProcessRunnerTransport.js';
8
12
  import { createRuntimeAdapter } from './runtimes/index.js';
9
13
  // The local-mode composition root. It is intentionally thin: the ENTIRE Drizzle/
10
14
  // Postgres persistence, pg-boss durable execution, gateways and model provisioning
11
15
  // come from `buildNodeContainer` unchanged. Local mode only swaps the differentiators
12
16
  // behind the seams `buildNodeContainer` exposes:
13
17
  // - the runner backend → host Docker by default (a per-run local container,
14
- // LocalContainerRunnerTransport, Docker/Podman/OrbStack/Colima/Apple `container`),
15
- // but PER WORKSPACE it can be delegated to the workspace's registered self-hosted
16
- // runner pool (the `delegateAgentsToRunnerPool` setting) the local-vs-external
17
- // opt-in. The Tester's environment is the symmetric opt-in (`delegateTestEnvToProvider`,
18
- // wired below as the tester fallback default), so a developer runs everything locally
19
- // by default but can flip either concern to an external service from the UI;
18
+ // LocalContainerRunnerTransport, Docker/Podman/OrbStack/Colima/Apple `container`,
19
+ // with the warm pool + per-repo checkout reuse configured from the DB local-mode
20
+ // settings), but PER WORKSPACE it can be delegated to the workspace's registered
21
+ // self-hosted runner pool (the `delegateAgentsToRunnerPool` setting) the
22
+ // local-vs-external opt-in. The Tester's environment is the symmetric opt-in
23
+ // (`delegateTestEnvToProvider`, wired below as the tester fallback default), so a
24
+ // developer runs everything locally by default but can flip either concern to an
25
+ // external service from the UI;
26
+ // - optional NATIVE execution: run agents as a host process driving the developer's own
27
+ // installed `claude` / `codex` CLI (ambient login), bypassing Docker for the steps that
28
+ // use that login (`LOCAL_NATIVE_AGENTS`); everything else still runs in a container;
20
29
  // - the push/clone token → a static GitHub PAT (`GITHUB_PAT`) instead of a GitHub
21
30
  // App installation token.
22
31
  // Repo resolution is unchanged: the executor still resolves a block's repo from the
@@ -34,9 +43,18 @@ export function buildLocalContainer(options) {
34
43
  // ON: the Node loader only enables it for a configured GitHub App, but local mode reaches
35
44
  // GitHub through the PAT-backed client, so the read/link endpoints (connection, available
36
45
  // repos, "add from existing repo") should be served the same way.
46
+ // Native local execution (opt-in): run agents as a host process driving the developer's
47
+ // OWN installed `claude` / `codex` CLI (ambient login), bypassing Docker. The env is the
48
+ // ALLOW-LIST of subscription harnesses to run natively (`claude-code,codex`); parsed into
49
+ // a harness set so the executor flags `ambientAuth` ONLY for a listed harness whose vendor
50
+ // is that CLI's native vendor (Claude/Codex), and the personal-credential gate skips just
51
+ // those vendors. Default off — the container path is unchanged.
52
+ const nativeHarnesses = parseNativeHarnesses(env.LOCAL_NATIVE_AGENTS);
53
+ const nativeAgents = nativeHarnesses.length > 0;
37
54
  const config = {
38
55
  ...base,
39
56
  ...(pat ? { github: { ...base.github, enabled: true } } : {}),
57
+ ...(nativeAgents ? { nativeAmbientAuth: nativeHarnesses } : {}),
40
58
  localMode: {
41
59
  enabled: true,
42
60
  ...(pat ? {} : { githubPatSetupUrl: githubPatCreationUrl() }),
@@ -62,15 +80,92 @@ export function buildLocalContainer(options) {
62
80
  workspaceSettingsRepository: repos.workspaceSettingsRepository,
63
81
  workspaceRepository: repos.workspaceRepository,
64
82
  });
65
- // The Docker transport is constructed LAZILY on first container-job dispatch, so the
66
- // service still boots to serve the board (and inline kinds) when LOCAL_HARNESS_IMAGE
67
- // is unset — only repo-operating kinds then fail, loudly and with a clear message,
83
+ // The local container transport is constructed LAZILY on first dispatch, so the service
84
+ // still boots to serve the board (and inline kinds) when LOCAL_HARNESS_IMAGE is unset —
85
+ // only repo-operating container kinds then fail, loudly and with a clear message,
68
86
  // mirroring how the Node facade treats a missing runner backend.
69
- let transport;
70
- const localResolve = () => {
71
- transport ??= createLocalContainerTransportFromEnv(env);
72
- return Promise.resolve(transport);
87
+ //
88
+ // Native mode does NOT blanket-route every dispatch to the host process: a host process
89
+ // has no sandbox, so only the steps that actually use the developer's ambient CLI login
90
+ // (flagged `ambientAuth` by the executor) run there. Everything else — a proxy/`pi` model,
91
+ // or a non-native vendor reusing the claude-code harness — still runs in a per-run
92
+ // container (built lazily, so a Claude/Codex-only native deployment never needs an image;
93
+ // a proxy step without one fails loudly there). See NativeRoutingRunnerTransport.
94
+ // Local-mode operational settings (warm-pool sizing + per-repo checkout reuse) live in
95
+ // the DB as a per-deployment singleton, edited through the dedicated local-mode settings
96
+ // panel — they REPLACED the old LOCAL_POOL_* / HARNESS_* env vars. Built here so the
97
+ // serving transport resolves its pool config from it and the local-settings controller
98
+ // can read/write it. Requires the Drizzle db (always present for the local service).
99
+ // The serving container transport is built once, lazily, reading its pool + checkout
100
+ // config from the DB settings (not env). The promise is cached so every dispatch — and
101
+ // the native router's container leg — reuses the same instance (and its in-process pool).
102
+ // A settings edit is applied LIVE to this instance (see the service's `onChange` below),
103
+ // so the panel takes effect without a restart.
104
+ let containerTransport;
105
+ const localSettingsService = options.db
106
+ ? new LocalSettingsService({
107
+ localSettingsRepository: new DrizzleLocalSettingsRepository(options.db),
108
+ clock: { now: () => Date.now() },
109
+ // Apply an edit to the already-built serving transport so the warm-pool + checkout
110
+ // config takes effect WITHOUT a restart. No-op until the transport is built (the
111
+ // next build reads the fresh settings anyway); a still-failing build is swallowed
112
+ // (a later dispatch surfaces the real problem with a clearer message).
113
+ onChange: async (settings) => {
114
+ if (!containerTransport)
115
+ return;
116
+ try {
117
+ ;
118
+ (await containerTransport).applySettings(settings);
119
+ }
120
+ catch {
121
+ // transport build is still failing — nothing to reconfigure yet
122
+ }
123
+ },
124
+ })
125
+ : undefined;
126
+ const buildServingTransport = async () => {
127
+ const settings = await localSettingsService?.resolve();
128
+ const transport = createLocalContainerTransportFromEnv(env, settings);
129
+ // Boot housekeeping on the SERVING instance: reap exited per-run containers, drain
130
+ // pool members orphaned by a previous process, and pre-warm to poolMinWarm. Best
131
+ // -effort — if the container runtime is down this throws, but a later dispatch then
132
+ // fails loudly with a clearer message, so swallow it here.
133
+ await transport.reapExited().catch((err) => {
134
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'local mode: could not reap / pre-warm job containers at startup');
135
+ });
136
+ return transport;
137
+ };
138
+ const resolveContainerTransport = () => {
139
+ if (!containerTransport) {
140
+ containerTransport = buildServingTransport();
141
+ // Don't let a transient build failure (e.g. a DB blip resolving the settings) poison
142
+ // every future dispatch: drop the cached promise on rejection so the next call retries.
143
+ containerTransport.catch(() => {
144
+ containerTransport = undefined;
145
+ });
146
+ }
147
+ return containerTransport;
73
148
  };
149
+ // The local-agents resolver: in native mode the per-job router (only ambient-CLI steps go
150
+ // to the host process, the rest to a container), otherwise the warm-pool container
151
+ // transport directly.
152
+ let routed;
153
+ const localAgentsResolve = () => {
154
+ if (nativeAgents) {
155
+ if (!routed) {
156
+ let proc;
157
+ routed = new NativeRoutingRunnerTransport(() => (proc ??= createLocalProcessTransportFromEnv(env)), resolveContainerTransport);
158
+ }
159
+ return Promise.resolve(routed);
160
+ }
161
+ return resolveContainerTransport();
162
+ };
163
+ // Eagerly kick off the serving transport's boot housekeeping (reap + pre-warm) when an
164
+ // image is configured, so a warm pool is ready before the first run rather than warming
165
+ // on first dispatch. Skipped without an image (the board still boots; only container
166
+ // kinds fail, loudly). Fire-and-forget: dispatch reuses the same cached promise.
167
+ if (env.LOCAL_HARNESS_IMAGE?.trim())
168
+ void resolveContainerTransport().catch(() => { });
74
169
  // The runner-pool resolver (the external opt-in target). In local mode `runners` is
75
170
  // enabled (it keys off ENCRYPTION_KEY, which `applyLocalDefaults` always sets), so this
76
171
  // is non-null and a workspace can register a pool via the API; a native adapter injected
@@ -87,11 +182,12 @@ export function buildLocalContainer(options) {
87
182
  idGenerator,
88
183
  clock,
89
184
  });
90
- const wrappedLocal = withProvisioningLog(localResolve, recorder, 'container');
185
+ const wrappedLocal = withProvisioningLog(localAgentsResolve, recorder, 'container');
91
186
  const wrappedPool = poolResolve ? withProvisioningLog(poolResolve, recorder, 'runner-pool') : null;
92
187
  // The local-vs-external agents opt-in: dispatch to the registered runner pool when the
93
- // workspace opts in (and one is wrapped), else to host Docker. The pool branch's own
94
- // throw surfaces a clean "register a pool" message when delegation is on but none exists.
188
+ // workspace opts in (and one is wrapped), else to host Docker (the warm-pool / native
189
+ // local backend). The pool branch's own throw surfaces a clean "register a pool" message
190
+ // when delegation is on but none exists.
95
191
  const resolveTransport = async (workspaceId) => {
96
192
  const delegate = !!workspaceId && (await wsSettings.get(workspaceId)).delegateAgentsToRunnerPool;
97
193
  if (delegate && wrappedPool)
@@ -132,15 +228,21 @@ export function buildLocalContainer(options) {
132
228
  // engine so it refuses a local-infra Tester run on an incapable runtime ("limited
133
229
  // mode") instead of dispatching a job that can't stand its dependencies up. Building
134
230
  // the adapter is pure (no IO), so this is cheap even though the transport stays lazy.
135
- const localTestInfraSupported = createRuntimeAdapter(env).capabilities.localDind;
136
- return buildNodeContainer({
231
+ // Native mode runs agents on the host with no per-run Docker container; the Tester's
232
+ // local docker-compose infra (host compose with per-run project names) is a later phase,
233
+ // so it's reported unsupported for now (the engine steers to "limited mode"). The
234
+ // container path keeps the runtime's real Docker-in-Docker capability.
235
+ const localTestInfraSupported = nativeAgents
236
+ ? false
237
+ : createRuntimeAdapter(env).capabilities.localDind;
238
+ const container = buildNodeContainer({
137
239
  ...options,
138
240
  env,
139
241
  config,
140
242
  repos,
141
- // The per-workspace chooser (host Docker vs the runner pool). Pre-wrapped with the
142
- // correct provisioning-log subsystem per branch, so tell buildNodeContainer not to
143
- // re-wrap with a single subsystem tag.
243
+ // The per-workspace chooser (host Docker / native local vs the runner pool). Pre-wrapped
244
+ // with the correct provisioning-log subsystem per branch, so tell buildNodeContainer not
245
+ // to re-wrap with a single subsystem tag.
144
246
  resolveTransport,
145
247
  skipProvisioningLogWrap: true,
146
248
  // Authenticate git with the developer's PAT when present. Absent → the executor
@@ -174,5 +276,46 @@ export function buildLocalContainer(options) {
174
276
  localTestInfraSupported,
175
277
  },
176
278
  });
279
+ // Surface the local-mode settings service so the dedicated local-settings panel can
280
+ // read/write the warm-pool + checkout config (the controller 503s when this is absent,
281
+ // which is the case on every non-local facade).
282
+ return localSettingsService
283
+ ? { ...container, localSettings: { service: localSettingsService } }
284
+ : container;
285
+ }
286
+ /** Values that explicitly DISABLE native mode (so `LOCAL_NATIVE_AGENTS=false` means off). */
287
+ const NATIVE_OFF_VALUES = new Set(['false', '0', 'off', 'no', 'none', 'disabled']);
288
+ /** Affirmative values that enable BOTH native harnesses without naming one. */
289
+ const NATIVE_ALL_VALUES = new Set(['true', '1', 'on', 'yes', 'all', 'both']);
290
+ /**
291
+ * Parse `LOCAL_NATIVE_AGENTS` into the set of subscription harnesses to run natively. The
292
+ * documented form is a comma-separated list of harness ids (`claude-code,codex`); `claude`
293
+ * is accepted as an alias for `claude-code`. Blank/unset OR an explicit off value
294
+ * (`false`/`0`/`off`/`no`/`none`/`disabled`) ⇒ off (`[]`) — so disabling native mode never
295
+ * accidentally enables it. An affirmative value naming no harness (`true`/`1`/`on`/…) ⇒ BOTH
296
+ * native harnesses. Only `claude-code` / `codex` are ever native; any other unrecognised
297
+ * token is ignored. A value with neither a recognised harness nor an affirmative keyword
298
+ * (e.g. a typo) ⇒ off, so an unintelligible setting fails safe rather than enabling an
299
+ * unsandboxed, unmetered mode.
300
+ */
301
+ export function parseNativeHarnesses(raw) {
302
+ const trimmed = raw?.trim().toLowerCase();
303
+ if (!trimmed || NATIVE_OFF_VALUES.has(trimmed))
304
+ return [];
305
+ const out = new Set();
306
+ let affirmative = false;
307
+ for (const token of trimmed.split(',').map((s) => s.trim())) {
308
+ if (token === 'claude-code' || token === 'claude')
309
+ out.add('claude-code');
310
+ else if (token === 'codex')
311
+ out.add('codex');
312
+ else if (NATIVE_ALL_VALUES.has(token))
313
+ affirmative = true;
314
+ }
315
+ if (out.size > 0)
316
+ return [...out];
317
+ // No harness named: enable both ONLY for an explicit affirmative keyword; anything else
318
+ // unrecognised stays off (fail-safe — see the doc comment).
319
+ return affirmative ? ['claude-code', 'codex'] : [];
177
320
  }
178
321
  //# sourceMappingURL=container.js.map