@cat-factory/local-server 0.6.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/LocalDockerRunnerTransport.d.ts +95 -0
  3. package/dist/LocalDockerRunnerTransport.d.ts.map +1 -0
  4. package/dist/LocalDockerRunnerTransport.js +342 -0
  5. package/dist/LocalDockerRunnerTransport.js.map +1 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +42 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/container.d.ts +4 -0
  11. package/dist/container.d.ts.map +1 -0
  12. package/dist/container.js +84 -0
  13. package/dist/container.js.map +1 -0
  14. package/dist/github.d.ts +36 -0
  15. package/dist/github.d.ts.map +1 -0
  16. package/dist/github.js +174 -0
  17. package/dist/github.js.map +1 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +21 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/installations.d.ts +31 -0
  23. package/dist/installations.d.ts.map +1 -0
  24. package/dist/installations.js +76 -0
  25. package/dist/installations.js.map +1 -0
  26. package/dist/link-repo.d.ts +2 -0
  27. package/dist/link-repo.d.ts.map +1 -0
  28. package/dist/link-repo.js +21 -0
  29. package/dist/link-repo.js.map +1 -0
  30. package/dist/linkRepo.d.ts +31 -0
  31. package/dist/linkRepo.d.ts.map +1 -0
  32. package/dist/linkRepo.js +113 -0
  33. package/dist/linkRepo.js.map +1 -0
  34. package/dist/main.d.ts +2 -0
  35. package/dist/main.d.ts.map +1 -0
  36. package/dist/main.js +10 -0
  37. package/dist/main.js.map +1 -0
  38. package/dist/server.d.ts +5 -0
  39. package/dist/server.d.ts.map +1 -0
  40. package/dist/server.js +50 -0
  41. package/dist/server.js.map +1 -0
  42. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Savin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ import type { RunnerDispatchKind, RunnerDispatchOptions, RunnerJobRef, RunnerJobView, RunnerTransport } from '@cat-factory/kernel';
