@sagentlab/navarch-runtime 0.1.1 → 0.1.2

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 CHANGED
@@ -7,7 +7,8 @@ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
7
7
  machine.
8
8
 
9
9
  The agent CLI a session runs is either **Claude Code** or **OpenAI Codex**,
10
- selected per machine via `NAVARCH_AGENT` (default `claude-code`) — see
10
+ selected by the local machine via `--agent` or `NAVARCH_AGENT` (default
11
+ `claude-code`) — see
11
12
  "Choosing an agent (Claude Code vs. Codex)" below.
12
13
 
13
14
  See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
@@ -20,7 +21,7 @@ the API contract.
20
21
 
21
22
  ```sh
22
23
  git clone <this repo> && cd sagentlab/runtime
23
- ./install.sh # checks node/docker, npm install, npm run build
24
+ ./install.sh # checks node, npm install, npm run build
24
25
  export NAVARCH_API_BASE=https://navarch.example.com
25
26
  node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
26
27
  node bin/navarch.cjs start
@@ -48,8 +49,8 @@ availability, and registration status without starting the daemon.
48
49
 
49
50
  `connect` is the project-scoped sibling of `register` — "Connect an agent to
50
51
  a project" (self-hosted-runner style, like a GitHub Actions self-hosted
51
- runner token): a project **owner** mints a short-lived (60 minute),
52
- single-use token from the platform UI (the project settings page's "Connect
52
+ runner token): a project **owner** mints a single-use token from the platform
53
+ UI (the project settings page's "Connect
53
54
  an agent" panel, or the onboarding wizard's Agent step),
54
55
  `POST /api/projects/:id/enrollment-tokens`, and pastes you the ready-to-run
55
56
  command. Unlike `register`, no `NAVARCH_ENROLLMENT_SECRET` or
@@ -59,7 +60,7 @@ project only (it will never be dispatched work from any other project).
59
60
 
60
61
  ```sh
61
62
  npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
62
- --name my-agent-1 --api-base https://navarch.example.com
63
+ --name my-agent-1 --agent codex --api-base https://navarch.example.com
63
64
  node bin/navarch.cjs start
64
65
  ```
65
66
 
@@ -72,9 +73,9 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
72
73
 
73
74
  | Command | Purpose |
74
75
  |---|---|
75
- | `register --token <t> --name <n> [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]` | Registers this machine (global enrollment secret), prints the token once. |
76
- | `connect --token <t> --name <n> [--project <id>] [--capabilities a,b] [--max-sessions N] [--api-base url]` | Connects this machine to exactly one project (project-scoped enrollment token), prints the token once. |
77
- | `start` | Runs the daemon: machine heartbeat loop + claim loop, `NAVARCH_MAX_SESSIONS` concurrent task sessions. |
76
+ | `register --token <t> --name <n> [--agent claude-code\|codex] []` | Registers this machine, saves its local agent choice, and prints the token once. |
77
+ | `connect --token <t> --name <n> [--agent claude-code\|codex] [--project <id>] []` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
78
+ | `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
78
79
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
79
80
 
80
81
  ## Configuration (`NAVARCH_*` env vars)
@@ -87,17 +88,17 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
87
88
  | `NAVARCH_ENROLLMENT_TOKEN` | — | Alternative to `register --token` / `connect --token`. |
88
89
  | `NAVARCH_PROJECT_ID` | — | Alternative to `connect --project`. |
89
90
  | `NAVARCH_CONFIG_DIR` | `~/.navarch` | Where `machine.json` lives. |
90
- | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Per-session working directories (host side; mounted into each container). |
91
- | `NAVARCH_MAX_SESSIONS` | `3` | Capacity cap — see `src/capacity.cts`. |
92
- | `NAVARCH_CAPABILITIES` | `docker-sandbox,shell` | Comma list reported at heartbeat/claim time. |
91
+ | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Persistent bare repo caches plus isolated per-session worktrees. |
92
+ | `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
93
+ | `NAVARCH_CAPABILITIES` | `shell` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. |
93
94
  | `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
94
95
  | `NAVARCH_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
95
96
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
96
97
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
97
98
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
98
- | `NAVARCH_SANDBOX_MODE` | `docker` | `docker` or `host` (host mode skips the container dev/debug only, no isolation). |
99
+ | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
99
100
  | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
100
- | `NAVARCH_AGENT` | `claude-code` | Which agent CLI runs sessions: `claude-code` or `codex`. Any other value falls back to `claude-code`. Recorded as `agent_type` on the `complete()` call so the sessions row shows which one ran. |
101
+ | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code` or `codex`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
101
102
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
102
103
  | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
