@north-light/crouter-env-docker 0.3.286

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 ADDED
@@ -0,0 +1,91 @@
1
+ # @north-light/crouter-env-docker
2
+
3
+ A Docker `Environment` for the [crouter SDK](https://www.npmjs.com/package/@north-light/crouter-sdk) (`@north-light/crouter-sdk`'s `generate()`): run a [`crtrd`](https://github.com/vallum-security/crouter/tree/main/docker) container, wait for it to come up, and hand back the `{ baseUrl, headers }` shape the SDK needs to talk to it.
4
+
5
+ Zero crouter imports — this package depends on nothing but Node built-ins. It shells out to the `docker` CLI via `child_process` and structurally satisfies the SDK's `Environment` interface (copied by hand into this package's own types, not imported).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @north-light/crouter-env-docker
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { start } from '@north-light/crouter-env-docker';
17
+
18
+ const env = await start(); // defaults to ghcr.io/vallum-security/crtrd:latest
19
+ const { baseUrl, headers } = await env.daemon();
20
+ // baseUrl: http://127.0.0.1:<random free port>
21
+ // headers: { Authorization: 'Bearer <randomly generated CRTRD_TOKEN>' }
22
+
23
+ await env.stop(); // stops and removes the container
24
+ ```
25
+
26
+ Named + volume-backed = persistent, addressable by a later `attach()`:
27
+
28
+ ```ts
29
+ import { start, attach } from '@north-light/crouter-env-docker';
30
+
31
+ await start({ image: 'crtrd:local', name: 'my-agent', volume: 'my-agent-home' });
32
+ // ... later, possibly from a different process ...
33
+ const env = await attach('my-agent');
34
+ const { baseUrl, headers } = await env.daemon();
35
+ await env.stop(); // stops only — the container and its volume survive
36
+ ```
37
+
38
+ ### With the crouter SDK
39
+
40
+ ```ts
41
+ import { start } from '@north-light/crouter-env-docker';
42
+ import { generate } from '@north-light/crouter-sdk';
43
+ import { z } from 'zod';
44
+
45
+ const env = await start({ image: 'crtrd:local', env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! } });
46
+
47
+ const result = await generate({
48
+ prompt: 'Read package.json and report the package name and version.',
49
+ schema: z.object({ name: z.string(), version: z.string() }),
50
+ env,
51
+ });
52
+
53
+ await env.stop();
54
+ ```
55
+
56
+ ## API
57
+
58
+ ```ts
59
+ export interface Environment {
60
+ daemon(): Promise<{ baseUrl?: string; socketPath?: string; headers?: Record<string, string> }>;
61
+ }
62
+
63
+ export interface DockerEnvironment extends Environment {
64
+ containerId: string;
65
+ name: string;
66
+ stop(): Promise<void>; // stop + remove (start()'s container) or just stop (attach()'s)
67
+ }
68
+
69
+ export function start(opts?: {
70
+ image?: string; // default: DEFAULT_IMAGE (ghcr.io/vallum-security/crtrd:<pin> — see below)
71
+ env?: Record<string, string>; // forwarded to the container as -e KEY=VALUE (model provider keys, etc)
72
+ name?: string; // container name; default: a generated random name
73
+ volume?: string; // named volume mounted at CRTR_HOME; default: none (ephemeral home)
74
+ port?: number; // host port, always bound to 127.0.0.1; default: a random free port docker assigns
75
+ }): Promise<DockerEnvironment>;
76
+
77
+ export function attach(name: string): Promise<DockerEnvironment>;
78
+ ```
79
+
80
+ - `start()` generates a random `CRTRD_TOKEN`, passes it into the container as `-e`, and resolves only once `/healthz` answers `200` with that bearer through the mapped host port.
81
+ - `attach(name)` reads the mapped port and `CRTRD_TOKEN` off an already-running container via `docker inspect`. It throws a clear error if the container does not exist, is not running, or was not started with a `CRTRD_TOKEN` env var.
82
+ - The token is never logged.
83
+ - The container port is published to `127.0.0.1` only, never `0.0.0.0` — the daemon holds whatever provider keys you passed in `env`, and must not be reachable from the LAN.
84
+
85
+ ## Default image
86
+
87
+ `DEFAULT_IMAGE` is `ghcr.io/vallum-security/crtrd:latest`, which the release workflow pushes alongside each version tag. Pass `image` to pin a version, or to run a locally built tag (`crtrd:local`, see [`docker/README.md`](../../docker/README.md)).
88
+
89
+ ## Requirements
90
+
91
+ The `docker` CLI, reachable and authenticated for whatever registry `image` names, on `PATH`.
@@ -0,0 +1,10 @@
1
+ /** A `docker` CLI invocation that exited non-zero. Carries stderr separately
2
+ * so callers can fold it into a more specific message without re-parsing. */
3
+ export declare class DockerCliError extends Error {
4
+ readonly stderr: string;
5
+ constructor(message: string, stderr: string);
6
+ }
7
+ /** Run `docker <args>` and return trimmed stdout, or throw `DockerCliError`
8
+ * with stderr folded into the message. Never logs anything itself — a
9
+ * caller passing `-e CRTRD_TOKEN=...` in `args` must not print `args`. */
10
+ export declare function docker(args: string[]): Promise<string>;
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /** A `docker` CLI invocation that exited non-zero. Carries stderr separately
5
+ * so callers can fold it into a more specific message without re-parsing. */
6
+ export class DockerCliError extends Error {
7
+ stderr;
8
+ constructor(message, stderr) {
9
+ super(message);
10
+ this.name = 'DockerCliError';
11
+ this.stderr = stderr;
12
+ }
13
+ }
14
+ /** Run `docker <args>` and return trimmed stdout, or throw `DockerCliError`
15
+ * with stderr folded into the message. Never logs anything itself — a
16
+ * caller passing `-e CRTRD_TOKEN=...` in `args` must not print `args`. */
17
+ export async function docker(args) {
18
+ try {
19
+ const { stdout } = await execFileAsync('docker', args, { maxBuffer: 16 * 1024 * 1024 });
20
+ return stdout.trim();
21
+ }
22
+ catch (error) {
23
+ const stderr = isExecError(error) ? error.stderr.trim() : '';
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ throw new DockerCliError(`docker ${args[0] ?? ''} failed: ${stderr || message}`, stderr);
26
+ }
27
+ }
28
+ function isExecError(error) {
29
+ return typeof error === 'object' && error !== null && typeof error.stderr === 'string';
30
+ }
@@ -0,0 +1,7 @@
1
+ /** Poll `<baseUrl>/healthz` until it answers 200, `opts.timeoutMs` elapses,
2
+ * or `opts.isAlive` reports the container already exited. */
3
+ export declare function waitForHealthy(baseUrl: string, headers: Record<string, string>, opts: {
4
+ timeoutMs: number;
5
+ intervalMs: number;
6
+ isAlive?: () => Promise<boolean>;
7
+ }): Promise<void>;
package/dist/health.js ADDED
@@ -0,0 +1,38 @@
1
+ import { request } from 'node:http';
2
+ /** Poll `<baseUrl>/healthz` until it answers 200, `opts.timeoutMs` elapses,
3
+ * or `opts.isAlive` reports the container already exited. */
4
+ export async function waitForHealthy(baseUrl, headers, opts) {
5
+ const deadline = Date.now() + opts.timeoutMs;
6
+ let lastError;
7
+ while (Date.now() < deadline) {
8
+ if (opts.isAlive && !(await opts.isAlive())) {
9
+ throw new Error(`container exited before /healthz answered${lastError ? ` (last probe: ${lastError})` : ''}`);
10
+ }
11
+ try {
12
+ const status = await probe(baseUrl, headers);
13
+ if (status === 200)
14
+ return;
15
+ lastError = `HTTP ${status}`;
16
+ }
17
+ catch (error) {
18
+ lastError = error instanceof Error ? error.message : String(error);
19
+ }
20
+ await sleep(opts.intervalMs);
21
+ }
22
+ throw new Error(`/healthz did not return 200 within ${opts.timeoutMs}ms (last: ${lastError ?? 'no response'})`);
23
+ }
24
+ function probe(baseUrl, headers) {
25
+ return new Promise((resolve, reject) => {
26
+ const url = new URL('/healthz', baseUrl);
27
+ const req = request(url, { method: 'GET', headers, timeout: 2000 }, (res) => {
28
+ res.resume();
29
+ resolve(res.statusCode ?? 0);
30
+ });
31
+ req.on('timeout', () => req.destroy(new Error('healthz probe timed out')));
32
+ req.on('error', reject);
33
+ req.end();
34
+ });
35
+ }
36
+ function sleep(ms) {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
@@ -0,0 +1,16 @@
1
+ import type { DockerEnvironment, Environment, StartOptions } from './types.js';
2
+ export type { DockerEnvironment, Environment, StartOptions };
3
+ /**
4
+ * Default image tag — the `latest` the release workflow's `publish-image` job
5
+ * pushes alongside the version tag. Pass `image` to pin a version or to run a
6
+ * locally built tag (e.g. `crtrd:local`).
7
+ */
8
+ export declare const DEFAULT_IMAGE = "ghcr.io/vallum-security/crtrd:latest";
9
+ /** Run the crtrd image, wait until `/healthz` answers 200 through the mapped
10
+ * port, and return an `Environment` pointed at it. `stop()` stops and
11
+ * removes the container. */
12
+ export declare function start(opts?: StartOptions): Promise<DockerEnvironment>;
13
+ /** Wrap an already-running container by name. Reads its mapped host port and
14
+ * `CRTRD_TOKEN` via `docker inspect`. `stop()` only stops the container —
15
+ * it does not remove it. */
16
+ export declare function attach(name: string): Promise<DockerEnvironment>;
package/dist/index.js ADDED
@@ -0,0 +1,86 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { docker } from './docker-cli.js';
3
+ import { waitForHealthy } from './health.js';
4
+ import { CONTAINER_PORT, inspectContainer, isContainerRunning, readHostPortFromInspect, readTokenFromInspect, resolveHostPort, tryReadLogs, } from './inspect.js';
5
+ /**
6
+ * Default image tag — the `latest` the release workflow's `publish-image` job
7
+ * pushes alongside the version tag. Pass `image` to pin a version or to run a
8
+ * locally built tag (e.g. `crtrd:local`).
9
+ */
10
+ export const DEFAULT_IMAGE = 'ghcr.io/vallum-security/crtrd:latest';
11
+ const HEALTH_TIMEOUT_MS = 60_000;
12
+ const HEALTH_INTERVAL_MS = 300;
13
+ function generateToken() {
14
+ return randomBytes(24).toString('hex');
15
+ }
16
+ function generateName() {
17
+ return `crtrd-env-${randomBytes(6).toString('hex')}`;
18
+ }
19
+ /** Run the crtrd image, wait until `/healthz` answers 200 through the mapped
20
+ * port, and return an `Environment` pointed at it. `stop()` stops and
21
+ * removes the container. */
22
+ export async function start(opts = {}) {
23
+ const image = opts.image ?? DEFAULT_IMAGE;
24
+ const name = opts.name ?? generateName();
25
+ const token = generateToken();
26
+ const args = ['run', '-d', '--name', name, '-e', `CRTRD_TOKEN=${token}`];
27
+ for (const [key, value] of Object.entries(opts.env ?? {})) {
28
+ args.push('-e', `${key}=${value}`);
29
+ }
30
+ if (opts.volume) {
31
+ args.push('-v', `${opts.volume}:/home/agent/.crouter`);
32
+ }
33
+ // Bind loopback explicitly: docker's default publishes to 0.0.0.0, which
34
+ // would put a daemon holding the caller's provider keys on the LAN behind
35
+ // nothing but its bearer token.
36
+ args.push('-p', `127.0.0.1:${opts.port ?? 0}:${CONTAINER_PORT}`);
37
+ args.push(image);
38
+ const containerId = await docker(args);
39
+ const headers = { Authorization: `Bearer ${token}` };
40
+ let baseUrl;
41
+ try {
42
+ const port = await resolveHostPort(containerId);
43
+ baseUrl = `http://127.0.0.1:${port}`;
44
+ await waitForHealthy(baseUrl, headers, {
45
+ timeoutMs: HEALTH_TIMEOUT_MS,
46
+ intervalMs: HEALTH_INTERVAL_MS,
47
+ isAlive: () => isContainerRunning(containerId),
48
+ });
49
+ }
50
+ catch (error) {
51
+ const logs = await tryReadLogs(containerId);
52
+ await docker(['rm', '-f', containerId]).catch(() => { });
53
+ throw new Error(`crouter-env-docker: start() container '${name}' (${containerId.slice(0, 12)}) never became healthy: ${error instanceof Error ? error.message : String(error)}${logs ? `\n--- docker logs ---\n${logs}` : ''}`);
54
+ }
55
+ return {
56
+ containerId,
57
+ name,
58
+ async daemon() {
59
+ return { baseUrl, headers };
60
+ },
61
+ async stop() {
62
+ await docker(['rm', '-f', containerId]);
63
+ },
64
+ };
65
+ }
66
+ /** Wrap an already-running container by name. Reads its mapped host port and
67
+ * `CRTRD_TOKEN` via `docker inspect`. `stop()` only stops the container —
68
+ * it does not remove it. */
69
+ export async function attach(name) {
70
+ const inspection = await inspectContainer(name);
71
+ const containerId = inspection.Id;
72
+ const token = readTokenFromInspect(inspection, name);
73
+ const port = readHostPortFromInspect(inspection, name);
74
+ const baseUrl = `http://127.0.0.1:${port}`;
75
+ const headers = { Authorization: `Bearer ${token}` };
76
+ return {
77
+ containerId,
78
+ name,
79
+ async daemon() {
80
+ return { baseUrl, headers };
81
+ },
82
+ async stop() {
83
+ await docker(['stop', containerId]);
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,30 @@
1
+ export declare const CONTAINER_PORT = 7777;
2
+ interface DockerInspectOutput {
3
+ Id: string;
4
+ State?: {
5
+ Running?: boolean;
6
+ };
7
+ Config?: {
8
+ Env?: string[];
9
+ };
10
+ NetworkSettings?: {
11
+ Ports?: Record<string, {
12
+ HostIp?: string;
13
+ HostPort?: string;
14
+ }[] | null>;
15
+ };
16
+ }
17
+ /** `docker inspect <name>` for one container. Throws a message naming `name`
18
+ * when the container does not exist — the caller (`attach()`) surfaces this
19
+ * as-is, which is the "attach fails clearly because the container is gone"
20
+ * contract. */
21
+ export declare function inspectContainer(name: string): Promise<DockerInspectOutput>;
22
+ export declare function readTokenFromInspect(inspection: DockerInspectOutput, name: string): string;
23
+ export declare function readHostPortFromInspect(inspection: DockerInspectOutput, name: string): number;
24
+ export declare function isContainerRunning(containerId: string): Promise<boolean>;
25
+ export declare function tryReadLogs(containerId: string): Promise<string | undefined>;
26
+ /** `docker port <id> <containerPort>/tcp` prints one host binding per line —
27
+ * e.g. `0.0.0.0:54321` and `[::]:54321` for a dual-stack mapping. Take the
28
+ * first line's port. */
29
+ export declare function resolveHostPort(containerId: string): Promise<number>;
30
+ export {};
@@ -0,0 +1,66 @@
1
+ import { docker } from './docker-cli.js';
2
+ export const CONTAINER_PORT = 7777;
3
+ /** `docker inspect <name>` for one container. Throws a message naming `name`
4
+ * when the container does not exist — the caller (`attach()`) surfaces this
5
+ * as-is, which is the "attach fails clearly because the container is gone"
6
+ * contract. */
7
+ export async function inspectContainer(name) {
8
+ let stdout;
9
+ try {
10
+ stdout = await docker(['inspect', name]);
11
+ }
12
+ catch (error) {
13
+ throw new Error(`crouter-env-docker: attach('${name}') failed — no such container (${error instanceof Error ? error.message : String(error)})`);
14
+ }
15
+ const parsed = JSON.parse(stdout);
16
+ const entry = parsed[0];
17
+ if (!entry) {
18
+ throw new Error(`crouter-env-docker: attach('${name}') failed — docker inspect returned no data`);
19
+ }
20
+ return entry;
21
+ }
22
+ export function readTokenFromInspect(inspection, name) {
23
+ const env = inspection.Config?.Env ?? [];
24
+ const entry = env.find((line) => line.startsWith('CRTRD_TOKEN='));
25
+ if (!entry) {
26
+ throw new Error(`crouter-env-docker: attach('${name}') failed — container has no CRTRD_TOKEN env var (was it started by this package's start()?)`);
27
+ }
28
+ return entry.slice('CRTRD_TOKEN='.length);
29
+ }
30
+ export function readHostPortFromInspect(inspection, name) {
31
+ const bindings = inspection.NetworkSettings?.Ports?.[`${CONTAINER_PORT}/tcp`];
32
+ const hostPort = bindings?.[0]?.HostPort;
33
+ if (!hostPort) {
34
+ throw new Error(`crouter-env-docker: attach('${name}') failed — no host port mapped for ${CONTAINER_PORT}/tcp (is the container running?)`);
35
+ }
36
+ return Number(hostPort);
37
+ }
38
+ export async function isContainerRunning(containerId) {
39
+ try {
40
+ const state = await docker(['inspect', '-f', '{{.State.Running}}', containerId]);
41
+ return state === 'true';
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ export async function tryReadLogs(containerId) {
48
+ try {
49
+ return await docker(['logs', '--tail', '50', containerId]);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ /** `docker port <id> <containerPort>/tcp` prints one host binding per line —
56
+ * e.g. `0.0.0.0:54321` and `[::]:54321` for a dual-stack mapping. Take the
57
+ * first line's port. */
58
+ export async function resolveHostPort(containerId) {
59
+ const output = await docker(['port', containerId, `${CONTAINER_PORT}/tcp`]);
60
+ const firstLine = output.split('\n')[0]?.trim() ?? '';
61
+ const match = firstLine.match(/:(\d+)$/);
62
+ if (!match) {
63
+ throw new Error(`crouter-env-docker: could not determine the host port docker mapped for container ${containerId} (docker port output: ${JSON.stringify(output)})`);
64
+ }
65
+ return Number(match[1]);
66
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Structural mirror of the crouter SDK's `Environment` interface
3
+ * (`packages/crouter-sdk`, `crouter-sdk-design.md` unit B). Copied by hand,
4
+ * not imported — this package carries zero crouter/SDK dependencies, so a
5
+ * `DockerEnvironment` satisfies the SDK's `Environment` purely by shape. Keep
6
+ * this in sync with the SDK's definition if it changes.
7
+ */
8
+ export interface Environment {
9
+ /** Where the crtrd for this run is. Called once per `generate()`. */
10
+ daemon(): Promise<{
11
+ baseUrl?: string;
12
+ socketPath?: string;
13
+ headers?: Record<string, string>;
14
+ }>;
15
+ }
16
+ export interface DockerEnvironment extends Environment {
17
+ /** The container's full id. */
18
+ containerId: string;
19
+ /** The container's name — as passed to `start({ name })` or generated, or the name passed to `attach()`. */
20
+ name: string;
21
+ /** `start()`'s container: stop and remove. `attach()`'s container: stop only. */
22
+ stop(): Promise<void>;
23
+ }
24
+ export interface StartOptions {
25
+ /**
26
+ * Image to run. Default: `DEFAULT_IMAGE` (`ghcr.io/vallum-security/crtrd:latest`).
27
+ * Pass a version tag to pin, or a locally built tag (e.g. `crtrd:local`).
28
+ */
29
+ image?: string;
30
+ /** Extra env vars forwarded into the container — model provider keys, etc. Never logged. */
31
+ env?: Record<string, string>;
32
+ /** Container name. A named container is later addressable via `attach()`. Default: a generated random name. */
33
+ name?: string;
34
+ /** Named volume mounted at `CRTR_HOME` (`/home/agent/.crouter`). Default: none — an ephemeral, unnamed home. */
35
+ volume?: string;
36
+ /** Host port to bind, always on `127.0.0.1`. Default: a random free port docker assigns. */
37
+ port?: number;
38
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@north-light/crouter-env-docker",
3
+ "version": "0.3.286",
4
+ "description": "Docker Environment for the crouter SDK — start, attach to, and stop a crtrd container over the docker CLI. Zero crouter imports.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/vallum-security/crouter.git",
30
+ "directory": "packages/crouter-env-docker"
31
+ },
32
+ "license": "UNLICENSED"
33
+ }