@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.
- package/README.md +351 -0
- package/bin/navarch.cjs +12 -0
- package/dist/adapter.cjs +22 -0
- package/dist/adapters/claude.cjs +135 -0
- package/dist/adapters/codex.cjs +167 -0
- package/dist/adapters/index.cjs +32 -0
- package/dist/adapters/types.cjs +2 -0
- package/dist/api.cjs +140 -0
- package/dist/capacity.cjs +41 -0
- package/dist/claim-loop.cjs +64 -0
- package/dist/cli.cjs +203 -0
- package/dist/config.cjs +55 -0
- package/dist/exit-conditions.cjs +191 -0
- package/dist/heartbeat-loop.cjs +47 -0
- package/dist/logger.cjs +20 -0
- package/dist/machine-store.cjs +58 -0
- package/dist/mcp-config.cjs +41 -0
- package/dist/prompt.cjs +33 -0
- package/dist/redact.cjs +61 -0
- package/dist/sandbox.cjs +166 -0
- package/dist/session.cjs +196 -0
- package/dist/types.cjs +19 -0
- package/dist/upload.cjs +19 -0
- package/package.json +50 -0
package/dist/config.cjs
ADDED
|
@@ -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;
|
package/dist/logger.cjs
ADDED
|
@@ -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
|
+
}
|
package/dist/prompt.cjs
ADDED
|
@@ -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
|
+
}
|
package/dist/redact.cjs
ADDED
|
@@ -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
|
+
}
|
package/dist/sandbox.cjs
ADDED
|
@@ -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
|
+
}
|