103
104
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
@@ -106,8 +107,10 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
106
107
 
107
108
  ## Choosing an agent (Claude Code vs. Codex)
108
109
 
109
- Each machine runs sessions with exactly one agent CLI, set once via
110
- `NAVARCH_AGENT`:
110
+ Each machine chooses its own agent CLI. Pass `--agent` while connecting to
111
+ persist the choice in local `machine.json`, override it for one daemon start
112
+ with `start --agent`, or set `NAVARCH_AGENT` in the machine's service
113
+ environment:
111
114
 
112
115
  ```sh
113
116
  # Claude Code (default) — requires the `claude` CLI installed and
@@ -120,6 +123,9 @@ export NAVARCH_AGENT=claude-code
120
123
  export NAVARCH_AGENT=codex
121
124
  ```
122
125
 
126
+ Priority is `start --agent` → `NAVARCH_AGENT` → the locally saved choice →
127
+ `claude-code`. The control plane does not choose the adapter.
128
+
123
129
  Both adapters implement the same `AgentAdapter` interface
124
130
  (`src/adapters/types.cts`) and run either directly on the host or via
125
131
  `docker exec` in the session's sandbox container, exactly like the Claude
@@ -149,7 +155,9 @@ cli.cts
149
155
  └─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
150
156
  1. write prompt.md (prompt.cts renders the 4-layer context bundle)
151
157
  2. api.issueSecrets() → held in memory only
152
- 3. DockerSandbox.create/injectEnv/cloneRepo (sandbox.cts)
158
+ 3. fetch the project's bare repository cache and create a
159
+ unique git worktree for this session; optionally mount it
160
+ into Docker when NAVARCH_SANDBOX_MODE=docker
153
161
  4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
154
162
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
155
163
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
@@ -158,7 +166,8 @@ cli.cts
158
166
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
159
167
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
160
168
  6. api.completeLease(), reporting agent_type: config.agentType
161
- 7. sandbox.wipe() unconditionally (finally block)
169
+ 7. sandbox.wipe() when present; remove the session workspace
170
+ unconditionally (finally block)
162
171
  ```
163
172
 
164
173
  `adapter.cts` (top-level) is now a backward-compat re-export of
@@ -47,6 +47,7 @@ class ClaimLoop {
47
47
  const claimed = await this.api.claim({
48
48
  available_capacity: this.capacity.available(),
49
49
  capabilities: this.config.capabilities,
50
+ agent_type: this.config.agentType,
50
51
  session_id: sessionId,
51
52
  });
52
53
  if (!claimed)
package/dist/cli.cjs CHANGED
@@ -29,6 +29,15 @@ function invocation(subcommand) {
29
29
  const prefix = viaNpx ? `npx ${PACKAGE_NAME}` : "navarch-runtime";
30
30
  return `${prefix} ${subcommand}`;
31
31
  }
32
+ function agentFromFlag(flags) {
33
+ const value = flags.agent;
34
+ if (value === undefined)
35
+ return undefined;
36
+ if (!(0, config_cjs_1.isRuntimeAgentType)(value)) {
37
+ throw new Error("--agent must be either 'claude-code' or 'codex'.");
38
+ }
39
+ return value;
40
+ }
32
41
  function parseArgs(argv) {
33
42
  const [command, ...rest] = argv;
34
43
  const flags = {};
@@ -70,6 +79,7 @@ async function registerCommand(flags) {
70
79
  .map((s) => s.trim())
71
80
  .filter(Boolean);
72
81
  const ownerZone = flags["owner-zone"] ?? config.ownerZone;
82
+ const agentType = agentFromFlag(flags) ?? config.agentType;
73
83
  const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
74
84
  const result = await client.registerMachine({
75
85
  enrollment_token: enrollmentToken,
@@ -83,6 +93,7 @@ async function registerCommand(flags) {
83
93
  token: result.token,
84
94
  name,
85
95
  api_base: apiBase,
96
+ agent_type: agentType,
86
97
  });
87
98
  // Printed exactly once. Never logged or echoed again after this point.
88
99
  console.log("Machine registered.");
@@ -93,8 +104,8 @@ async function registerCommand(flags) {
93
104
  /**
94
105
  * `navarch-runtime connect` — "Connect an agent to a project"
95
106
  * (docs/navarch/schema-design.md §7 "Agent connect"; self-hosted-runner
96
- * style). The project-scoped sibling of `register`: redeems a short-lived,
97
- * single-use enrollment token a project owner minted from the platform UI
107
+ * style). The project-scoped sibling of `register`: redeems a single-use
108
+ * enrollment token a project owner minted from the platform UI
98
109
  * (ConnectAgentPanel → POST /api/projects/:id/enrollment-tokens) instead of
99
110
  * the global NAVARCH_ENROLLMENT_SECRET `register` needs. Prints the
100
111
  * machine token exactly once, same discipline as `register`.
@@ -116,6 +127,7 @@ async function connectCommand(flags) {
116
127
  .split(",")
117
128
  .map((s) => s.trim())
118
129
  .filter(Boolean);
130
+ const agentType = agentFromFlag(flags) ?? config.agentType;
119
131
  const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
120
132
  const result = await client.connectMachine({
121
133
  enrollment_token: enrollmentToken,
@@ -129,6 +141,7 @@ async function connectCommand(flags) {
129
141
  token: result.token,
130
142
  name,
131
143
  api_base: apiBase,
144
+ agent_type: agentType,
132
145
  });
133
146
  // Printed exactly once. Never logged or echoed again after this point.
134
147
  console.log("Machine connected.");
@@ -136,16 +149,22 @@ async function connectCommand(flags) {
136
149
  console.log(` token: ${result.token}`);
137
150
  console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("start")}\` to begin serving tasks.`);
