@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,196 @@
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.runSession = runSession;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const node_fs_1 = require("node:fs");
9
+ const sandbox_cjs_1 = require("./sandbox.cjs");
10
+ const index_cjs_1 = require("./adapters/index.cjs");
11
+ const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
12
+ const redact_cjs_1 = require("./redact.cjs");
13
+ const upload_cjs_1 = require("./upload.cjs");
14
+ const prompt_cjs_1 = require("./prompt.cjs");
15
+ const mcp_config_cjs_1 = require("./mcp-config.cjs");
16
+ const logger_cjs_1 = require("./logger.cjs");
17
+ /** Filename the generated platform MCP config is written under, inside the session's workDir (host-side; also /workspace inside the Docker sandbox -- see sandbox.cts's bind mount). */
18
+ const MCP_CONFIG_FILENAME = "mcp-config.json";
19
+ const log = (0, logger_cjs_1.createLogger)("session");
20
+ /**
21
+ * Runs one claimed task end to end (implementation-plan.md WP-07):
22
+ * 1. write the prompt file
23
+ * 2. fetch secrets from the broker once, at session start
24
+ * 3. stand up a Docker sandbox, clone the repo, inject env vars
25
+ * 4. run the configured agent adapter (Claude Code or Codex, per
26
+ * FLOTILLA_AGENT — adapters/index.cts#selectAdapter), heartbeating the
27
+ * lease throughout
28
+ * 5. redact + upload the transcript, map the exit condition, complete the
29
+ * lease (recording which agent_type ran it)
30
+ * 6. wipe the sandbox unconditionally
31
+ *
32
+ * NEEDS LIVE VERIFICATION: the full path requires a real Docker daemon, a
33
+ * real `claude` (or `codex`) binary, and a live control-plane API — none of
34
+ * which are available in this offline build environment. Unit tests exercise
35
+ * each collaborator (api.cts, sandbox.cts, exit-conditions.cts, redact.cts,
36
+ * adapters/*.cts) in isolation instead; see runtime/README.md.
37
+ */
38
+ async function runSession(deps, claimed, sessionId) {
39
+ const { api, config } = deps;
40
+ const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
41
+ // The session's identity is the pre-allocated session id sent at claim time
42
+ // (recorded on the lease by the dispatcher). Lease-scoped API calls
43
+ // (heartbeat/complete/issue/transcript) still key on leaseId.
44
+ const workDir = node_path_1.default.join(config.workspaceRoot, sessionId);
45
+ await node_fs_1.promises.mkdir(workDir, { recursive: true });
46
+ const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
47
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
48
+ // Secrets: fetched once, held only in memory (registry + env map below),
49
+ // never written to disk on the host. They only ever reach disk inside the
50
+ // sandbox's tmpfs (sandbox.cts injectEnv), which is wiped with the container.
51
+ const registry = new redact_cjs_1.SecretRegistry();
52
+ let secrets = {};
53
+ const secretNames = bundle.secret_manifest.map((s) => s.name);
54
+ if (secretNames.length > 0) {
55
+ const issued = await api.issueSecrets({ lease_id: leaseId, secret_names: secretNames });
56
+ secrets = issued.secrets;
57
+ registry.registerAll(secrets);
58
+ }
59
+ const abortController = new AbortController();
60
+ let leaseLost = false;
61
+ const heartbeatTimer = setInterval(() => {
62
+ api.heartbeatLease(leaseId).catch((err) => {
63
+ log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
64
+ leaseLost = true;
65
+ abortController.abort();
66
+ });
67
+ }, config.leaseHeartbeatIntervalMs);
68
+ const dockerAvailable = config.sandboxMode === "docker" && (await (0, sandbox_cjs_1.isDockerAvailable)());
69
+ if (config.sandboxMode === "docker" && !dockerAvailable) {
70
+ clearInterval(heartbeatTimer);
71
+ log.warn("Docker requested but not available on this machine — failing the task instead of crashing.");
72
+ await api
73
+ .completeLease(leaseId, {
74
+ status: "failed",
75
+ report: "Docker sandbox unavailable on this machine.",
76
+ evidence_urls: [],
77
+ cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
78
+ exit_status: "crashed",
79
+ agent_type: config.agentType,
80
+ })
81
+ .catch((err) => log.warn(`complete() after docker-unavailable also failed: ${String(err)}`));
82
+ return;
83
+ }
84
+ const sandbox = dockerAvailable
85
+ ? new sandbox_cjs_1.DockerSandbox({ sessionId, workspaceRoot: config.workspaceRoot, image: config.dockerImage })
86
+ : null;
87
+ // Platform MCP config (implementation-plan.md WP-07: "--mcp-config
88
+ // pointing at the platform MCP server"): generated fresh per session,
89
+ // carrying this machine's bearer token and this session's lease id (the
90
+ // auth app/api/mcp/route.ts requires -- see lib/flotilla/mcp/context.ts),
91
+ // unless the operator pinned a static override via FLOTILLA_MCP_CONFIG_PATH
92
+ // (e.g. pointing at a fake MCP server in local testing). Written to
93
+ // workDir (host path) so it also lands at /workspace/mcp-config.json
94
+ // inside the Docker sandbox (sandbox.cts binds workDir at /workspace) --
95
+ // the selected adapter (adapters/claude.cts or adapters/codex.cts) needs a
96
+ // path valid in whichever environment it actually runs.
97
+ let mcpConfigPath = config.mcpConfigPath;
98
+ if (!mcpConfigPath) {
99
+ const mcpConfig = (0, mcp_config_cjs_1.buildNavarchMcpConfig)({
100
+ apiBase: api.getBaseUrl(),
101
+ machineToken: api.getToken() ?? "",
102
+ leaseId,
103
+ });
104
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, MCP_CONFIG_FILENAME), JSON.stringify(mcpConfig, null, 2), "utf8");
105
+ mcpConfigPath = sandbox ? `/workspace/${MCP_CONFIG_FILENAME}` : node_path_1.default.join(workDir, MCP_CONFIG_FILENAME);
106
+ }
107
+ try {
108
+ if (sandbox) {
109
+ await sandbox.create();
110
+ await sandbox.injectEnv(toEnvMap(secrets));
111
+ await sandbox.cloneRepo(task.repo, Boolean(secrets["github-pat"]));
112
+ }
113
+ // Picks the Claude Code or Codex adapter per FLOTILLA_AGENT
114
+ // (config.cts's `agentType`) — see adapters/index.cts#selectAdapter.
115
+ // Both adapters implement the same AgentAdapter.run() shape
116
+ // (adapters/types.cts), so nothing else in this function branches on
117
+ // which agent is running.
118
+ const adapter = (0, index_cjs_1.selectAdapter)(config.agentType);
119
+ const bin = config.agentType === "codex" ? config.codexBin : config.claudeBin;
120
+ const extraArgs = config.agentType === "codex" ? config.codexExtraArgs : config.claudeExtraArgs;
121
+ const result = await adapter.run({
122
+ prompt: promptText,
123
+ mcpConfigPath,
124
+ bin,
125
+ extraArgs,
126
+ timeoutMs: config.sessionTimeoutMs,
127
+ env: secrets,
128
+ cwd: sandbox ? undefined : workDir,
129
+ dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
130
+ signal: abortController.signal,
131
+ });
132
+ const mapping = (0, exit_conditions_cjs_1.mapExitCondition)({ ...result, killedByLeaseLoss: leaseLost || result.killedByLeaseLoss });
133
+ const knownSecrets = registry.list();
134
+ const transcript = [
135
+ "# stdout",
136
+ (0, redact_cjs_1.redactText)(result.stdout, knownSecrets),
137
+ "",
138
+ "# stderr",
139
+ (0, redact_cjs_1.redactText)(result.stderr, knownSecrets),
140
+ "",
141
+ ].join("\n");
142
+ let transcriptUrl;
143
+ try {
144
+ const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
145
+ await (0, upload_cjs_1.uploadTranscript)(upload_url, transcript);
146
+ transcriptUrl = public_url;
147
+ }
148
+ catch (err) {
149
+ log.warn(`transcript upload failed for ${leaseId}: ${String(err)}`);
150
+ }
151
+ await api.completeLease(leaseId, {
152
+ status: mapping.leaseOutcome,
153
+ report: (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets),
154
+ evidence_urls: mapping.evidenceUrls,
155
+ cost: {
156
+ tokens_in: result.tokensIn ?? 0,
157
+ tokens_out: result.tokensOut ?? 0,
158
+ cost_usd: result.costUsd ?? 0,
159
+ },
160
+ transcript_url: transcriptUrl,
161
+ exit_status: mapping.exitStatus,
162
+ agent_type: config.agentType,
163
+ });
164
+ }
165
+ catch (err) {
166
+ log.error(`session ${leaseId} threw before completing: ${String(err)}`);
167
+ await api
168
+ .completeLease(leaseId, {
169
+ status: "failed",
170
+ report: (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, registry.list()),
171
+ evidence_urls: [],
172
+ cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
173
+ exit_status: "crashed",
174
+ agent_type: config.agentType,
175
+ })
176
+ .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
177
+ }
178
+ finally {
179
+ clearInterval(heartbeatTimer);
180
+ secrets = {};
181
+ if (sandbox)
182
+ await sandbox.wipe();
183
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
184
+ }
185
+ }
186
+ /** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
187
+ function toEnvMap(secrets) {
188
+ const out = {};
189
+ for (const [name, value] of Object.entries(secrets)) {
190
+ out[name.toUpperCase().replace(/[^A-Z0-9_]/g, "_")] = value;
191
+ }
192
+ if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
193
+ out.GITHUB_TOKEN = secrets["github-pat"];
194
+ }
195
+ return out;
196
+ }
package/dist/types.cjs ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ // Navarch runtime — shared wire types.
3
+ //
4
+ // These interfaces mirror the JSON shapes described in:
5
+ // - docs/flotilla/schema-design.md (table columns => API field names, esp. §4, §5, §7)
6
+ // - docs/flotilla/implementation-plan.md (WP-07 behavioral contract)
7
+ // - docs/agent-platform-project-plan.md (§3.8 dispatch/§3.9 adapter contract)
8
+ //
9
+ // The control-plane API is being built in parallel (WP-01/WP-04/WP-05) in
10
+ // other worktrees this agent cannot see. Fields/endpoints marked "ASSUMED"
11
+ // are not pinned down by an explicit route contract in the docs as written
12
+ // and were inferred from the closest analogous shape; see the WP-07 report
13
+ // for the full list of assumptions to confirm once those WPs land. Everything
14
+ // else is quoted close to verbatim from schema-design.md.
15
+ //
16
+ // runtime/src/api.cts is the ONLY place that turns these types into HTTP
17
+ // calls, so reconciling an assumption against the real contract is a
18
+ // same-file edit, not a rewrite.
19
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.uploadTranscript = uploadTranscript;
4
+ /**
5
+ * PUTs an already-redacted transcript to the signed Supabase Storage URL the
6
+ * control plane hands back (schema-design.md §4 sessions.transcript_url;
7
+ * implementation-plan.md WP-07). Kept as its own module so the storage
8
+ * mechanism is a one-function swap if it ever changes.
9
+ */
10
+ async function uploadTranscript(uploadUrl, content, fetchImpl = fetch) {
11
+ const response = await fetchImpl(uploadUrl, {
12
+ method: "PUT",
13
+ headers: { "content-type": "text/plain; charset=utf-8" },
14
+ body: content,
15
+ });
16
+ if (!response.ok) {
17
+ throw new Error(`Transcript upload failed with ${response.status}`);
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@sagentlab/navarch-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them in a Docker sandbox via the Claude Code or Codex adapter, and reports results back.",
5
+ "type": "commonjs",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sagentlab/navarch.git",
10
+ "directory": "runtime"
11
+ },
12
+ "homepage": "https://www.sagentlab.com/navarch",
13
+ "keywords": [
14
+ "navarch",
15
+ "ai-agent",
16
+ "claude-code",
17
+ "codex",
18
+ "task-runner"
19
+ ],
20
+ "bin": {
21
+ "navarch-runtime": "./bin/navarch.cjs"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "bin",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "prepublishOnly": "npm run build && npm test",
40
+ "start": "node dist/cli.cjs start",
41
+ "register": "node dist/cli.cjs register",
42
+ "connect": "node dist/cli.cjs connect",
43
+ "doctor": "node dist/cli.cjs doctor"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^20.14.0",
47
+ "typescript": "^5.7.2",
48
+ "vitest": "^2.1.8"
49
+ }
50
+ }