2
+ /** Injectable docker/podman CLI runner — overridable in tests. */
3
+ export type DockerExec = (args: string[]) => Promise<{
4
+ stdout: string;
5
+ stderr: string;
6
+ }>;
7
+ export interface LocalDockerRunnerTransportOptions {
8
+ /** The executor-harness image ref (a GHCR pull or a locally built tag). */
9
+ image: string;
10
+ /** The container CLI binary. Default `docker` (Podman works via the same surface). */
11
+ binary?: string;
12
+ /**
13
+ * Shared secret injected as `HARNESS_SHARED_SECRET` and sent as the
14
+ * `x-harness-secret` header on every call. Defaults to a random per-process value.
15
+ */
16
+ sharedSecret?: string;
17
+ /**
18
+ * Add `--add-host=host.docker.internal:host-gateway` so the harness can reach the
19
+ * backend LLM proxy at `host.docker.internal` (needed on Linux; harmless on
20
+ * Docker Desktop). Default true.
21
+ */
22
+ addHostGateway?: boolean;
23
+ /** Optional `--network` for the container. */
24
+ network?: string;
25
+ /** Extra `-e KEY=VALUE` env passed into the container (rarely needed). */
26
+ env?: Record<string, string>;
27
+ /** Injectable docker exec — defaults to running {@link binary} via execFile. */
28
+ exec?: DockerExec;
29
+ /** Injectable fetch — defaults to the global. */
30
+ fetchImpl?: typeof fetch;
31
+ /** How long to wait for the container's port + `/health` after start. Default 60s. */
32
+ readyTimeoutMs?: number;
33
+ /** Per-HTTP-call timeout. Default 30s. */
34
+ requestTimeoutMs?: number;
35
+ /**
36
+ * Run the Tester (`test`) job container with `--privileged` so its in-container
37
+ * Docker-in-Docker daemon can start and the Tester can `docker compose up` the
38
+ * service's local infra. This is the local analogue of the Cloudflare harness's
39
+ * rootless-dockerd path, but reliable: on a developer machine privileged DinD
40
+ * "just works", keeping the service's dependencies on the job container's own
41
+ * `localhost` (exactly what the Tester prompt assumes). Default true; set false to
42
+ * fall back to the harness's best-effort rootless daemon (e.g. under Podman, whose
43
+ * rootless containers can run nested Podman without `--privileged`). Only the
44
+ * `test` kind gets it — every other kind runs unprivileged. See entrypoint.sh.
45
+ */
46
+ privilegedTestJobs?: boolean;
47
+ }
48
+ export declare class LocalDockerRunnerTransport implements RunnerTransport {
49
+ private readonly image;
50
+ private readonly binary;
51
+ private readonly sharedSecret;
52
+ private readonly addHostGateway;
53
+ private readonly network?;
54
+ private readonly extraEnv;
55
+ private readonly exec;
56
+ private readonly fetchImpl;
57
+ private readonly readyTimeoutMs;
58
+ private readonly requestTimeoutMs;
59
+ private readonly privilegedTestJobs;
60
+ /** runId → resolved container handle, to spare a `docker` lookup on the hot poll path. */
61
+ private readonly cache;
62
+ constructor(options: LocalDockerRunnerTransportOptions);
63
+ dispatch(ref: RunnerJobRef, spec: Record<string, unknown>, kind?: RunnerDispatchKind, options?: RunnerDispatchOptions): Promise<void>;
64
+ poll(ref: RunnerJobRef): Promise<RunnerJobView>;
65
+ /**
66
+ * Reclaim the per-RUN container now (`docker rm -f`) rather than leaving it idle —
67
+ * this tears down the whole run's container (and with it any step still running in
68
+ * it). Best-effort and idempotent: removing an already-gone container is a no-op.
69
+ */
70
+ release(ref: RunnerJobRef): Promise<void>;
71
+ /**
72
+ * Reap exited per-job containers this transport manages — orphans a crash or hard
73
+ * kill left behind (release() never ran for them). Best-effort; returns the count
74
+ * removed. Call once at boot, before any job is in flight.
75
+ */
76
+ reapExited(): Promise<number>;
77
+ /** Force-remove every (running or exited) container labelled with this run id. */
78
+ private removeContainersForRun;
79
+ private url;
80
+ /** The container handle for a run from the cache, else rediscovered by label. */
81
+ private resolve;
82
+ /** The (running-or-exited) container id labelled with this run id, if any. */
83
+ private findContainer;
84
+ /** Parse the published host port for the harness port, or undefined if unmapped. */
85
+ private hostPort;
86
+ private waitForPort;
87
+ private waitForHealth;
88
+ private isRunning;
89
+ }
90
+ /**
91
+ * Build a {@link LocalDockerRunnerTransport} from the process environment. The image
92
+ * ref (`LOCAL_HARNESS_IMAGE`) is required; everything else has sane local defaults.
93
+ */
94
+ export declare function createLocalDockerTransportFromEnv(env: NodeJS.ProcessEnv): LocalDockerRunnerTransport;
95
+ //# sourceMappingURL=LocalDockerRunnerTransport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LocalDockerRunnerTransport.d.ts","sourceRoot":"","sources":["../src/LocalDockerRunnerTransport.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,aAAa,EACb,eAAe,EAChB,MAAM,qBAAqB,CAAA;AAkD5B,kEAAkE;AAClE,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAExF,MAAM,WAAW,iCAAiC;IAChD,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAA;IACb,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5B,gFAAgF;IAChF,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,iDAAiD;IACjD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,0CAA0C;IAC1C,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAC7B;AAID,qBAAa,0BAA2B,YAAW,eAAe;IAChE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwB;IACjD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAY;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAE5C,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2D;IAEjF,YAAY,OAAO,EAAE,iCAAiC,EAarD;IAEK,QAAQ,CACZ,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,GAAE,kBAA0B,EAChC,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA+Df;IAEK,IAAI,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAmCpD;IAED;;;;OAIG;IACG,OAAO,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9C;IAED;;;;OAIG;IACG,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAgBlC;IAID,kFAAkF;YACpE,sBAAsB;IAiBpC,OAAO,CAAC,GAAG;IAIX,iFAAiF;YACnE,OAAO;IAYrB,8EAA8E;YAChE,aAAa;IAY3B,oFAAoF;YACtE,QAAQ;YAUR,WAAW;YAYX,aAAa;YAmBb,SAAS;CASxB;AAUD;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,GAAG,EAAE,MAAM,CAAC,UAAU,GACrB,0BAA0B,CAmB5B"}
@@ -0,0 +1,342 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+ import { resolveDockerResources } from '@cat-factory/contracts';
5
+ const execFileAsync = promisify(execFile);
6
+ // The local-mode runner backend: each RUN gets its OWN local Docker/Podman container
7
+ // — the SAME executor-harness image the Cloudflare Worker runs per-run Containers
8
+ // from — which hosts that run's whole sequence of step jobs. It is the local analogue
9
+ // of `CloudflareContainerTransport` (a per-run Cloudflare Container) and of
10
+ // `RunnerPoolTransport` (an org's self-hosted pool): the ContainerAgentExecutor drives
11
+ // all three identically through the `RunnerTransport` port, addressed by a
12
+ // {@link RunnerJobRef} — the run id (which container) plus the per-step job id.
13
+ //
14
+ // A container is started per RUN (`docker run -d`, harness `:8080` published to an
15
+ // ephemeral host port), labelled with the run id so the run's later steps re-attach to
16
+ // it instead of starting a duplicate, and the harness keys each step's job by the
17
+ // per-step job id (so siblings never collide). The harness reaches this service's LLM proxy at
18
+ // `host.docker.internal` — published via `--add-host` on Linux — and clones/pushes
19
+ // to github.com directly with the per-job token in the request body. Nothing
20
+ // long-lived is mounted: the per-job GitHub + proxy tokens travel in the POST body
21
+ // and live only for the job, in the container's ephemeral filesystem.
22
+ /** Maps a dispatch kind to the harness HTTP route that starts that job. */
23
+ const KIND_ROUTE = {
24
+ run: '/run',
25
+ blueprint: '/blueprint',
26
+ spec: '/spec',
27
+ explore: '/explore',
28
+ bootstrap: '/bootstrap',
29
+ 'ci-fix': '/ci-fix',
30
+ 'resolve-conflicts': '/resolve-conflicts',
31
+ merge: '/merge',
32
+ test: '/test',
33
+ 'fix-tests': '/fix-tests',
34
+ };
35
+ // The failed-poll error the engine classifies as a container eviction (matched by
36
+ // orchestration `isContainerEvictionError`, also used by the bootstrap flow). A
37
+ // vanished/exited local container maps to it so the run stops and the stale-run
38
+ // sweeper can re-drive it — mirroring the Worker transport's 404 mapping.
39
+ const EVICTION_ERROR = 'Job not found (container evicted or crashed)';
40
+ // Labels the per-RUN container by its run id (the container key). A run's steps share
41
+ // one container, so this is the run id, not a per-step job id.
42
+ const LABEL_RUN = 'cat-factory.runId';
43
+ const LABEL_MANAGED = 'cat-factory.managed=local-docker';
44
+ /** The port the harness listens on inside the container. */
45
+ const HARNESS_PORT = 8080;
46
+ const SECRET_HEADER = 'x-harness-secret';
47
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
48
+ export class LocalDockerRunnerTransport {
49
+ image;
50
+ binary;
51
+ sharedSecret;
52
+ addHostGateway;
53
+ network;
54
+ extraEnv;
55
+ exec;
56
+ fetchImpl;
57
+ readyTimeoutMs;
58
+ requestTimeoutMs;
59
+ privilegedTestJobs;
60
+ /** runId → resolved container handle, to spare a `docker` lookup on the hot poll path. */
61
+ cache = new Map();
62
+ constructor(options) {
63
+ this.image = options.image;
64
+ this.binary = options.binary ?? 'docker';
65
+ this.sharedSecret = options.sharedSecret ?? randomBytes(24).toString('hex');
66
+ this.addHostGateway = options.addHostGateway ?? true;
67
+ this.network = options.network;
68
+ this.extraEnv = options.env ?? {};
69
+ this.exec =
70
+ options.exec ?? ((args) => execFileAsync(this.binary, args, { maxBuffer: 16 * 1024 * 1024 }));
71
+ this.fetchImpl = options.fetchImpl ?? fetch;
72
+ this.readyTimeoutMs = options.readyTimeoutMs ?? 60_000;
73
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
74
+ this.privilegedTestJobs = options.privilegedTestJobs ?? true;
75
+ }
76
+ async dispatch(ref, spec, kind = 'run', options) {
77
+ // The container is per-RUN: a run's first step starts it, later steps re-attach to
78
+ // it (resolved by the run-id label), and the harness keys each step's job by the
79
+ // per-step `ref.jobId` carried in the spec body.
80
+ const runId = ref.runId;
81
+ let resolved = await this.resolve(runId);
82
+ if (!resolved) {
83
+ // A prior attempt may have left an exited/dead container under this run label
84
+ // (resolve() returns undefined for one whose port is no longer published). Remove
85
+ // any such container first so it can't shadow the fresh one in later label lookups
86
+ // (findContainer returns the first match).
87
+ await this.removeContainersForRun(runId);
88
+ const args = [
89
+ 'run',
90
+ '-d',
91
+ '--label',
92
+ `${LABEL_RUN}=${runId}`,
93
+ '--label',
94
+ LABEL_MANAGED,
95
+ '-p',
96
+ `127.0.0.1:0:${HARNESS_PORT}`,
97
+ '-e',
98
+ `HARNESS_SHARED_SECRET=${this.sharedSecret}`,
99
+ ];
100
+ // Size the per-job container on the host daemon from the service's abstract
101
+ // instance size — the local backend never touches a cloud, it just provisions a
102
+ // bigger/smaller Docker container (`--memory`/`--cpus`).
103
+ if (options?.instanceSize) {
104
+ const { memory, cpus } = resolveDockerResources(options.instanceSize);
105
+ args.push('--memory', memory, '--cpus', cpus);
106
+ }
107
+ // The Tester stands its infra up with `docker compose` INSIDE the job container
108
+ // (Docker-in-Docker), so the dependencies sit on the container's own localhost.
109
+ // Run that one kind privileged so the in-container daemon can start. No other
110
+ // kind needs Docker, so none other gets elevated.
111
+ if (kind === 'test' && this.privilegedTestJobs)
112
+ args.push('--privileged');
113
+ if (this.addHostGateway)
114
+ args.push('--add-host=host.docker.internal:host-gateway');
115
+ if (this.network)
116
+ args.push('--network', this.network);
117
+ for (const [k, v] of Object.entries(this.extraEnv))
118
+ args.push('-e', `${k}=${v}`);
119
+ args.push(this.image);
120
+ const { stdout } = await this.exec(args);
121
+ const containerId = stdout.trim().split(/\s+/).pop();
122
+ if (!containerId)
123
+ throw new Error('docker run returned no container id');
124
+ const port = await this.waitForPort(containerId);
125
+ resolved = { containerId, port };
126
+ this.cache.set(runId, resolved);
127
+ await this.waitForHealth(resolved.port);
128
+ }
129
+ // POST the job to the kind's route. Idempotent: re-attaching to an already-running
130
+ // container re-POSTs, which the harness's per-id registry treats as a re-attach.
131
+ const res = await this.fetchImpl(this.url(resolved.port, KIND_ROUTE[kind]), {
132
+ method: 'POST',
133
+ headers: { 'content-type': 'application/json', [SECRET_HEADER]: this.sharedSecret },
134
+ body: JSON.stringify(spec),
135
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
136
+ });
137
+ if (!res.ok) {
138
+ throw new Error(`Local container dispatch failed (HTTP ${res.status}): ${await safeText(res)}`);
139
+ }
140
+ }
141
+ async poll(ref) {
142
+ const resolved = await this.resolve(ref.runId);
143
+ // No container for this run at all → it was evicted/reaped (or never started).
144
+ if (!resolved)
145
+ return { state: 'failed', error: EVICTION_ERROR };
146
+ let res;
147
+ try {
148
+ // Address the per-RUN container, but read the per-step job by its own id.
149
+ res = await this.fetchImpl(this.url(resolved.port, `/jobs/${encodeURIComponent(ref.jobId)}`), {
150
+ method: 'GET',
151
+ headers: { [SECRET_HEADER]: this.sharedSecret },
152
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
153
+ });
154
+ }
155
+ catch (err) {
156
+ // Connection refused / DNS gone: the container most likely exited. Confirm via
157
+ // the daemon — if it is no longer running, report an eviction so the run stops;
158
+ // otherwise surface the transient error so the caller can retry.
159
+ if (!(await this.isRunning(resolved.containerId))) {
160
+ this.cache.delete(ref.runId);
161
+ return { state: 'failed', error: EVICTION_ERROR };
162
+ }
163
+ throw err;
164
+ }
165
+ // The container is up but the harness no longer knows this job id (it was reaped
166
+ // after completion, or the container was recreated): treat as an eviction.
167
+ if (res.status === 404)
168
+ return { state: 'failed', error: EVICTION_ERROR };
169
+ if (!res.ok) {
170
+ throw new Error(`Local container job poll failed (HTTP ${res.status}): ${await safeText(res)}`);
171
+ }
172
+ return (await res.json());
173
+ }
174
+ /**
175
+ * Reclaim the per-RUN container now (`docker rm -f`) rather than leaving it idle —
176
+ * this tears down the whole run's container (and with it any step still running in
177
+ * it). Best-effort and idempotent: removing an already-gone container is a no-op.
178
+ */
179
+ async release(ref) {
180
+ const containerId = this.cache.get(ref.runId)?.containerId ?? (await this.findContainer(ref.runId));
181
+ this.cache.delete(ref.runId);
182
+ if (!containerId)
183
+ return;
184
+ await this.exec(['rm', '-f', containerId]).catch(() => undefined);
185
+ }
186
+ /**
187
+ * Reap exited per-job containers this transport manages — orphans a crash or hard
188
+ * kill left behind (release() never ran for them). Best-effort; returns the count
189
+ * removed. Call once at boot, before any job is in flight.
190
+ */
191
+ async reapExited() {
192
+ const { stdout } = await this.exec([
193
+ 'ps',
194
+ '-aq',
195
+ '--filter',
196
+ `label=${LABEL_MANAGED}`,
197
+ '--filter',
198
+ 'status=exited',
199
+ ]);
200
+ const ids = stdout
201
+ .trim()
202
+ .split('\n')
203
+ .map((s) => s.trim())
204
+ .filter(Boolean);
205
+ if (ids.length)
206
+ await this.exec(['rm', '-f', ...ids]).catch(() => undefined);
207
+ return ids.length;
208
+ }
209
+ // --- internals ----------------------------------------------------------
210
+ /** Force-remove every (running or exited) container labelled with this run id. */
211
+ async removeContainersForRun(runId) {
212
+ const { stdout } = await this.exec([
213
+ 'ps',
214
+ '-aq',
215
+ '--filter',
216
+ `label=${LABEL_RUN}=${runId}`,
217
+ '--filter',
218
+ `label=${LABEL_MANAGED}`,
219
+ ]);
220
+ const ids = stdout
221
+ .trim()
222
+ .split('\n')
223
+ .map((s) => s.trim())
224
+ .filter(Boolean);
225
+ if (ids.length)
226
+ await this.exec(['rm', '-f', ...ids]).catch(() => undefined);
227
+ }
228
+ url(port, path) {
229
+ return `http://127.0.0.1:${port}${path}`;
230
+ }
231
+ /** The container handle for a run from the cache, else rediscovered by label. */
232
+ async resolve(runId) {
233
+ const cached = this.cache.get(runId);
234
+ if (cached)
235
+ return cached;
236
+ const containerId = await this.findContainer(runId);
237
+ if (!containerId)
238
+ return undefined;
239
+ const port = await this.hostPort(containerId);
240
+ if (port === undefined)
241
+ return undefined;
242
+ const resolved = { containerId, port };
243
+ this.cache.set(runId, resolved);
244
+ return resolved;
245
+ }
246
+ /** The (running-or-exited) container id labelled with this run id, if any. */
247
+ async findContainer(runId) {
248
+ const { stdout } = await this.exec([
249
+ 'ps',
250
+ '-aq',
251
+ '--filter',
252
+ `label=${LABEL_RUN}=${runId}`,
253
+ '--filter',
254
+ `label=${LABEL_MANAGED}`,
255
+ ]);
256
+ return stdout.trim().split('\n')[0]?.trim() || undefined;
257
+ }
258
+ /** Parse the published host port for the harness port, or undefined if unmapped. */
259
+ async hostPort(containerId) {
260
+ const { stdout } = await this.exec(['port', containerId, `${HARNESS_PORT}/tcp`]);
261
+ // e.g. "127.0.0.1:49153" (possibly several lines for IPv4/IPv6); take the last
262
+ // numeric segment of the first line.
263
+ const line = stdout.trim().split('\n')[0]?.trim();
264
+ if (!line)
265
+ return undefined;
266
+ const port = Number(line.slice(line.lastIndexOf(':') + 1));
267
+ return Number.isFinite(port) && port > 0 ? port : undefined;
268
+ }
269
+ async waitForPort(containerId) {
270
+ const deadline = Date.now() + this.readyTimeoutMs;
271
+ for (;;) {
272
+ const port = await this.hostPort(containerId).catch(() => undefined);
273
+ if (port !== undefined)
274
+ return port;
275
+ if (Date.now() >= deadline) {
276
+ throw new Error(`Timed out waiting for container ${containerId} to publish its port`);
277
+ }
278
+ await delay(250);
279
+ }
280
+ }
281
+ async waitForHealth(port) {
282
+ const deadline = Date.now() + this.readyTimeoutMs;
283
+ for (;;) {
284
+ try {
285
+ const res = await this.fetchImpl(this.url(port, '/health'), {
286
+ method: 'GET',
287
+ signal: AbortSignal.timeout(Math.min(this.requestTimeoutMs, 5_000)),
288
+ });
289
+ if (res.ok)
290
+ return;
291
+ }
292
+ catch {
293
+ // not up yet
294
+ }
295
+ if (Date.now() >= deadline) {
296
+ throw new Error(`Timed out waiting for the harness on :${port} to become healthy`);
297
+ }
298
+ await delay(300);
299
+ }
300
+ }
301
+ async isRunning(containerId) {
302
+ try {
303
+ const { stdout } = await this.exec(['inspect', '-f', '{{.State.Running}}', containerId]);
304
+ return stdout.trim() === 'true';
305
+ }
306
+ catch {
307
+ // No such container → not running.
308
+ return false;
309
+ }
310
+ }
311
+ }
312
+ async function safeText(res) {
313
+ try {
314
+ return (await res.text()).slice(0, 500);
315
+ }
316
+ catch {
317
+ return '(no body)';
318
+ }
319
+ }
320
+ /**
321
+ * Build a {@link LocalDockerRunnerTransport} from the process environment. The image
322
+ * ref (`LOCAL_HARNESS_IMAGE`) is required; everything else has sane local defaults.
323
+ */
324
+ export function createLocalDockerTransportFromEnv(env) {
325
+ const image = env.LOCAL_HARNESS_IMAGE?.trim();
326
+ if (!image) {
327
+ throw new Error('LOCAL_HARNESS_IMAGE is required for local mode: set it to the executor-harness image ref ' +
328
+ '(a GHCR pull or a tag built from backend/internal/executor-harness/Dockerfile).');
329
+ }
330
+ return new LocalDockerRunnerTransport({
331
+ image,
332
+ binary: env.LOCAL_DOCKER_BINARY?.trim() || 'docker',
333
+ sharedSecret: env.HARNESS_SHARED_SECRET?.trim() || undefined,
334
+ network: env.LOCAL_DOCKER_NETWORK?.trim() || undefined,
335
+ addHostGateway: env.LOCAL_DOCKER_ADD_HOST_GATEWAY?.trim() !== 'false',
336
+ // Default on: the Tester stands its docker-compose infra up via Docker-in-Docker,
337
+ // which needs a privileged job container. Set to `false` for runtimes that run
338
+ // nested containers without it (e.g. rootless Podman).
339
+ privilegedTestJobs: env.LOCAL_DOCKER_PRIVILEGED_TEST_JOBS?.trim() !== 'false',
340
+ });
341
+ }
342
+ //# sourceMappingURL=LocalDockerRunnerTransport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LocalDockerRunnerTransport.js","sourceRoot":"","sources":["../src/LocalDockerRunnerTransport.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { AppConfig } from '@cat-factory/server';
2
+ /**
3
+ * Apply local-mode env defaults onto a copy of {@link env}. Idempotent: an explicitly
4
+ * set value is always preserved, so calling it twice (loader + container) is safe.
5
+ */
6
+ export declare function applyLocalDefaults(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
7
+ /** The shared {@link AppConfig} with local-mode defaults applied. */
8
+ export declare function loadLocalConfig(env: NodeJS.ProcessEnv): AppConfig;
9
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAepD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,CAmB5E;AAED,qEAAqE;AACrE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,SAAS,CAEjE"}
package/dist/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { loadNodeConfig } from '@cat-factory/node-server';
3
+ // Local mode is a single developer running the whole product on their own machine.
4
+ // It reuses the Node facade's config loader verbatim and only changes the defaults
5
+ // that would otherwise force cloud-style setup:
6
+ // - the auth gate defaults OPEN (no GitHub OAuth app to register) — never in a
7
+ // production ENVIRONMENT (`loadNodeConfig` enforces that);
8
+ // - a session secret is generated if absent (it only signs the short-lived LLM-proxy
9
+ // tokens the local container uses; a per-process value is fine for dev);
10
+ // - PUBLIC_URL defaults to `host.docker.internal:<PORT>` so a job's container can
11
+ // reach this service's LLM proxy from inside Docker.
12
+ // Every default is overridable: setting the corresponding env var wins.
13
+ const DEFAULT_PORT = '8787';
14
+ /**
15
+ * Apply local-mode env defaults onto a copy of {@link env}. Idempotent: an explicitly
16
+ * set value is always preserved, so calling it twice (loader + container) is safe.
17
+ */
18
+ export function applyLocalDefaults(env) {
19
+ const port = env.PORT?.trim() || DEFAULT_PORT;
20
+ return {
21
+ ...env,
22
+ // `|| 'true'` (not `??`) so an explicit empty `AUTH_DEV_OPEN=` still defaults open,
23
+ // consistent with the other fields here; set `AUTH_DEV_OPEN=false` to close the gate.
24
+ AUTH_DEV_OPEN: env.AUTH_DEV_OPEN?.trim() || 'true',
25
+ // Stable within a process; only signs short-lived proxy tokens for local jobs.
26
+ AUTH_SESSION_SECRET: env.AUTH_SESSION_SECRET?.trim() || randomBytes(32).toString('hex'),
27
+ // The shared key backing credential encryption at rest (document/task/runner/slack
28
+ // integrations, personal subscriptions). `loadNodeConfig` requires it, so generate a
29
+ // per-process key when absent — enough to boot and run a pipeline. Set ENCRYPTION_KEY
30
+ // explicitly to keep encrypted-at-rest credentials decryptable across restarts.
31
+ ENCRYPTION_KEY: env.ENCRYPTION_KEY?.trim() || randomBytes(32).toString('base64'),
32
+ // The harness (inside Docker) posts to `${PUBLIC_URL}/v1`; host.docker.internal
33
+ // routes back to this service on the host. The transport publishes the gateway
34
+ // host alias on Linux via `--add-host`.
35
+ PUBLIC_URL: env.PUBLIC_URL?.trim() || `http://host.docker.internal:${port}`,
36
+ };
37
+ }
38
+ /** The shared {@link AppConfig} with local-mode defaults applied. */
39
+ export function loadLocalConfig(env) {
40
+ return loadNodeConfig(applyLocalDefaults(env));
41
+ }
42
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AAGzD,mFAAmF;AACnF,mFAAmF;AACnF,gDAAgD;AAChD,iFAAiF;AACjF,+DAA+D;AAC/D,uFAAuF;AACvF,6EAA6E;AAC7E,oFAAoF;AACpF,yDAAyD;AACzD,wEAAwE;AAExE,MAAM,YAAY,GAAG,MAAM,CAAA;AAE3B;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAsB;IACvD,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,YAAY,CAAA;IAC7C,OAAO;QACL,GAAG,GAAG;QACN,oFAAoF;QACpF,sFAAsF;QACtF,aAAa,EAAE,GAAG,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,MAAM;QAClD,+EAA+E;QAC/E,mBAAmB,EAAE,GAAG,CAAC,mBAAmB,EAAE,IAAI,EAAE,IAAI,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;QACvF,mFAAmF;QACnF,qFAAqF;QACrF,sFAAsF;QACtF,gFAAgF;QAChF,cAAc,EAAE,GAAG,CAAC,cAAc,EAAE,IAAI,EAAE,IAAI,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAChF,gFAAgF;QAChF,+EAA+E;QAC/E,wCAAwC;QACxC,UAAU,EAAE,GAAG,CAAC,UAAU,EAAE,IAAI,EAAE,IAAI,+BAA+B,IAAI,EAAE;KAC5E,CAAA;AACH,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,eAAe,CAAC,GAAsB;IACpD,OAAO,cAAc,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAA;AAChD,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { NodeContainerOptions } from '@cat-factory/node-server';
2
+ import type { ServerContainer } from '@cat-factory/server';
3
+ export declare function buildLocalContainer(options: NodeContainerOptions): ServerContainer;
4
+ //# sourceMappingURL=container.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"container.d.ts","sourceRoot":"","sources":["../src/container.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAA;AACpE,OAAO,KAAK,EAAqC,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAwB7F,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,eAAe,CAuElF"}
@@ -0,0 +1,84 @@
1
+ import { DrizzleGitHubInstallationRepository, buildNodeContainer, loadNodeConfig, } from '@cat-factory/node-server';
2
+ import { applyLocalDefaults } from './config.js';
3
+ import { createLocalGitHubClient, fetchPatAccount, githubPatCreationUrl } from './github.js';
4
+ import { AutoProvisioningInstallationRepository } from './installations.js';
5
+ import { createLocalDockerTransportFromEnv, } from './LocalDockerRunnerTransport.js';
6
+ // The local-mode composition root. It is intentionally thin: the ENTIRE Drizzle/
7
+ // Postgres persistence, pg-boss durable execution, gateways and model provisioning
8
+ // come from `buildNodeContainer` unchanged. Local mode only swaps the two
9
+ // differentiators behind the seams `buildNodeContainer` exposes:
10
+ // - the runner backend → a per-job local Docker container (LocalDockerRunnerTransport)
11
+ // instead of a self-hosted runner pool;
12
+ // - the push/clone token → a static GitHub PAT (`GITHUB_PAT`) instead of a GitHub
13
+ // App installation token.
14
+ // Repo resolution is unchanged: the executor still resolves a block's repo from the
15
+ // `github_repos` / `github_installations` projection (seed those rows for a target
16
+ // repo with the link helper). So a developer can run coder/mocker/playwright/
17
+ // blueprints/ci-fixer/merger jobs entirely locally, pushing real branches and opening
18
+ // real PRs on github.com via the PAT.
19
+ export function buildLocalContainer(options) {
20
+ const env = applyLocalDefaults(options.env ?? process.env);
21
+ const pat = env.GITHUB_PAT?.trim();
22
+ const base = options.config ?? loadNodeConfig(env);
23
+ // Tag the config as local mode and, when no PAT is set, carry the (scopes-preselected)
24
+ // creation URL so the SPA can surface it as a dismissible banner — the server-side warn
25
+ // log alone is easy to miss in a dev terminal. With a PAT, force the GitHub integration
26
+ // ON: the Node loader only enables it for a configured GitHub App, but local mode reaches
27
+ // GitHub through the PAT-backed client, so the read/link endpoints (connection, available
28
+ // repos, "add from existing repo") should be served the same way.
29
+ const config = {
30
+ ...base,
31
+ ...(pat ? { github: { ...base.github, enabled: true } } : {}),
32
+ localMode: {
33
+ enabled: true,
34
+ ...(pat ? {} : { githubPatSetupUrl: githubPatCreationUrl() }),
35
+ },
36
+ };
37
+ // Local mode has no GitHub-App connect flow, so a workspace's installation is conjured
38
+ // from the PAT on first read (see AutoProvisioningInstallationRepository): the synthetic
39
+ // row makes `getConnection` report connected and gives the sync service an installation
40
+ // id to list/link repos under. The PAT account is fetched once and shared across
41
+ // workspaces (a single developer's token).
42
+ let accountPromise;
43
+ const resolveAccount = () => (accountPromise ??= fetchPatAccount(env));
44
+ const githubInstallationRepository = pat && options.db
45
+ ? new AutoProvisioningInstallationRepository(new DrizzleGitHubInstallationRepository(options.db), resolveAccount)
46
+ : undefined;
47
+ // The Docker transport is constructed LAZILY on first container-job dispatch, so the
48
+ // service still boots to serve the board (and inline kinds) when LOCAL_HARNESS_IMAGE
49
+ // is unset — only repo-operating kinds then fail, loudly and with a clear message,
50
+ // mirroring how the Node facade treats a missing runner backend.
51
+ let transport;
52
+ const resolveTransport = () => {
53
+ transport ??= createLocalDockerTransportFromEnv(env);
54
+ return Promise.resolve(transport);
55
+ };
56
+ return buildNodeContainer({
57
+ ...options,
58
+ env,
59
+ config,
60
+ // Always dispatch container jobs to the local Docker transport (a constant
61
+ // resolver, ignoring workspace — local mode has no per-workspace runner pools).
62
+ resolveTransport,
63
+ // Authenticate git with the developer's PAT when present. Absent → the executor
64
+ // falls back to the GitHub App path (and is null without it), so container kinds
65
+ // fail loudly rather than silently mis-running.
66
+ ...(pat ? { mintInstallationToken: async () => pat } : {}),
67
+ // The PAT-backed GitHub client wires the CI gate + merge / mergeability providers,
68
+ // so a local pipeline gates on real GitHub Actions CI and merges the PR for real, AND
69
+ // serves the read/link endpoints (it lists repos via /user/repos, the PAT analogue of
70
+ // the App-only /installation/repositories).
71
+ ...(pat ? { githubClient: createLocalGitHubClient(env) } : {}),
72
+ // Auto-provision the synthetic per-workspace installation so the integration reports
73
+ // connected with no manual connect step.
74
+ ...(githubInstallationRepository ? { githubInstallationRepository } : {}),
75
+ overrides: {
76
+ ...options.overrides,
77
+ // The local PAT carries `workflow` scope (the creation URL pre-selects it), so the
78
+ // connection isn't missing workflows: write — report it granted to suppress the
79
+ // advisory banner. (The App-permissions probe this normally uses needs an app JWT.)
80
+ ...(pat ? { workflowsGranted: async () => true } : {}),
81
+ },
82
+ });
83
+ }
84
+ //# sourceMappingURL=container.js.map