138
151
  }
139
- async function startCommand() {
140
- const config = (0, config_cjs_1.loadRuntimeConfig)();
141
- const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
152
+ async function startCommand(flags) {
153
+ const baseConfig = (0, config_cjs_1.loadRuntimeConfig)();
154
+ const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
155
+ // Adapter selection belongs to the local machine: an explicit start flag
156
+ // wins, followed by NAVARCH_AGENT, the choice saved at connect/register
157
+ // time, and finally the backwards-compatible Claude Code default.
158
+ const agentType = agentFromFlag(flags) ??
159
+ (process.env.NAVARCH_AGENT ? baseConfig.agentType : identity.agent_type ?? baseConfig.agentType);
160
+ const config = { ...baseConfig, agentType };
142
161
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
143
162
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
144
163
  const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity);
145
164
  const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId));
146
165
  heartbeat.start();
147
166
  claimLoop.start();
148
- log.info(`navarch-runtime started: machine=${identity.name} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
167
+ log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
149
168
  const shutdown = () => {
150
169
  log.info("shutting down...");
151
170
  heartbeat.stop();
@@ -155,7 +174,7 @@ async function startCommand() {
155
174
  process.on("SIGINT", shutdown);
156
175
  process.on("SIGTERM", shutdown);
157
176
  }
158
- async function doctorCommand() {
177
+ async function doctorCommand(flags) {
159
178
  const config = (0, config_cjs_1.loadRuntimeConfig)();
160
179
  const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
161
180
  console.log(`api_base: ${config.apiBase}`);
@@ -165,23 +184,28 @@ async function doctorCommand() {
165
184
  console.log(`capabilities: ${config.capabilities.join(", ")}`);
166
185
  console.log(`sandbox_mode: ${config.sandboxMode}`);
167
186
  console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
187
+ let identity;
168
188
  try {
169
- const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
170
- console.log(`machine: ${identity.name} (${identity.machine_id})`);
189
+ identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
171
190
  }
172
191
  catch {
173
192
  console.log(`machine: not registered — run \`${invocation("register")}\``);
193
+ return;
174
194
  }
195
+ const agentType = agentFromFlag(flags) ??
196
+ (process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
197
+ console.log(`machine: ${identity.name} (${identity.machine_id})`);
198
+ console.log(`agent: ${agentType}`);
175
199
  }
