@sagentlab/navarch-runtime 0.1.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.
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadRuntimeConfig = loadRuntimeConfig;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ function envInt(env, name, fallback) {
10
+ const raw = env[name];
11
+ if (!raw)
12
+ return fallback;
13
+ const n = Number(raw);
14
+ return Number.isFinite(n) && n > 0 ? n : fallback;
15
+ }
16
+ function envList(env, name, fallback) {
17
+ const raw = env[name];
18
+ if (!raw)
19
+ return fallback;
20
+ return raw
21
+ .split(",")
22
+ .map((s) => s.trim())
23
+ .filter(Boolean);
24
+ }
25
+ /**
26
+ * Loads runtime config from FLOTILLA_* env vars, with sane defaults for a
27
+ * fresh machine. Accepts an explicit env map (defaulting to process.env) so
28
+ * it is trivially unit-testable without mutating global state.
29
+ */
30
+ function loadRuntimeConfig(env = process.env) {
31
+ const configDir = env.FLOTILLA_CONFIG_DIR ?? node_path_1.default.join(node_os_1.default.homedir(), ".flotilla");
32
+ const leaseHeartbeatIntervalMs = envInt(env, "FLOTILLA_LEASE_HEARTBEAT_INTERVAL_MS", 5 * 60 * 1000);
33
+ return {
34
+ apiBase: env.FLOTILLA_API_BASE ?? "http://localhost:3000",
35
+ workspaceRoot: env.FLOTILLA_WORKSPACE_ROOT ?? node_path_1.default.join(configDir, "sandboxes"),
36
+ configDir,
37
+ maxSessions: envInt(env, "FLOTILLA_MAX_SESSIONS", 3),
38
+ capabilities: envList(env, "FLOTILLA_CAPABILITIES", ["docker-sandbox", "shell"]),
39
+ ownerZone: env.FLOTILLA_OWNER_ZONE ?? "sagentlab",
40
+ pollIntervalMs: envInt(env, "FLOTILLA_POLL_INTERVAL_MS", 5000),
41
+ machineHeartbeatIntervalMs: envInt(env, "FLOTILLA_HEARTBEAT_INTERVAL_MS", 60_000),
42
+ // leases.expires_at = claimed_at + 15 min (schema-design.md §4) — default renewal
43
+ // interval must stay comfortably under that TTL.
44
+ leaseHeartbeatIntervalMs,
45
+ sessionTimeoutMs: envInt(env, "FLOTILLA_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
46
+ agentType: env.FLOTILLA_AGENT === "codex" ? "codex" : "claude-code",
47
+ claudeBin: env.FLOTILLA_CLAUDE_BIN ?? "claude",
48
+ claudeExtraArgs: envList(env, "FLOTILLA_CLAUDE_EXTRA_ARGS", []),
49
+ codexBin: env.FLOTILLA_CODEX_BIN ?? "codex",
50
+ codexExtraArgs: envList(env, "FLOTILLA_CODEX_EXTRA_ARGS", []),
51
+ mcpConfigPath: env.FLOTILLA_MCP_CONFIG_PATH ?? null,
52
+ sandboxMode: env.FLOTILLA_SANDBOX_MODE === "host" ? "host" : "docker",
53
+ dockerImage: env.FLOTILLA_DOCKER_IMAGE ?? "node:20-slim",
54
+ };
55
+ }
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractPrUrl = extractPrUrl;
4
+ exports.parseClaudeJsonResult = parseClaudeJsonResult;
5
+ exports.extractUsageFromClaudeJson = extractUsageFromClaudeJson;
6
+ exports.parseCodexJsonEvents = parseCodexJsonEvents;
7
+ exports.extractUsageFromCodexEvents = extractUsageFromCodexEvents;
8
+ exports.extractFinalMessageFromCodexEvents = extractFinalMessageFromCodexEvents;
9
+ exports.mapExitCondition = mapExitCondition;
10
+ const PR_URL_PATTERN = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/;
11
+ function extractPrUrl(text) {
12
+ const match = text.match(PR_URL_PATTERN);
13
+ return match ? match[0] : null;
14
+ }
15
+ /**
16
+ * Best-effort parse of `claude -p --output-format json` stdout into the
17
+ * assumed result shape above. Tries the whole trimmed stdout first (the
18
+ * expected case); if that fails, tries each non-empty line that looks like a
19
+ * JSON object, in case a warning/log line preceded the result (last such
20
+ * line wins, since the final result is expected last). Returns null rather
21
+ * than throwing when nothing parses as an object carrying at least one of
22
+ * the fields this module actually reads.
23
+ */
24
+ function parseClaudeJsonResult(stdout) {
25
+ const trimmed = stdout.trim();
26
+ if (!trimmed)
27
+ return null;
28
+ const isUsableResult = (value) => typeof value === "object" &&
29
+ value !== null &&
30
+ !Array.isArray(value) &&
31
+ ("result" in value || "usage" in value || "cost_usd" in value || "total_cost_usd" in value);
32
+ try {
33
+ const parsed = JSON.parse(trimmed);
34
+ if (isUsableResult(parsed))
35
+ return parsed;
36
+ }
37
+ catch {
38
+ // fall through to line-by-line best effort below
39
+ }
40
+ const lines = trimmed.split("\n").filter((line) => line.trim().startsWith("{"));
41
+ for (const line of lines.reverse()) {
42
+ try {
43
+ const parsed = JSON.parse(line);
44
+ if (isUsableResult(parsed))
45
+ return parsed;
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Extracts token/cost usage from a parsed `claude -p --output-format json`
55
+ * result. Input tokens are the sum of `input_tokens` plus both cache
56
+ * variants (schema-design.md §4 `sessions.tokens_in` is a single bigint with
57
+ * no cache breakdown, so the components are folded together here rather
58
+ * than dropped). Cost prefers `total_cost_usd` (the field name used in
59
+ * multi-turn/agentic CLI output) and falls back to `cost_usd`.
60
+ */
61
+ function extractUsageFromClaudeJson(parsed) {
62
+ const usage = parsed.usage ?? {};
63
+ const tokensIn = (usage.input_tokens ?? 0) +
64
+ (usage.cache_creation_input_tokens ?? 0) +
65
+ (usage.cache_read_input_tokens ?? 0);
66
+ const tokensOut = usage.output_tokens ?? 0;
67
+ const costUsd = parsed.total_cost_usd ?? parsed.cost_usd ?? 0;
68
+ return { tokensIn, tokensOut, costUsd };
69
+ }
70
+ /**
71
+ * Best-effort line-by-line parse of `codex exec --json` stdout into the
72
+ * assumed JSONL event shape above. Silently skips any line that isn't a JSON
73
+ * object carrying a `msg` field (blank lines, stray log lines, a shape that
74
+ * doesn't match the guess) rather than throwing; returns an empty array (not
75
+ * null) when nothing parses, since a run can legitimately emit zero events.
76
+ */
77
+ function parseCodexJsonEvents(stdout) {
78
+ const events = [];
79
+ const lines = stdout
80
+ .split("\n")
81
+ .map((line) => line.trim())
82
+ .filter((line) => line.startsWith("{"));
83
+ for (const line of lines) {
84
+ try {
85
+ const parsed = JSON.parse(line);
86
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && "msg" in parsed) {
87
+ events.push(parsed);
88
+ }
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ }
94
+ return events;
95
+ }
96
+ /**
97
+ * Extracts token/cost usage from parsed `codex exec --json` events. Assumes
98
+ * each `token_count` event reports the run's cumulative totals so far (like
99
+ * Claude Code's running usage updates), so the *last* such event wins rather
100
+ * than summing across events; if that assumption is wrong (incremental
101
+ * per-turn deltas instead), this under- or over-counts until reconciled here.
102
+ */
103
+ function extractUsageFromCodexEvents(events) {
104
+ let tokensIn = 0;
105
+ let tokensOut = 0;
106
+ let costUsd = 0;
107
+ for (const event of events) {
108
+ if (event.msg?.type === "token_count") {
109
+ tokensIn = (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
110
+ tokensOut = event.msg.output_tokens ?? 0;
111
+ }
112
+ if (typeof event.msg?.total_cost_usd === "number") {
113
+ costUsd = event.msg.total_cost_usd;
114
+ }
115
+ }
116
+ return { tokensIn, tokensOut, costUsd };
117
+ }
118
+ /** The last `agent_message` event's text — assumed to be the run's final agent-visible output, mirroring Claude JSON result's `result` field. Returns null when no such event parsed (see parseCodexJsonEvents doc comment). */
119
+ function extractFinalMessageFromCodexEvents(events) {
120
+ for (let i = events.length - 1; i >= 0; i--) {
121
+ const msg = events[i]?.msg;
122
+ if (msg?.type === "agent_message" && typeof msg.message === "string")
123
+ return msg.message;
124
+ }
125
+ return null;
126
+ }
127
+ function summarize(text, maxLen = 500) {
128
+ const trimmed = text.trim();
129
+ if (!trimmed)
130
+ return "";
131
+ const collapsed = trimmed.replace(/\s+/g, " ");
132
+ return collapsed.length > maxLen ? `${collapsed.slice(0, maxLen - 1)}…` : collapsed;
133
+ }
134
+ /**
135
+ * Maps a finished (or forcibly ended) Claude Code adapter run onto the
136
+ * dispatcher's complete() contract (implementation-plan.md WP-07: "collect
137
+ * final report + PR URL; map exit conditions to complete/fail"). Priority
138
+ * order, highest first: lease loss > timeout > process crash > non-zero
139
+ * exit > clean exit.
140
+ */
141
+ function mapExitCondition(result) {
142
+ const prUrl = extractPrUrl(result.stdout) ?? extractPrUrl(result.stderr);
143
+ const evidenceUrls = prUrl ? [prUrl] : [];
144
+ if (result.killedByLeaseLoss) {
145
+ return {
146
+ leaseOutcome: "failed",
147
+ exitStatus: "killed",
148
+ reportSummary: "Session killed: lease was lost or expired mid-run (heartbeat renewal failed).",
149
+ evidenceUrls,
150
+ };
151
+ }
152
+ if (result.timedOut) {
153
+ return {
154
+ leaseOutcome: "failed",
155
+ exitStatus: "killed",
156
+ reportSummary: "Session killed: exceeded the configured max session duration.",
157
+ evidenceUrls,
158
+ };
159
+ }
160
+ if (result.exitCode === null) {
161
+ return {
162
+ leaseOutcome: "failed",
163
+ exitStatus: "crashed",
164
+ reportSummary: summarize(result.stderr) || "Adapter process crashed before exiting.",
165
+ evidenceUrls,
166
+ };
167
+ }
168
+ if (result.exitCode !== 0) {
169
+ return {
170
+ leaseOutcome: "failed",
171
+ exitStatus: "failed",
172
+ reportSummary: summarize(result.stderr || result.stdout) || `Adapter exited with code ${result.exitCode}.`,
173
+ evidenceUrls,
174
+ };
175
+ }
176
+ // Prefer an adapter-supplied reportText (e.g. codex.cts's extracted final
177
+ // `agent_message`) over parsing stdout ourselves; otherwise prefer the
178
+ // parsed `result` text (readable prose) over the raw `--output-format
179
+ // json` blob so report_summary doesn't end up being a dumped JSON object;
180
+ // falls back to raw stdout when neither is available (see
181
+ // parseClaudeJsonResult's doc comment).
182
+ const parsedJson = parseClaudeJsonResult(result.stdout);
183
+ const reportText = result.reportText ?? parsedJson?.result ?? result.stdout;
184
+ const reportSummary = summarize(reportText) || "Adapter completed with no report text.";
185
+ return {
186
+ leaseOutcome: "completed",
187
+ exitStatus: "completed",
188
+ reportSummary,
189
+ evidenceUrls,
190
+ };
191
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MachineHeartbeatLoop = void 0;
4
+ const logger_cjs_1 = require("./logger.cjs");
5
+ const log = (0, logger_cjs_1.createLogger)("heartbeat");
6
+ /**
7
+ * Machine-level heartbeat loop (implementation-plan.md WP-07), separate from
8
+ * the per-lease heartbeat in session.cts: reports this machine's online
9
+ * status, capabilities, and current available capacity regardless of whether
10
+ * any task is claimed right now.
11
+ */
12
+ class MachineHeartbeatLoop {
13
+ api;
14
+ machineId;
15
+ config;
16
+ capacity;
17
+ timer = null;
18
+ constructor(api, machineId, config, capacity) {
19
+ this.api = api;
20
+ this.machineId = machineId;
21
+ this.config = config;
22
+ this.capacity = capacity;
23
+ }
24
+ start() {
25
+ if (this.timer)
26
+ return;
27
+ void this.tick();
28
+ this.timer = setInterval(() => void this.tick(), this.config.machineHeartbeatIntervalMs);
29
+ }
30
+ stop() {
31
+ if (this.timer)
32
+ clearInterval(this.timer);
33
+ this.timer = null;
34
+ }
35
+ async tick() {
36
+ try {
37
+ await this.api.machineHeartbeat(this.machineId, {
38
+ available_capacity: this.capacity.available(),
39
+ capabilities: this.config.capabilities,
40
+ });
41
+ }
42
+ catch (err) {
43
+ log.warn(`machine heartbeat failed: ${String(err)}`);
44
+ }
45
+ }
46
+ }
47
+ exports.MachineHeartbeatLoop = MachineHeartbeatLoop;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLogger = createLogger;
4
+ /** Minimal structured console logger; swap for something richer without touching call sites. */
5
+ function createLogger(scope) {
6
+ const emit = (level, msg) => {
7
+ const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] [${scope}] ${msg}`;
8
+ if (level === "error")
9
+ console.error(line);
10
+ else if (level === "warn")
11
+ console.warn(line);
12
+ else
13
+ console.log(line);
14
+ };
15
+ return {
16
+ info: (msg) => emit("info", msg),
17
+ warn: (msg) => emit("warn", msg),
18
+ error: (msg) => emit("error", msg),
19
+ };
20
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.saveMachineIdentity = saveMachineIdentity;
7
+ exports.loadMachineIdentity = loadMachineIdentity;
8
+ exports.resolveMachineIdentity = resolveMachineIdentity;
9
+ const node_fs_1 = require("node:fs");
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ function storePath(configDir) {
12
+ return node_path_1.default.join(configDir, "machine.json");
13
+ }
14
+ /** Persists the machine's long-lived auth token to `<configDir>/machine.json`, mode 0600. */
15
+ async function saveMachineIdentity(configDir, identity) {
16
+ await node_fs_1.promises.mkdir(configDir, { recursive: true, mode: 0o700 });
17
+ const file = storePath(configDir);
18
+ await node_fs_1.promises.writeFile(file, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
19
+ await node_fs_1.promises.chmod(file, 0o600);
20
+ }
21
+ async function loadMachineIdentity(configDir) {
22
+ try {
23
+ const raw = await node_fs_1.promises.readFile(storePath(configDir), "utf8");
24
+ return JSON.parse(raw);
25
+ }
26
+ catch (err) {
27
+ if (err.code === "ENOENT")
28
+ return null;
29
+ throw err;
30
+ }
31
+ }
32
+ /**
33
+ * Resolves machine identity for the running daemon: env vars win (twelve-factor
34
+ * deployments — systemd EnvironmentFile, container secrets), falling back to
35
+ * the on-disk file written by `navarch-runtime register`.
36
+ *
37
+ * Note: this is the *machine's own* long-lived credential (analogous to an SSH
38
+ * host key), not a task secret from the broker — the "never write secrets to
39
+ * disk outside the sandbox" rule (implementation-plan.md WP-07) is scoped to
40
+ * per-task broker-issued secrets, handled entirely in session.cts/sandbox.cts.
41
+ */
42
+ async function resolveMachineIdentity(configDir, apiBaseFallback) {
43
+ const envToken = process.env.FLOTILLA_MACHINE_TOKEN;
44
+ const envId = process.env.FLOTILLA_MACHINE_ID;
45
+ if (envToken && envId) {
46
+ return {
47
+ machine_id: envId,
48
+ token: envToken,
49
+ name: process.env.FLOTILLA_MACHINE_NAME ?? envId,
50
+ api_base: process.env.FLOTILLA_API_BASE ?? apiBaseFallback,
51
+ };
52
+ }
53
+ const stored = await loadMachineIdentity(configDir);
54
+ if (stored)
55
+ return stored;
56
+ throw new Error("No machine identity found. Run `navarch-runtime register` first, or set " +
57
+ "FLOTILLA_MACHINE_TOKEN + FLOTILLA_MACHINE_ID for a twelve-factor deployment.");
58
+ }
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ // Builds the per-session `--mcp-config` JSON pointed at the platform MCP
3
+ // server (app/api/mcp/route.ts), per implementation-plan.md WP-07: "Claude
4
+ // Code adapter: headless `claude -p` ... with `--mcp-config` pointing at the
5
+ // platform MCP server".
6
+ //
7
+ // Previously `mcpConfigPath` was just a static, operator-supplied path
8
+ // (config.cts's `FLOTILLA_MCP_CONFIG_PATH`) with nothing that ever wrote a
9
+ // real config file -- there was no way for a session to actually reach the
10
+ // platform MCP server with the auth it needs. This module is the fix: one
11
+ // config, generated fresh per session, carrying the machine's bearer token
12
+ // and this session's lease id (the same two-layer auth app/api/mcp/route.ts
13
+ // requires -- see lib/flotilla/mcp/context.ts).
14
+ //
15
+ // Pure and side-effect free (no filesystem access) so it's trivially unit
16
+ // testable; session.cts is the only caller that writes the result to disk.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.FLOTILLA_LEASE_HEADER = void 0;
19
+ exports.buildNavarchMcpConfig = buildNavarchMcpConfig;
20
+ /** The header app/api/mcp/route.ts's lib/flotilla/mcp/context.ts reads to identify which lease is calling. */
21
+ exports.FLOTILLA_LEASE_HEADER = "X-Navarch-Lease-Id";
22
+ /**
23
+ * Builds the `.mcp.json`-shaped config object Claude Code's `--mcp-config`
24
+ * flag expects: a remote "http" (streamable HTTP) server entry with the
25
+ * bearer token and lease id as request headers.
26
+ */
27
+ function buildNavarchMcpConfig(opts) {
28
+ const serverName = opts.serverName ?? "flotilla";
29
+ return {
30
+ mcpServers: {
31
+ [serverName]: {
32
+ type: "http",
33
+ url: `${opts.apiBase.replace(/\/+$/, "")}/api/mcp`,
34
+ headers: {
35
+ Authorization: `Bearer ${opts.machineToken}`,
36
+ [exports.FLOTILLA_LEASE_HEADER]: opts.leaseId,
37
+ },
38
+ },
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderPrompt = renderPrompt;
4
+ /**
5
+ * Renders the four-layer context bundle (project-plan.md §3.8: steering docs,
6
+ * task itself, task-type playbook, depends_on reports) into the single prompt
7
+ * string handed to `claude -p`. Also written verbatim to prompt.md in the
8
+ * session workspace (session.cts) for audit/debugging, per implementation-plan.md
9
+ * WP-07 "write prompt file".
10
+ */
11
+ function renderPrompt(task, bundle) {
12
+ const sections = [];
13
+ sections.push(`# Task: ${task.summary}`);
14
+ sections.push(`Type: ${task.task_type} | Repo: ${task.repo} | Environment: ${task.environment_scope}`);
15
+ if (task.github_issue_url) {
16
+ sections.push(`GitHub issue: ${task.github_issue_url}`);
17
+ }
18
+ if (bundle.steering_docs.length > 0) {
19
+ sections.push("## Steering documents");
20
+ for (const doc of bundle.steering_docs) {
21
+ sections.push(`### ${doc.path}\n\n${doc.content}`);
22
+ }
23
+ }
24
+ if (bundle.depends_on_reports.length > 0) {
25
+ sections.push("## Prior task reports (depends_on)");
26
+ for (const report of bundle.depends_on_reports) {
27
+ sections.push(`- ${report.task_id}: ${report.report_summary ?? report.summary}`);
28
+ }
29
+ }
30
+ sections.push("## Playbook");
31
+ sections.push(bundle.playbook);
32
+ return sections.join("\n\n");
33
+ }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ // Log/transcript redaction (project-plan.md §3.7: "session logs are
3
+ // redaction-filtered against known secret values before storage"; §3.9 exit
4
+ // step 5; implementation-plan.md WP-04 "registry of issued values per
5
+ // session; scrub from report_summary, transcripts, and event payloads before
6
+ // persistence" — WP-07 owns the runtime-side half of that contract).
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.SecretRegistry = void 0;
9
+ exports.redactText = redactText;
10
+ const SECRET_PATTERNS = [
11
+ /gh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub PAT / OAuth / user-to-server / refresh tokens
12
+ /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
13
+ /sk-[A-Za-z0-9-]{20,}/g, // generic vendor secret-key style token
14
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
15
+ ];
16
+ const REDACTED = "[REDACTED]";
17
+ /**
18
+ * Registry of secret values actually issued to a session, populated as soon
19
+ * as the broker returns them. Kept separate from redactText() so callers can
20
+ * accumulate values from multiple broker calls before a single redaction
21
+ * pass over the transcript.
22
+ */
23
+ class SecretRegistry {
24
+ values = new Set();
25
+ register(value) {
26
+ if (value && value.length >= 4)
27
+ this.values.add(value);
28
+ }
29
+ registerAll(values) {
30
+ const list = Array.isArray(values) ? values : Object.values(values);
31
+ for (const v of list)
32
+ this.register(v);
33
+ }
34
+ list() {
35
+ return [...this.values];
36
+ }
37
+ }
38
+ exports.SecretRegistry = SecretRegistry;
39
+ /**
40
+ * Scrubs known-issued secret values (exact match) and common secret-shaped
41
+ * patterns (defense in depth for values the registry didn't see, e.g. one an
42
+ * agent typed into a comment) from text before it is persisted anywhere:
43
+ * report_summary, transcripts, event payloads.
44
+ */
45
+ function redactText(text, knownSecrets = []) {
46
+ let result = text;
47
+ for (const value of knownSecrets) {
48
+ if (!value)
49
+ continue;
50
+ result = splitJoin(result, value, REDACTED);
51
+ }
52
+ for (const pattern of SECRET_PATTERNS) {
53
+ result = result.replace(pattern, REDACTED);
54
+ }
55
+ return result;
56
+ }
57
+ function splitJoin(haystack, needle, replacement) {
58
+ if (!needle)
59
+ return haystack;
60
+ return haystack.split(needle).join(replacement);
61
+ }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DockerSandbox = exports.nodeCommandRunner = exports.SandboxUnavailableError = void 0;
7
+ exports.isDockerAvailable = isDockerAvailable;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_fs_1 = require("node:fs");
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ class SandboxUnavailableError extends Error {
12
+ }
13
+ exports.SandboxUnavailableError = SandboxUnavailableError;
14
+ /** Real process-spawning CommandRunner. Tests inject a fake one instead — see sandbox.test.cts. */
15
+ exports.nodeCommandRunner = {
16
+ run(cmd, args, opts = {}) {
17
+ return new Promise((resolve, reject) => {
18
+ const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd });
19
+ let stdout = "";
20
+ let stderr = "";
21
+ let settled = false;
22
+ const timer = opts.timeoutMs
23
+ ? setTimeout(() => {
24
+ child.kill("SIGKILL");
25
+ }, opts.timeoutMs)
26
+ : null;
27
+ child.stdout?.on("data", (d) => {
28
+ stdout += d.toString();
29
+ });
30
+ child.stderr?.on("data", (d) => {
31
+ stderr += d.toString();
32
+ });
33
+ child.on("error", (err) => {
34
+ if (settled)
35
+ return;
36
+ settled = true;
37
+ if (timer)
38
+ clearTimeout(timer);
39
+ reject(err);
40
+ });
41
+ child.on("close", (code) => {
42
+ if (settled)
43
+ return;
44
+ settled = true;
45
+ if (timer)
46
+ clearTimeout(timer);
47
+ resolve({ code: code ?? -1, stdout, stderr });
48
+ });
49
+ if (opts.input !== undefined) {
50
+ child.stdin?.write(opts.input);
51
+ }
52
+ child.stdin?.end();
53
+ });
54
+ },
55
+ };
56
+ /**
57
+ * Whether Docker is usable on this machine. Degrades gracefully — callers use
58
+ * this to fall back to a clear "sandbox unavailable" failure instead of
59
+ * crashing when Docker isn't installed (needs-live-verification path per
60
+ * implementation-plan.md WP-07 test requirements).
61
+ */
62
+ async function isDockerAvailable(runner = exports.nodeCommandRunner) {
63
+ try {
64
+ const result = await runner.run("docker", ["info"], { timeoutMs: 10_000 });
65
+ return result.code === 0;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function containerName(sessionId) {
72
+ return `flotilla-${sessionId}`;
73
+ }
74
+ /**
75
+ * One Docker container per session (implementation-plan.md WP-07 "Sandbox:
76
+ * Docker container per session (rootless; repo clone + env vars; wiped on
77
+ * exit)"): rootless-leaning flags (--cap-drop=ALL, --security-opt=no-new-
78
+ * privileges) and tmpfs /tmp + /run so injected secrets never touch a
79
+ * persistent disk, mirroring scripts/dcc-agent.mjs's sandbox.create pattern.
80
+ *
81
+ * Secret hygiene: injectEnv() writes KEY=value lines to a tmpfs-backed file
82
+ * *inside* the container over stdin (never a `docker run -e` flag, which
83
+ * would be visible via `docker inspect`, and never a host-side file).
84
+ * cloneRepo() then references the env var by name in a git credential
85
+ * helper, so the literal secret value never appears in any argv the host's
86
+ * `ps` can see and is never written to the container's persistent layer
87
+ * (tmpfs only) — it disappears with the container on wipe().
88
+ */
89
+ class DockerSandbox {
90
+ name;
91
+ runner;
92
+ workDir;
93
+ image;
94
+ constructor(opts) {
95
+ this.name = containerName(opts.sessionId);
96
+ this.runner = opts.runner ?? exports.nodeCommandRunner;
97
+ this.workDir = node_path_1.default.join(opts.workspaceRoot, opts.sessionId);
98
+ this.image = opts.image;
99
+ }
100
+ async create() {
101
+ await node_fs_1.promises.mkdir(this.workDir, { recursive: true });
102
+ const result = await this.runner.run("docker", [
103
+ "run",
104
+ "-d",
105
+ "--name",
106
+ this.name,
107
+ "--cap-drop=ALL",
108
+ "--security-opt=no-new-privileges",
109
+ "--tmpfs",
110
+ "/tmp",
111
+ "--tmpfs",
112
+ "/run",
113
+ "-v",
114
+ `${this.workDir}:/workspace`,
115
+ "-w",
116
+ "/workspace",
117
+ this.image,
118
+ "tail",
119
+ "-f",
120
+ "/dev/null",
121
+ ]);
122
+ if (result.code !== 0) {
123
+ throw new SandboxUnavailableError(`docker run failed: ${result.stderr || result.stdout}`);
124
+ }
125
+ }
126
+ /**
127
+ * Writes KEY=value env lines into a tmpfs-backed file inside the container
128
+ * via stdin. Callers source it (`exec()` does this automatically) before
129
+ * running anything that needs the values.
130
+ */
131
+ async injectEnv(env) {
132
+ const lines = Object.entries(env)
133
+ .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
134
+ .join("\n");
135
+ const result = await this.runner.run("docker", ["exec", "-i", this.name, "sh", "-c", "cat > /tmp/session.env"], { input: lines });
136
+ if (result.code !== 0) {
137
+ throw new SandboxUnavailableError(`failed to inject env: ${result.stderr || result.stdout}`);
138
+ }
139
+ }
140
+ /** Runs a shell command inside the container, sourcing any injected env first. */
141
+ async exec(command, opts = {}) {
142
+ return this.runner.run("docker", ["exec", this.name, "sh", "-c", `[ -f /tmp/session.env ] && . /tmp/session.env; ${command}`], { timeoutMs: opts.timeoutMs });
143
+ }
144
+ /**
145
+ * Clones `owner/repo` into /workspace/repo. When `hasCredential` is true,
146
+ * assumes GITHUB_TOKEN was already injected via injectEnv() and wires a
147
+ * git credential helper that references `$GITHUB_TOKEN` — the literal
148
+ * token value is never part of the argv string itself.
149
+ */
150
+ async cloneRepo(repo, hasCredential) {
151
+ const url = `https://github.com/${repo}.git`;
152
+ const command = hasCredential
153
+ ? `git -c credential.helper='!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f' clone --depth 1 ${shellQuote(url)} repo`
154
+ : `git clone --depth 1 ${shellQuote(url)} repo`;
155
+ return this.exec(command);
156
+ }
157
+ /** Force-removes the container and the host-side workspace mount. Best-effort: never throws. */
158
+ async wipe() {
159
+ await this.runner.run("docker", ["rm", "-f", this.name]).catch(() => undefined);
160
+ await node_fs_1.promises.rm(this.workDir, { recursive: true, force: true }).catch(() => undefined);
161
+ }
162
+ }
163
+ exports.DockerSandbox = DockerSandbox;
164
+ function shellQuote(value) {
165
+ return `'${value.replace(/'/g, `'\\''`)}'`;
166
+ }