176
200
  function helpText() {
177
201
  return `navarch-runtime — Navarch machine-side session manager
178
202
 
179
203
  Usage:
180
204
  navarch-runtime register --token <enrollment-token> --name <machine-name> \\
181
- [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
205
+ [--agent claude-code|codex] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
182
206
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
183
- [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
184
- navarch-runtime start
207
+ [--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
208
+ navarch-runtime start [--agent claude-code|codex]
185
209
  navarch-runtime doctor
186
210
 
187
211
  Configuration is via NAVARCH_* environment variables; see runtime/README.md.
@@ -198,10 +222,10 @@ async function main(argv = process.argv.slice(2)) {
198
222
  await connectCommand(flags);
199
223
  break;
200
224
  case "start":
201
- await startCommand();
225
+ await startCommand(flags);
202
226
  break;
203
227
  case "doctor":
204
- await doctorCommand();
228
+ await doctorCommand(flags);
205
229
  break;
206
230
  case "help":
207
231
  case "--help":
package/dist/config.cjs CHANGED
@@ -3,9 +3,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isRuntimeAgentType = isRuntimeAgentType;
6
7
  exports.loadRuntimeConfig = loadRuntimeConfig;
7
8
  const node_path_1 = __importDefault(require("node:path"));
8
9
  const node_os_1 = __importDefault(require("node:os"));
10
+ function isRuntimeAgentType(value) {
11
+ return value === "claude-code" || value === "codex";
12
+ }
9
13
  function envInt(env, name, fallback) {
10
14
  const raw = env[name];
11
15
  if (!raw)
@@ -30,12 +34,17 @@ function envList(env, name, fallback) {
30
34
  function loadRuntimeConfig(env = process.env) {
31
35
  const configDir = env.NAVARCH_CONFIG_DIR ?? node_path_1.default.join(node_os_1.default.homedir(), ".navarch");
32
36
  const leaseHeartbeatIntervalMs = envInt(env, "NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS", 5 * 60 * 1000);
37
+ // Run the agent with the resources already available on its machine by
38
+ // default. Docker isolation is an explicit operator opt-in, not a
39
+ // prerequisite for claiming ordinary shell work.
40
+ const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
41
+ const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell"];
33
42
  return {
34
43
  apiBase: env.NAVARCH_API_BASE ?? "http://localhost:3000",
35
44
  workspaceRoot: env.NAVARCH_WORKSPACE_ROOT ?? node_path_1.default.join(configDir, "sandboxes"),
36
45
  configDir,
37
- maxSessions: envInt(env, "NAVARCH_MAX_SESSIONS", 3),
38
- capabilities: envList(env, "NAVARCH_CAPABILITIES", ["docker-sandbox", "shell"]),
46
+ maxSessions: envInt(env, "NAVARCH_MAX_SESSIONS", 5),
47
+ capabilities: envList(env, "NAVARCH_CAPABILITIES", defaultCapabilities),
39
48
  ownerZone: env.NAVARCH_OWNER_ZONE ?? "sagentlab",
40
49
  pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS", 5000),
41
50
  machineHeartbeatIntervalMs: envInt(env, "NAVARCH_HEARTBEAT_INTERVAL_MS", 60_000),
@@ -43,13 +52,13 @@ function loadRuntimeConfig(env = process.env) {
43
52
  // interval must stay comfortably under that TTL.
44
53
  leaseHeartbeatIntervalMs,
45
54
  sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
46
- agentType: env.NAVARCH_AGENT === "codex" ? "codex" : "claude-code",
55
+ agentType: isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code",
47
56
  claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
48
57
  claudeExtraArgs: envList(env, "NAVARCH_CLAUDE_EXTRA_ARGS", []),
49
58
  codexBin: env.NAVARCH_CODEX_BIN ?? "codex",
50
59
  codexExtraArgs: envList(env, "NAVARCH_CODEX_EXTRA_ARGS", []),
51
60
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
52
- sandboxMode: env.NAVARCH_SANDBOX_MODE === "host" ? "host" : "docker",
61
+ sandboxMode,
53
62
  dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
54
63
  };
55
64
  }
@@ -0,0 +1,142 @@
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.GitWorktree = void 0;
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 repositoryLocks = new Map();
11
+ /**
12
+ * Maintains one bare repository cache per project and checks out each session
13
+ * into its own uniquely named worktree. The cache avoids N full clones while
14
+ * git's worktree metadata keeps concurrent agents from sharing an index or
15
+ * working directory.
16
+ */
17
+ class GitWorktree {
18
+ sessionRoot;
19
+ worktreePath;
20
+ repositoryPath;
21
+ branch;
22
+ runner;
23
+ cloneUrl;
24
+ githubToken;
25
+ constructor(options) {
26
+ const projectKey = safePathSegment(options.projectId);
27
+ const sessionKey = safePathSegment(options.sessionId);
28
+ this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
29
+ this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
30
+ this.repositoryPath = node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
31
+ this.branch = `navarch/${safePathSegment(options.taskId).slice(0, 32)}-${sessionKey.slice(0, 12)}`;
32
+ this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
33
+ this.cloneUrl = options.cloneUrl;
34
+ this.githubToken = options.githubToken;
35
+ }
36
+ async prepare() {
37
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.repositoryPath), { recursive: true });
38
+ await node_fs_1.promises.mkdir(this.sessionRoot, { recursive: true });
39
+ await withRepositoryLock(this.repositoryPath, async () => {
40
+ if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
41
+ await this.runGit(["clone", "--bare", this.cloneUrl, this.repositoryPath], true);
42
+ }
43
+ else {
44
+ const origin = await this.runGit(["--git-dir", this.repositoryPath, "remote", "get-url", "origin"], false);
45
+ if (normalizeCloneUrl(origin.stdout) !== normalizeCloneUrl(this.cloneUrl)) {
46
+ await this.runGit(["--git-dir", this.repositoryPath, "remote", "set-url", "origin", this.cloneUrl], false);
47
+ }
48
+ }
49
+ const remoteHead = await this.runGit(["ls-remote", "--symref", "origin", "HEAD"], true);
50
+ const startRef = parseRemoteHead(remoteHead.stdout) ?? "HEAD";
51
+ await this.runGit([
52
+ "--git-dir",
53
+ this.repositoryPath,
54
+ "fetch",
55
+ "--prune",
56
+ "origin",
57
+ "+refs/heads/*:refs/heads/*",
58
+ ], true);
59
+ await this.runGit([
60
+ "--git-dir",
61
+ this.repositoryPath,
62
+ "worktree",
63
+ "add",
64
+ "-b",
65
+ this.branch,
66
+ this.worktreePath,
67
+ startRef,
68
+ ], false);
69
+ });
70
+ }
71
+ async cleanup() {
72
+ await withRepositoryLock(this.repositoryPath, async () => {
73
+ await this.runner
74
+ .run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", this.worktreePath])
75
+ .catch(() => undefined);
76
+ await this.runner
77
+ .run("git", ["--git-dir", this.repositoryPath, "branch", "-D", this.branch])
78
+ .catch(() => undefined);
79
+ await this.runner
80
+ .run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
81
+ .catch(() => undefined);
82
+ });
83
+ }
84
+ async runGit(args, authenticated) {
85
+ const credentialArgs = authenticated && this.githubToken
86
+ ? [
87
+ "-c",
88
+ 'credential.helper=!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f',
89
+ ]
90
+ : [];
91
+ const result = await this.runner.run("git", [...credentialArgs, ...args], {
92
+ env: this.githubToken
93
+ ? { ...process.env, GITHUB_TOKEN: this.githubToken, GIT_TERMINAL_PROMPT: "0" }
94
+ : { ...process.env, GIT_TERMINAL_PROMPT: "0" },
95
+ });
96
+ if (result.code !== 0) {
97
+ throw new Error(`git ${args[0] ?? "command"} failed: ${result.stderr || result.stdout}`);
98
+ }
99
+ return result;
100
+ }
101
+ }
102
+ exports.GitWorktree = GitWorktree;
103
+ async function withRepositoryLock(key, work) {
104
+ const previous = repositoryLocks.get(key) ?? Promise.resolve();
105
+ let release;
106
+ const current = new Promise((resolve) => {
107
+ release = resolve;
108
+ });
109
+ const queued = previous.then(() => current);
110
+ repositoryLocks.set(key, queued);
111
+ await previous;
112
+ try {
113
+ return await work();
114
+ }
115
+ finally {
116
+ release();
117
+ if (repositoryLocks.get(key) === queued)
118
+ repositoryLocks.delete(key);
119
+ }
120
+ }
121
+ async function pathExists(value) {
122
+ try {
123
+ await node_fs_1.promises.access(value);
124
+ return true;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ function safePathSegment(value) {
131
+ const safe = value.replace(/[^A-Za-z0-9_.-]/g, "-").replace(/^-+|-+$/g, "");
132
+ if (!safe)
133
+ throw new Error("Cannot create a git worktree without a valid project/session identifier.");
134
+ return safe;
135
+ }
136
+ function normalizeCloneUrl(value) {
137
+ return value.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
138
+ }
139
+ function parseRemoteHead(output) {
140
+ const match = output.match(/^ref:\s+(refs\/heads\/[A-Za-z0-9._/-]+)\s+HEAD$/m);
141
+ return match?.[1] ?? null;
142
+ }
@@ -48,6 +48,9 @@ async function resolveMachineIdentity(configDir, apiBaseFallback) {
48
48
  token: envToken,
49
49
  name: process.env.NAVARCH_MACHINE_NAME ?? envId,
50
50
  api_base: process.env.NAVARCH_API_BASE ?? apiBaseFallback,
51
+ agent_type: process.env.NAVARCH_AGENT === "codex" || process.env.NAVARCH_AGENT === "claude-code"
52
+ ? process.env.NAVARCH_AGENT
53
+ : undefined,
51
54
  };
52
55
  }
53
56
  const stored = await loadMachineIdentity(configDir);
package/dist/prompt.cjs CHANGED
@@ -10,13 +10,19 @@ exports.renderPrompt = renderPrompt;
10
10
  */
11
11
  function renderPrompt(task, bundle) {
12
12
  if (bundle.task_context) {
13
- return [bundle.task_context.trim(), bundle.retry_context?.trim()]
13
+ const repositoryContext = bundle.repository
14
+ ? `Repository: ${bundle.repository.full_name} (${bundle.repository.url})\nLocal checkout: this session's isolated git worktree (current working directory).`
15
+ : null;
16
+ return [repositoryContext, bundle.task_context.trim(), bundle.retry_context?.trim()]
14
17
  .filter((section) => Boolean(section))
15
18
  .join("\n\n") + "\n";
16
19
  }
17
20
  const sections = [];
18
21
  sections.push(`# Task: ${task.summary}`);
19
22
  sections.push(`Type: ${task.task_type} | Repo: ${task.repo} | Environment: ${task.environment_scope}`);
23
+ if (bundle.repository) {
24
+ sections.push(`Repository URL: ${bundle.repository.url}\nLocal checkout: this session's isolated git worktree (current working directory).`);
25
+ }
20
26
  if (task.github_issue_url) {
21
27
  sections.push(`GitHub issue: ${task.github_issue_url}`);
22
28
  }
package/dist/sandbox.cjs CHANGED
@@ -15,7 +15,7 @@ exports.SandboxUnavailableError = SandboxUnavailableError;
15
15
  exports.nodeCommandRunner = {
16
16
  run(cmd, args, opts = {}) {
17
17
  return new Promise((resolve, reject) => {
18
- const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd });
18
+ const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env });
19
19
  let stdout = "";
20
20
  let stderr = "";
21
21
  let settled = false;
@@ -91,14 +91,27 @@ class DockerSandbox {
91
91
  runner;
92
92
  workDir;
93
93
  image;
94
+ containerWorkDir;
95
+ sharedGitDir;
94
96
  constructor(opts) {
95
97
  this.name = containerName(opts.sessionId);
96
98
  this.runner = opts.runner ?? exports.nodeCommandRunner;
97
99
  this.workDir = node_path_1.default.join(opts.workspaceRoot, opts.sessionId);
98
100
  this.image = opts.image;
101
+ this.containerWorkDir = opts.containerWorkDir ?? null;
102
+ this.sharedGitDir = opts.sharedGitDir ?? null;
99
103
  }
100
104
  async create() {
101
105
  await node_fs_1.promises.mkdir(this.workDir, { recursive: true });
106
+ const mounts = this.containerWorkDir
107
+ ? [
108
+ "-v",
109
+ `${this.workDir}:${this.workDir}`,
110
+ ...(this.sharedGitDir ? ["-v", `${this.sharedGitDir}:${this.sharedGitDir}`] : []),
111
+ "-w",
112
+ this.containerWorkDir,
113
+ ]
114
+ : ["-v", `${this.workDir}:/workspace`, "-w", "/workspace"];
102
115
  const result = await this.runner.run("docker", [
103
116
  "run",
104
117
  "-d",
@@ -110,10 +123,7 @@ class DockerSandbox {
110
123
  "/tmp",
111
124
  "--tmpfs",
112
125
  "/run",
113
- "-v",
114
- `${this.workDir}:/workspace`,
115
- "-w",
116
- "/workspace",
126
+ ...mounts,
117
127
  this.image,
118
128
  "tail",
119
129
  "-f",
@@ -156,9 +166,13 @@ class DockerSandbox {
156
166
  }
157
167
  /** Force-removes the container and the host-side workspace mount. Best-effort: never throws. */
158
168
  async wipe() {
159
- await this.runner.run("docker", ["rm", "-f", this.name]).catch(() => undefined);
169
+ await this.stop();
160
170
  await node_fs_1.promises.rm(this.workDir, { recursive: true, force: true }).catch(() => undefined);
161
171
  }
172
+ /** Stops/removes only the container; the session orchestrator owns worktree cleanup. */
173
+ async stop() {
174
+ await this.runner.run("docker", ["rm", "-f", this.name]).catch(() => undefined);
175
+ }
162
176
  }
163
177
  exports.DockerSandbox = DockerSandbox;
164
178
  function shellQuote(value) {
package/dist/session.cjs CHANGED
@@ -14,14 +14,15 @@ const upload_cjs_1 = require("./upload.cjs");
14
14
  const prompt_cjs_1 = require("./prompt.cjs");
15
15
  const mcp_config_cjs_1 = require("./mcp-config.cjs");
16
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). */
17
+ const git_worktree_cjs_1 = require("./git-worktree.cjs");
18
+ /** Filename the generated platform MCP config is written under inside the session metadata directory. */
18
19
  const MCP_CONFIG_FILENAME = "mcp-config.json";
19
20
  const log = (0, logger_cjs_1.createLogger)("session");
20
21
  /**
21
22
  * Runs one claimed task end to end (implementation-plan.md WP-07):
22
23
  * 1. write the prompt file
23
24
  * 2. fetch secrets from the broker once, at session start
24
- * 3. stand up a Docker sandbox, clone the repo, inject env vars
25
+ * 3. optionally stand up a Docker sandbox when explicitly configured
25
26
  * 4. run the configured agent adapter (Claude Code or Codex, per
26
27
  * NAVARCH_AGENT — adapters/index.cts#selectAdapter), heartbeating the
27
28
  * lease throughout
@@ -29,11 +30,11 @@ const log = (0, logger_cjs_1.createLogger)("session");
29
30
  * lease (recording which agent_type ran it)
30
31
  * 6. wipe the sandbox unconditionally
31
32
  *
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.
33
+ * NEEDS LIVE VERIFICATION: the full path requires a real `claude` (or
34
+ * `codex`) binary and a live control-plane API; Docker-backed execution also
35
+ * requires a real Docker daemon. Unit tests exercise each collaborator
36
+ * (api.cts, sandbox.cts, exit-conditions.cts, redact.cts, adapters/*.cts) in
37
+ * isolation instead; see runtime/README.md.
37
38
  */
38
39
  async function runSession(deps, claimed, sessionId) {
39
40
  const { api, config } = deps;
@@ -41,7 +42,7 @@ async function runSession(deps, claimed, sessionId) {
41
42
  // The session's identity is the pre-allocated session id sent at claim time
42
43
  // (recorded on the lease by the dispatcher). Lease-scoped API calls
43
44
  // (heartbeat/complete/issue/transcript) still key on leaseId.
44
- const workDir = node_path_1.default.join(config.workspaceRoot, sessionId);
45
+ const workDir = node_path_1.default.join(config.workspaceRoot, "sessions", sessionId);
45
46
  await node_fs_1.promises.mkdir(workDir, { recursive: true });
46
47
  const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
47
48
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
@@ -56,6 +57,31 @@ async function runSession(deps, claimed, sessionId) {
56
57
  secrets = issued.secrets;
57
58
  registry.registerAll(secrets);
58
59
  }
60
+ const cloneUrl = bundle.repository?.clone_url ??
61
+ (task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
62
+ if (!cloneUrl) {
63
+ const failureSummary = `Project ${task.project_id} has no GitHub repository URL. Set it in Project settings before dispatching work.`;
64
+ await api.completeLease(leaseId, {
65
+ status: "failed",
66
+ report: failureSummary,
67
+ failure_summary: failureSummary,
68
+ evidence_urls: [],
69
+ cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
70
+ exit_status: "crashed",
71
+ agent_type: config.agentType,
72
+ });
73
+ secrets = {};
74
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
75
+ return;
76
+ }
77
+ const gitWorktree = new git_worktree_cjs_1.GitWorktree({
78
+ workspaceRoot: config.workspaceRoot,
79
+ projectId: task.project_id,
80
+ taskId: task.id,
81
+ sessionId,
82
+ cloneUrl,
83
+ githubToken: secrets[bundle.repository?.credential_secret_name ?? "github-pat"],
84
+ });
59
85
  const abortController = new AbortController();
60
86
  let leaseLost = false;
61
87
  const heartbeatTimer = setInterval(() => {
@@ -73,16 +99,25 @@ async function runSession(deps, claimed, sessionId) {
73
99
  .completeLease(leaseId, {
74
100
  status: "failed",
75
101
  report: "Docker sandbox unavailable on this machine.",
102
+ failure_summary: "Docker sandbox unavailable on this machine.",
76
103
  evidence_urls: [],
77
104
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
78
105
  exit_status: "crashed",
79
106
  agent_type: config.agentType,
80
107
  })
81
108
  .catch((err) => log.warn(`complete() after docker-unavailable also failed: ${String(err)}`));
109
+ secrets = {};
110
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
82
111
  return;
83
112
  }
84
113
  const sandbox = dockerAvailable
85
- ? new sandbox_cjs_1.DockerSandbox({ sessionId, workspaceRoot: config.workspaceRoot, image: config.dockerImage })
114
+ ? new sandbox_cjs_1.DockerSandbox({
115
+ sessionId,
116
+ workspaceRoot: node_path_1.default.join(config.workspaceRoot, "sessions"),
117
+ image: config.dockerImage,
118
+ containerWorkDir: gitWorktree.worktreePath,
119
+ sharedGitDir: gitWorktree.repositoryPath,
120
+ })
86
121
  : null;
87
122
  // Platform MCP config (implementation-plan.md WP-07: "--mcp-config
88
123
  // pointing at the platform MCP server"): generated fresh per session,
@@ -90,8 +125,8 @@ async function runSession(deps, claimed, sessionId) {
90
125
  // auth app/api/mcp/route.ts requires -- see lib/navarch/mcp/context.ts),
91
126
  // unless the operator pinned a static override via NAVARCH_MCP_CONFIG_PATH
92
127
  // (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) --
128
+ // workDir (host path), which Docker mode mounts at that same absolute path
129
+ // so worktree .git pointers and this config path remain valid --
95
130
  // the selected adapter (adapters/claude.cts or adapters/codex.cts) needs a
96
131
  // path valid in whichever environment it actually runs.
97
132
  let mcpConfigPath = config.mcpConfigPath;
@@ -102,13 +137,13 @@ async function runSession(deps, claimed, sessionId) {
102
137
  leaseId,
103
138
  });
104
139
  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);
140
+ mcpConfigPath = node_path_1.default.join(workDir, MCP_CONFIG_FILENAME);
106
141
  }
107
142
  try {
143
+ await gitWorktree.prepare();
108
144
  if (sandbox) {
109
145
  await sandbox.create();
110
146
  await sandbox.injectEnv(toEnvMap(secrets));
111
- await sandbox.cloneRepo(task.repo, Boolean(secrets["github-pat"]));
112
147
  }
113
148
  // Picks the Claude Code or Codex adapter per NAVARCH_AGENT
114
149
  // (config.cts's `agentType`) — see adapters/index.cts#selectAdapter.
@@ -124,8 +159,8 @@ async function runSession(deps, claimed, sessionId) {
124
159
  bin,
125
160
  extraArgs,
126
161
  timeoutMs: config.sessionTimeoutMs,
127
- env: secrets,
128
- cwd: sandbox ? undefined : workDir,
162
+ env: toEnvMap(secrets),
163
+ cwd: sandbox ? undefined : gitWorktree.worktreePath,
129
164
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
130
165
  signal: abortController.signal,
131
166
  });
@@ -164,10 +199,12 @@ async function runSession(deps, claimed, sessionId) {
164
199
  }
165
200
  catch (err) {
166
201
  log.error(`session ${leaseId} threw before completing: ${String(err)}`);
202
+ const failureSummary = (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, registry.list());
167
203
  await api
168
204
  .completeLease(leaseId, {
169
205
  status: "failed",
170
- report: (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, registry.list()),
206
+ report: failureSummary,
207
+ failure_summary: failureSummary,
171
208
  evidence_urls: [],
172
209
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
173
210
  exit_status: "crashed",
@@ -179,7 +216,8 @@ async function runSession(deps, claimed, sessionId) {
179
216
  clearInterval(heartbeatTimer);
180
217
  secrets = {};
181
218
  if (sandbox)
182
- await sandbox.wipe();
219
+ await sandbox.stop();
220
+ await gitWorktree.cleanup();
183
221
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
184
222
  }
185
223
  }
@@ -191,6 +229,14 @@ function toEnvMap(secrets) {
191
229
  }
192
230
  if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
193
231
  out.GITHUB_TOKEN = secrets["github-pat"];
232
+ // Make ordinary `git push` calls from the agent use the in-memory token.
233
+ // The helper contains only an env-var reference; the token itself never
234
+ // lands in argv, git config, or the worktree.
235
+ out.GIT_CONFIG_COUNT = "1";
236
+ out.GIT_CONFIG_KEY_0 = "credential.helper";
237
+ out.GIT_CONFIG_VALUE_0 =
238
+ '!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f';
239
+ out.GIT_TERMINAL_PROMPT = "0";
194
240
  }
195
241
  return out;
196
242
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.1",
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.",
3
+ "version": "0.1.2",
4
+ "description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them via the Claude Code or Codex adapter, and reports results back.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "repository": {