@sagentlab/navarch-runtime 0.1.8 → 0.1.10

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
@@ -60,8 +60,9 @@ project only (it will never be dispatched work from any other project).
60
60
 
61
61
  ```sh
62
62
  npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
63
- --name my-agent-1 --agent codex --api-base https://navarch.example.com
64
- npx @sagentlab/navarch-runtime supervise
63
+ --name my-agent-1 --agent codex --api-base https://navarch.example.com \
64
+ --config-dir ~/.navarch/my-agent-1
65
+ npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/my-agent-1
65
66
  ```
66
67
 
67
68
  (`@sagentlab/navarch-runtime` is published to npm, so `npx
@@ -79,6 +80,30 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
79
80
  | `supervise [--agent claude-code\|codex]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
80
81
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
81
82
 
83
+ ### Running multiple agents on one machine
84
+
85
+ Every agent instance must have its own config directory. The directory contains
86
+ the machine identity, update state, managed runtime versions, and (by default)
87
+ session workspaces. Reusing it lets a later `connect` replace `machine.json` and
88
+ causes both processes to race over update and workspace state.
89
+
90
+ Pass the same `--config-dir` to `connect` and `supervise` for each instance:
91
+
92
+ ```sh
93
+ npx @sagentlab/navarch-runtime connect <pebble enrollment options> \
94
+ --name pebble-agent-2816 --agent codex --config-dir ~/.navarch/pebble
95
+ npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/pebble
96
+
97
+ npx @sagentlab/navarch-runtime connect <tobi enrollment options> \
98
+ --name tobi-agent-1521 --agent codex --config-dir ~/.navarch/tobi
99
+ npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/tobi
100
+ ```
101
+
102
+ `NAVARCH_CONFIG_DIR` remains equivalent for service managers and environment
103
+ files. A supervisor also pins its starting identity into replacement workers,
104
+ so an accidental later edit to `machine.json` cannot change that running
105
+ instance during an automatic-update handoff.
106
+
82
107
  ## Active-task guidance
83
108
 
84
109
  Guidance added to a task that is already running is delivered on that
@@ -183,7 +208,7 @@ unchanged across the deployment.
183
208
  | `NAVARCH_MACHINE_NAME` | — | Used by `register`. |
184
209
  | `NAVARCH_ENROLLMENT_TOKEN` | — | Alternative to `register --token` / `connect --token`. |
185
210
  | `NAVARCH_PROJECT_ID` | — | Alternative to `connect --project`. |
186
- | `NAVARCH_CONFIG_DIR` | `~/.navarch` | Where `machine.json` lives. |
211
+ | `NAVARCH_CONFIG_DIR` | `~/.navarch` | Per-instance state root. Equivalent to `--config-dir`; use a different directory for every agent on the same host. |
187
212
  | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Persistent bare repo caches plus isolated per-session worktrees. |
188
213
  | `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
189
214
  | `NAVARCH_CAPABILITIES` | `shell` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. |
@@ -192,6 +217,7 @@ unchanged across the deployment.
192
217
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
193
218
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
194
219
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
220
+ | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
195
221
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
196
222
  | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
197
223
  | `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. |
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Git credential helper for managed Navarch repositories. Git invokes this
4
+ // for every remote operation, so each lookup gets a fresh installation token
5
+ // from the lease-scoped broker instead of relying on the session-start token.
6
+
7
+ const operation = process.argv[2];
8
+ if (operation !== "get") process.exit(0);
9
+
10
+ const apiBase = process.env.NAVARCH_GIT_CREDENTIAL_API_BASE?.replace(/\/+$/, "");
11
+ const machineToken = process.env.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN;
12
+ const leaseId = process.env.NAVARCH_GIT_CREDENTIAL_LEASE_ID;
13
+
14
+ if (!apiBase || !machineToken || !leaseId) {
15
+ console.error("navarch git credential helper is missing broker configuration");
16
+ process.exit(1);
17
+ }
18
+
19
+ async function main() {
20
+ try {
21
+ const response = await fetch(`${apiBase}/api/broker/issue`, {
22
+ method: "POST",
23
+ headers: {
24
+ authorization: `Bearer ${machineToken}`,
25
+ "content-type": "application/json",
26
+ },
27
+ body: JSON.stringify({ lease_id: leaseId, secret_names: ["github-pat"] }),
28
+ signal: AbortSignal.timeout(15_000),
29
+ });
30
+ if (!response.ok) {
31
+ throw new Error(`broker returned HTTP ${response.status}`);
32
+ }
33
+ const result = await response.json();
34
+ const token = result?.secrets?.["github-pat"];
35
+ if (typeof token !== "string" || token.length === 0) {
36
+ throw new Error("broker did not issue github-pat");
37
+ }
38
+ process.stdout.write(`username=x-access-token\npassword=${token}\n`);
39
+ } catch (error) {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ console.error(`navarch git credential refresh failed: ${message}`);
42
+ process.exitCode = 1;
43
+ }
44
+ }
45
+
46
+ void main();
@@ -136,7 +136,11 @@ function tomlKey(value) {
136
136
  function safeEnvSegment(value) {
137
137
  return value.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
138
138
  }
139
- /** Parses stdout for `codex exec --json` usage/final-message events and folds them onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd/reportText unset when nothing parses — see exit-conditions.cts#parseCodexJsonEvents). */
139
+ /**
140
+ * Parses stdout for `codex exec --json` usage/final-message events and folds
141
+ * them onto the raw result. Codex currently reports token counts but no USD
142
+ * cost, so costUsd remains absent unless the stream explicitly provides one.
143
+ */
140
144
  function attachUsage(result) {
141
145
  const events = (0, exit_conditions_cjs_1.parseCodexJsonEvents)(result.stdout);
142
146
  if (events.length === 0)
@@ -147,7 +151,7 @@ function attachUsage(result) {
147
151
  ...result,
148
152
  tokensIn: usage.tokensIn,
149
153
  tokensOut: usage.tokensOut,
150
- costUsd: usage.costUsd,
154
+ ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
151
155
  ...(reportText !== undefined ? { reportText } : {}),
152
156
  };
153
157
  }
package/dist/cli.cjs CHANGED
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.main = main;
4
7
  const config_cjs_1 = require("./config.cjs");
5
8
  const node_crypto_1 = require("node:crypto");
9
+ const node_path_1 = __importDefault(require("node:path"));
6
10
  const machine_store_cjs_1 = require("./machine-store.cjs");
7
11
  const api_cjs_1 = require("./api.cjs");
8
12
  const capacity_cjs_1 = require("./capacity.cjs");
@@ -15,6 +19,19 @@ const update_coordinator_cjs_1 = require("./update-coordinator.cjs");
15
19
  const supervisor_cjs_1 = require("./supervisor.cjs");
16
20
  const log = (0, logger_cjs_1.createLogger)("cli");
17
21
  const PACKAGE_NAME = "@sagentlab/navarch-runtime";
22
+ /** Include a control-plane response's safe error detail in top-level CLI failures. */
23
+ function describeCliError(err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ if (!err || typeof err !== "object" || !("body" in err))
26
+ return message;
27
+ const body = err.body;
28
+ const detail = body && typeof body === "object" && "error" in body && typeof body.error === "string"
29
+ ? body.error
30
+ : typeof body === "string"
31
+ ? body
32
+ : undefined;
33
+ return detail && !message.includes(detail) ? `${message}: ${detail}` : message;
34
+ }
18
35
  /**
19
36
  * How to tell the user to re-invoke this CLI, matching however THEY launched
20
37
  * it. The bare `navarch-runtime` bin only exists on PATH after a global install
@@ -32,6 +49,17 @@ function invocation(subcommand) {
32
49
  const prefix = viaNpx ? `npx ${PACKAGE_NAME}` : "navarch-runtime";
33
50
  return `${prefix} ${subcommand}`;
34
51
  }
52
+ /** Keep the post-enrollment next step pinned to the identity we just saved. */
53
+ function superviseInvocation(configDir) {
54
+ return `${invocation("supervise")} --config-dir ${configDir}`;
55
+ }
56
+ /** Resolve CLI-local configuration without mutating the parent shell environment. */
57
+ function configFromFlags(flags) {
58
+ const configDir = flags["config-dir"];
59
+ return (0, config_cjs_1.loadRuntimeConfig)(configDir
60
+ ? { ...process.env, NAVARCH_CONFIG_DIR: node_path_1.default.resolve(configDir) }
61
+ : process.env);
62
+ }
35
63
  function agentFromFlag(flags) {
36
64
  const value = flags.agent;
37
65
  if (value === undefined)
@@ -66,7 +94,7 @@ function parseArgs(argv) {
66
94
  * RegisterMachineRequest for the (assumed, to-confirm) endpoint contract.
67
95
  */
68
96
  async function registerCommand(flags) {
69
- const config = (0, config_cjs_1.loadRuntimeConfig)();
97
+ const config = configFromFlags(flags);
70
98
  const apiBase = flags["api-base"] ?? config.apiBase;
71
99
  const enrollmentToken = flags.token ?? process.env.NAVARCH_ENROLLMENT_TOKEN;
72
100
  const name = flags.name ?? process.env.NAVARCH_MACHINE_NAME;
@@ -102,7 +130,7 @@ async function registerCommand(flags) {
102
130
  console.log("Machine registered.");
103
131
  console.log(` machine_id: ${result.machine_id}`);
104
132
  console.log(` token: ${result.token}`);
105
- console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("supervise")}\` to begin serving tasks.`);
133
+ console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${superviseInvocation(config.configDir)}\` to begin serving tasks.`);
106
134
  }
107
135
  /**
108
136
  * `navarch-runtime connect` — "Connect an agent to a project"
@@ -114,7 +142,7 @@ async function registerCommand(flags) {
114
142
  * machine token exactly once, same discipline as `register`.
115
143
  */
116
144
  async function connectCommand(flags) {
117
- const config = (0, config_cjs_1.loadRuntimeConfig)();
145
+ const config = configFromFlags(flags);
118
146
  const apiBase = flags["api-base"] ?? config.apiBase;
119
147
  const enrollmentToken = flags.token ?? process.env.NAVARCH_ENROLLMENT_TOKEN;
120
148
  const name = flags.name ?? process.env.NAVARCH_MACHINE_NAME;
@@ -150,10 +178,10 @@ async function connectCommand(flags) {
150
178
  console.log("Machine connected.");
151
179
  console.log(` machine_id: ${result.machine_id}`);
152
180
  console.log(` token: ${result.token}`);
153
- console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("supervise")}\` to begin serving tasks.`);
181
+ console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${superviseInvocation(config.configDir)}\` to begin serving tasks.`);
154
182
  }
155
183
  async function startCommand(flags) {
156
- const baseConfig = (0, config_cjs_1.loadRuntimeConfig)();
184
+ const baseConfig = configFromFlags(flags);
157
185
  const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
158
186
  // Adapter selection belongs to the local machine: an explicit start flag
159
187
  // wins, followed by NAVARCH_AGENT, the choice saved at connect/register
@@ -203,14 +231,25 @@ async function startCommand(flags) {
203
231
  process.on("SIGTERM", shutdown);
204
232
  }
205
233
  async function superviseCommand(flags) {
206
- const config = (0, config_cjs_1.loadRuntimeConfig)();
234
+ const config = configFromFlags(flags);
235
+ const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
207
236
  const agentType = agentFromFlag(flags);
208
237
  const workerArgs = agentType ? ["--agent", agentType] : [];
209
- const exitCode = await (0, supervisor_cjs_1.superviseRuntime)(config.configDir, process.argv[1] ?? "", workerArgs);
238
+ // Pin the identity for this supervisor's lifetime. Without this snapshot, a
239
+ // replacement worker rereads machine.json after an automatic update and can
240
+ // silently become a different machine if another terminal reused the same
241
+ // config directory in the meantime.
242
+ const exitCode = await (0, supervisor_cjs_1.superviseRuntime)(config.configDir, process.argv[1] ?? "", workerArgs, 120_000, {
243
+ NAVARCH_CONFIG_DIR: config.configDir,
244
+ NAVARCH_MACHINE_ID: identity.machine_id,
245
+ NAVARCH_MACHINE_TOKEN: identity.token,
246
+ NAVARCH_MACHINE_NAME: identity.name,
247
+ NAVARCH_API_BASE: identity.api_base,
248
+ });
210
249
  process.exitCode = exitCode;
211
250
  }
212
251
  async function doctorCommand(flags) {
213
- const config = (0, config_cjs_1.loadRuntimeConfig)();
252
+ const config = configFromFlags(flags);
214
253
  const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
215
254
  console.log(`api_base: ${config.apiBase}`);
216
255
  console.log(`config_dir: ${config.configDir}`);
@@ -237,14 +276,15 @@ function helpText() {
237
276
 
238
277
  Usage:
239
278
  navarch-runtime register --token <enrollment-token> --name <machine-name> \\
240
- [--agent claude-code|codex] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
279
+ [--config-dir <path>] [--agent claude-code|codex] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
241
280
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
242
- [--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
243
- navarch-runtime start [--agent claude-code|codex]
244
- navarch-runtime supervise [--agent claude-code|codex]
245
- navarch-runtime doctor
281
+ [--config-dir <path>] [--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
282
+ navarch-runtime start [--config-dir <path>] [--agent claude-code|codex]
283
+ navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex]
284
+ navarch-runtime doctor [--config-dir <path>]
246
285
 
247
- Configuration is via NAVARCH_* environment variables; see runtime/README.md.
286
+ Use a different --config-dir (or NAVARCH_CONFIG_DIR) for every agent instance.
287
+ Other configuration is via NAVARCH_* environment variables; see runtime/README.md.
248
288
  `;
249
289
  }
250
290
  async function main(argv = process.argv.slice(2)) {
@@ -278,7 +318,7 @@ async function main(argv = process.argv.slice(2)) {
278
318
  }
279
319
  }
280
320
  catch (err) {
281
- log.error(err instanceof Error ? err.message : String(err));
321
+ log.error(describeCliError(err));
282
322
  process.exitCode = 1;
283
323
  }
284
324
  }
package/dist/config.cjs CHANGED
@@ -57,6 +57,8 @@ function loadRuntimeConfig(env = process.env) {
57
57
  claudeExtraArgs: envList(env, "NAVARCH_CLAUDE_EXTRA_ARGS", []),
58
58
  codexBin: env.NAVARCH_CODEX_BIN ?? "codex",
59
59
  codexExtraArgs: envList(env, "NAVARCH_CODEX_EXTRA_ARGS", []),
60
+ gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
61
+ gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
60
62
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
61
63
  sandboxMode,
62
64
  dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
@@ -95,7 +95,7 @@ function parseCodexJsonEvents(stdout) {
95
95
  function extractUsageFromCodexEvents(events) {
96
96
  let tokensIn = 0;
97
97
  let tokensOut = 0;
98
- let costUsd = 0;
98
+ let costUsd;
99
99
  for (const event of events) {
100
100
  if (event.type === "turn.completed" && event.usage) {
101
101
  tokensIn = event.usage.input_tokens ?? 0;
@@ -104,8 +104,22 @@ function extractUsageFromCodexEvents(events) {
104
104
  costUsd = event.usage.total_cost_usd;
105
105
  }
106
106
  if (event.msg?.type === "token_count") {
107
- tokensIn = (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
108
- tokensOut = event.msg.output_tokens ?? 0;
107
+ const nested = event.msg.info?.total_token_usage;
108
+ tokensIn = nested
109
+ ? (nested.input_tokens ?? 0)
110
+ : (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
111
+ tokensOut = nested?.output_tokens ?? event.msg.output_tokens ?? 0;
112
+ if (typeof nested?.total_cost_usd === "number")
113
+ costUsd = nested.total_cost_usd;
114
+ }
115
+ if (event.type === "event_msg" && event.payload?.type === "token_count") {
116
+ const total = event.payload.info?.total_token_usage;
117
+ if (total) {
118
+ tokensIn = total.input_tokens ?? 0;
119
+ tokensOut = total.output_tokens ?? 0;
120
+ if (typeof total.total_cost_usd === "number")
121
+ costUsd = total.total_cost_usd;
122
+ }
109
123
  }
110
124
  if (typeof event.msg?.total_cost_usd === "number") {
111
125
  costUsd = event.msg.total_cost_usd;
@@ -38,40 +38,95 @@ class GitWorktree {
38
38
  await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.repositoryPath), { recursive: true });
39
39
  await node_fs_1.promises.mkdir(this.sessionRoot, { recursive: true });
40
40
  await withRepositoryLock(this.repositoryPath, async () => {
41
- if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
42
- await this.runGit(["clone", "--bare", this.cloneUrl, this.repositoryPath], true);
41
+ await this.ensureRepositoryCache();
42
+ try {
43
+ await this.addSessionWorktree();
43
44
  }
44
- else {
45
- const origin = await this.runGit(["--git-dir", this.repositoryPath, "remote", "get-url", "origin"], false);
46
- if (normalizeCloneUrl(origin.stdout) !== normalizeCloneUrl(this.cloneUrl)) {
47
- await this.runGit(["--git-dir", this.repositoryPath, "remote", "set-url", "origin", this.cloneUrl], false);
48
- }
45
+ catch (error) {
46
+ if (!isInvalidReferenceError(error))
47
+ throw error;
48
+ // This bare cache owns the metadata for every active session worktree.
49
+ // Replacing it here would invalidate those worktrees, so leave it intact
50
+ // and fail with enough context for a coordinated repair.
51
+ throw new Error(`Failed to create a session worktree because the cached repository at ${this.repositoryPath} ` +
52
+ `could not resolve the fetched start ref. The shared cache was preserved to avoid invalidating ` +
53
+ `active worktrees; retry after active sessions finish or repair the cache in place: ${errorMessage(error)}`);
49
54
  }
50
- const remoteHead = await this.runGit(["--git-dir", this.repositoryPath, "ls-remote", "--symref", "origin", "HEAD"], true);
51
- const defaultBranch = parseRemoteHead(remoteHead.stdout);
52
- const startRef = defaultBranch
53
- ? defaultBranch.replace(/^refs\/heads\//, "refs/remotes/origin/")
54
- : "HEAD";
55
- await this.runGit([
56
- "--git-dir",
57
- this.repositoryPath,
58
- "fetch",
59
- "--prune",
60
- "origin",
61
- "+refs/heads/*:refs/remotes/origin/*",
62
- ], true);
63
- await this.runGit([
64
- "--git-dir",
65
- this.repositoryPath,
66
- "worktree",
67
- "add",
68
- "-b",
69
- this.branch,
70
- this.worktreePath,
71
- startRef,
72
- ], false);
73
55
  });
74
56
  }
57
+ /** Clones the bare cache if missing, repoints origin if needed, and fetches all branches. */
58
+ async ensureRepositoryCache() {
59
+ if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
60
+ await this.runGit(["clone", "--bare", this.cloneUrl, this.repositoryPath], true);
61
+ }
62
+ else {
63
+ const origin = await this.runGit(["--git-dir", this.repositoryPath, "remote", "get-url", "origin"], false);
64
+ if (normalizeCloneUrl(origin.stdout) !== normalizeCloneUrl(this.cloneUrl)) {
65
+ await this.runGit(["--git-dir", this.repositoryPath, "remote", "set-url", "origin", this.cloneUrl], false);
66
+ }
67
+ }
68
+ await this.runGit([
69
+ "--git-dir",
70
+ this.repositoryPath,
71
+ "fetch",
72
+ "--prune",
73
+ "origin",
74
+ "+refs/heads/*:refs/remotes/origin/*",
75
+ ], true);
76
+ }
77
+ async addSessionWorktree() {
78
+ const startRef = await this.resolveStartRef();
79
+ if (!startRef) {
80
+ // Empty remote (no commits yet): there is nothing to base the session on,
81
+ // so bootstrap an orphan branch the session can push as the first commit.
82
+ await this.addOrphanWorktree();
83
+ return;
84
+ }
85
+ await this.runGit([
86
+ "--git-dir",
87
+ this.repositoryPath,
88
+ "worktree",
89
+ "add",
90
+ "-b",
91
+ this.branch,
92
+ this.worktreePath,
93
+ startRef,
94
+ ], false);
95
+ }
96
+ /**
97
+ * Resolves the remote-tracking ref new session branches start from, or null
98
+ * when the remote has no branches at all (a freshly provisioned empty repo).
99
+ */
100
+ async resolveStartRef() {
101
+ const remoteHead = await this.runGit(["--git-dir", this.repositoryPath, "ls-remote", "--symref", "origin", "HEAD"], true);
102
+ const defaultBranch = parseRemoteHead(remoteHead.stdout);
103
+ if (defaultBranch)
104
+ return defaultBranch.replace(/^refs\/heads\//, "refs/remotes/origin/");
105
+ // No symref (e.g. an unborn remote HEAD): fall back to whatever branches
106
+ // the fetch brought in, preferring the conventional default names.
107
+ const refs = await this.runGit(["--git-dir", this.repositoryPath, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], false);
108
+ const branches = refs.stdout
109
+ .split("\n")
110
+ .map((line) => line.trim())
111
+ .filter((line) => line && line !== "refs/remotes/origin/HEAD");
112
+ for (const preferred of ["refs/remotes/origin/main", "refs/remotes/origin/master"]) {
113
+ if (branches.includes(preferred))
114
+ return preferred;
115
+ }
116
+ return branches[0] ?? null;
117
+ }
118
+ async addOrphanWorktree() {
119
+ try {
120
+ await this.runGit(["--git-dir", this.repositoryPath, "worktree", "add", "--orphan", "-b", this.branch, this.worktreePath], false);
121
+ }
122
+ catch (error) {
123
+ // `worktree add --orphan` needs git >= 2.42; older gits reject the flag.
124
+ if (!/unknown option|usage: git worktree add/i.test(errorMessage(error)))
125
+ throw error;
126
+ await this.runGit(["init", "--initial-branch", this.branch, this.worktreePath], false);
127
+ await this.runGit(["-C", this.worktreePath, "remote", "add", "origin", this.cloneUrl], false);
128
+ }
129
+ }
75
130
  async cleanup() {
76
131
  await withRepositoryLock(this.repositoryPath, async () => {
77
132
  await this.runner
@@ -172,3 +227,9 @@ function parseRemoteHead(output) {
172
227
  const match = output.match(/^ref:\s+(refs\/heads\/[A-Za-z0-9._/-]+)\s+HEAD$/m);
173
228
  return match?.[1] ?? null;
174
229
  }
230
+ function errorMessage(error) {
231
+ return error instanceof Error ? error.message : String(error);
232
+ }
233
+ function isInvalidReferenceError(error) {
234
+ return /invalid reference/i.test(errorMessage(error));
235
+ }
package/dist/session.cjs CHANGED
@@ -4,8 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runSession = runSession;
7
+ exports.toEnvMap = toEnvMap;
7
8
  const node_path_1 = __importDefault(require("node:path"));
8
9
  const node_fs_1 = require("node:fs");
10
+ const api_cjs_1 = require("./api.cjs");
9
11
  const sandbox_cjs_1 = require("./sandbox.cjs");
10
12
  const index_cjs_1 = require("./adapters/index.cjs");
11
13
  const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
@@ -19,11 +21,14 @@ const github_pr_cjs_1 = require("./github-pr.cjs");
19
21
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
20
22
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
21
23
  const MCP_CONFIG_FILENAME = "mcp-config.json";
24
+ const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
25
+ const PR_REQUIRED_COMPLETION_RETRIES = 2;
22
26
  const log = (0, logger_cjs_1.createLogger)("session");
23
27
  /**
24
28
  * Runs one claimed task end to end (implementation-plan.md WP-07):
25
29
  * 1. write the prompt file
26
- * 2. fetch secrets from the broker once, at session start
30
+ * 2. fetch secrets from the broker at session start (managed GitHub git
31
+ * credentials are subsequently refreshed by a lease-scoped helper)
27
32
  * 3. optionally stand up a Docker sandbox when explicitly configured
28
33
  * 4. run the configured agent adapter (Claude Code or Codex, per
29
34
  * NAVARCH_AGENT — adapters/index.cts#selectAdapter), heartbeating the
@@ -58,17 +63,30 @@ async function runSession(deps, claimed, sessionId) {
58
63
  await node_fs_1.promises.mkdir(workDir, { recursive: true });
59
64
  const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
60
65
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
61
- // Secrets: fetched once, held only in memory (registry + env map below),
66
+ // Secrets: initially fetched once, held only in memory (registry + env map below),
62
67
  // never written to disk on the host. They only ever reach disk inside the
63
68
  // sandbox's tmpfs (sandbox.cts injectEnv), which is wiped with the container.
64
69
  const registry = new redact_cjs_1.SecretRegistry();
65
70
  let secrets = {};
71
+ let managedGithubCredential = false;
66
72
  const secretNames = bundle.secret_manifest.map((s) => s.name);
67
73
  if (secretNames.length > 0) {
68
74
  const issued = await api.issueSecrets({ lease_id: leaseId, secret_names: secretNames });
69
75
  secrets = issued.secrets;
76
+ managedGithubCredential = issued.github_app_issued === true;
70
77
  registry.registerAll(secrets);
71
78
  }
79
+ let gitCredentialRefresh;
80
+ if (managedGithubCredential && secrets["github-pat"]) {
81
+ const helperPath = node_path_1.default.join(workDir, GIT_CREDENTIAL_HELPER_FILENAME);
82
+ await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch.cjs"), helperPath);
83
+ gitCredentialRefresh = {
84
+ helperPath,
85
+ apiBase: api.getBaseUrl(),
86
+ machineToken: api.getToken() ?? "",
87
+ leaseId,
88
+ };
89
+ }
72
90
  const cloneUrl = bundle.repository?.clone_url ??
73
91
  (task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
74
92
  if (!cloneUrl) {
@@ -223,7 +241,7 @@ async function runSession(deps, claimed, sessionId) {
223
241
  await gitWorktree.prepare();
224
242
  if (sandbox) {
225
243
  await sandbox.create();
226
- await sandbox.injectEnv(toEnvMap(secrets));
244
+ await sandbox.injectEnv(toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail));
227
245
  }
228
246
  // Picks the Claude Code or Codex adapter per NAVARCH_AGENT
229
247
  // (config.cts's `agentType`) — see adapters/index.cts#selectAdapter.
@@ -234,7 +252,8 @@ async function runSession(deps, claimed, sessionId) {
234
252
  const bin = config.agentType === "codex" ? config.codexBin : config.claudeBin;
235
253
  const extraArgs = config.agentType === "codex" ? config.codexExtraArgs : config.claudeExtraArgs;
236
254
  const attempts = [];
237
- let result;
255
+ let nextPrompt = null;
256
+ let prRequiredCompletionRetries = 0;
238
257
  while (true) {
239
258
  // Guidance can arrive while the worktree/sandbox is being prepared.
240
259
  // It is already included in deliveredGuidance, so clear the pending
@@ -243,9 +262,11 @@ async function runSession(deps, claimed, sessionId) {
243
262
  activeAbortController = new AbortController();
244
263
  if (leaseLost)
245
264
  activeAbortController.abort();
246
- const runPrompt = deliveredGuidance.length > (bundle.guidance?.length ?? 0)
247
- ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
248
- : promptText;
265
+ const runPrompt = nextPrompt ??
266
+ (deliveredGuidance.length > (bundle.guidance?.length ?? 0)
267
+ ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
268
+ : promptText);
269
+ nextPrompt = null;
249
270
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), runPrompt, "utf8");
250
271
  const turnResult = await adapter.run({
251
272
  prompt: runPrompt,
@@ -255,7 +276,7 @@ async function runSession(deps, claimed, sessionId) {
255
276
  model: execution.model,
256
277
  reasoningEffort: execution.reasoning_effort,
257
278
  timeoutMs: config.sessionTimeoutMs,
258
- env: toEnvMap(secrets),
279
+ env: toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail),
259
280
  settingsPath: claudeSettingsPath,
260
281
  codexGuardArgs,
261
282
  cwd: sandbox ? undefined : gitWorktree.worktreePath,
@@ -270,66 +291,93 @@ async function runSession(deps, claimed, sessionId) {
270
291
  await pollLease();
271
292
  if (!leaseLost && pendingGuidance.length > 0)
272
293
  continue;
273
- result = {
294
+ const result = {
274
295
  ...turnResult,
275
- tokensIn: attempts.reduce((sum, attempt) => sum + (attempt.tokensIn ?? 0), 0),
276
- tokensOut: attempts.reduce((sum, attempt) => sum + (attempt.tokensOut ?? 0), 0),
277
- costUsd: attempts.reduce((sum, attempt) => sum + (attempt.costUsd ?? 0), 0),
296
+ tokensIn: sumReportedUsage(attempts, "tokensIn"),
297
+ tokensOut: sumReportedUsage(attempts, "tokensOut"),
298
+ costUsd: sumReportedUsage(attempts, "costUsd"),
278
299
  };
279
- break;
280
- }
281
- const mapping = (0, exit_conditions_cjs_1.mapExitCondition)({ ...result, killedByLeaseLoss: leaseLost || result.killedByLeaseLoss });
282
- try {
283
- const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
284
- repository: bundle.repository?.full_name ?? task.repo,
285
- headBranch: gitWorktree.branch,
286
- githubToken,
300
+ const mapping = (0, exit_conditions_cjs_1.mapExitCondition)({
301
+ ...result,
302
+ killedByLeaseLoss: leaseLost || result.killedByLeaseLoss,
287
303
  });
288
- if (prUrl)
289
- mapping.evidenceUrls.push(prUrl);
290
- }
291
- catch (err) {
292
- // Evidence discovery is best-effort: a GitHub outage or token scope
293
- // mismatch must not turn an otherwise valid completion into a crash.
294
- log.warn(`head-branch PR lookup failed for ${leaseId}: ${String(err)}`);
295
- }
296
- const knownSecrets = registry.list();
297
- if (mapping.leaseOutcome === "failed") {
298
- log.warn(`adapter failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets)}`);
299
- }
300
- const transcript = attempts
301
- .flatMap((attempt, index) => [
302
- `# agent turn ${index + 1} stdout`,
303
- (0, redact_cjs_1.redactText)(attempt.stdout, knownSecrets),
304
- "",
305
- `# agent turn ${index + 1} stderr`,
306
- (0, redact_cjs_1.redactText)(attempt.stderr, knownSecrets),
307
- "",
308
- ])
309
- .join("\n");
310
- let transcriptUrl;
311
- try {
312
- const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
313
- await (0, upload_cjs_1.uploadTranscript)(upload_url, transcript);
314
- transcriptUrl = public_url;
315
- }
316
- catch (err) {
317
- log.warn(`transcript upload failed for ${leaseId}: ${String(err)}`);
304
+ try {
305
+ const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
306
+ repository: bundle.repository?.full_name ?? task.repo,
307
+ headBranch: gitWorktree.branch,
308
+ githubToken,
309
+ });
310
+ if (prUrl)
311
+ mapping.evidenceUrls.push(prUrl);
312
+ }
313
+ catch (err) {
314
+ // Evidence discovery is best-effort: a GitHub outage or token scope
315
+ // mismatch must not turn an otherwise valid completion into a crash.
316
+ log.warn(`head-branch PR lookup failed for ${leaseId}: ${String(err)}`);
317
+ }
318
+ const knownSecrets = registry.list();
319
+ if (mapping.leaseOutcome === "failed") {
320
+ log.warn(`adapter failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets)}`);
321
+ }
322
+ const transcript = attempts
323
+ .flatMap((attempt, index) => [
324
+ `# agent turn ${index + 1} stdout`,
325
+ (0, redact_cjs_1.redactText)(attempt.stdout, knownSecrets),
326
+ "",
327
+ `# agent turn ${index + 1} stderr`,
328
+ (0, redact_cjs_1.redactText)(attempt.stderr, knownSecrets),
329
+ "",
330
+ ])
331
+ .join("\n");
332
+ let transcriptUrl;
333
+ try {
334
+ const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
335
+ await (0, upload_cjs_1.uploadTranscript)(upload_url, transcript);
336
+ transcriptUrl = public_url;
337
+ }
338
+ catch (err) {
339
+ log.warn(`transcript upload failed for ${leaseId}: ${String(err)}`);
340
+ }
341
+ const completion = {
342
+ status: mapping.leaseOutcome,
343
+ report: (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets),
344
+ evidence_urls: mapping.evidenceUrls,
345
+ cost: {
346
+ ...(result.tokensIn !== undefined ? { tokens_in: result.tokensIn } : {}),
347
+ ...(result.tokensOut !== undefined ? { tokens_out: result.tokensOut } : {}),
348
+ ...(result.costUsd !== undefined ? { cost_usd: result.costUsd } : {}),
349
+ },
350
+ transcript_url: transcriptUrl,
351
+ exit_status: mapping.exitStatus,
352
+ agent_type: config.agentType,
353
+ ...executionReport,
354
+ };
355
+ try {
356
+ await api.completeLease(leaseId, completion);
357
+ break;
358
+ }
359
+ catch (err) {
360
+ const rejection = mapping.leaseOutcome === "completed" ? prRequiredRejectionMessage(err) : null;
361
+ if (!rejection)
362
+ throw err;
363
+ const redactedRejection = (0, redact_cjs_1.redactText)(rejection, knownSecrets);
364
+ if (prRequiredCompletionRetries < PR_REQUIRED_COMPLETION_RETRIES) {
365
+ prRequiredCompletionRetries += 1;
366
+ log.warn(`completion for ${leaseId} requires a pull request; restarting agent turn ${prRequiredCompletionRetries}/${PR_REQUIRED_COMPLETION_RETRIES} in the same worktree.`);
367
+ nextPrompt = redactedRejection;
368
+ continue;
369
+ }
370
+ log.warn(`completion for ${leaseId} still requires a pull request after ${PR_REQUIRED_COMPLETION_RETRIES} retries; failing with the control-plane rejection.`);
371
+ await api.completeLease(leaseId, {
372
+ ...completion,
373
+ status: "failed",
374
+ report: redactedRejection,
375
+ failure_summary: redactedRejection,
376
+ exit_status: "failed",
377
+ });
378
+ break;
379
+ }
318
380
  }
319
- await api.completeLease(leaseId, {
320
- status: mapping.leaseOutcome,
321
- report: (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets),
322
- evidence_urls: mapping.evidenceUrls,
323
- cost: {
324
- tokens_in: result.tokensIn ?? 0,
325
- tokens_out: result.tokensOut ?? 0,
326
- cost_usd: result.costUsd ?? 0,
327
- },
328
- transcript_url: transcriptUrl,
329
- exit_status: mapping.exitStatus,
330
- agent_type: config.agentType,
331
- ...executionReport,
332
- });
333
381
  }
334
382
  catch (err) {
335
383
  log.error(`session ${leaseId} threw before completing: ${String(err)}`);
@@ -357,22 +405,71 @@ async function runSession(deps, claimed, sessionId) {
357
405
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
358
406
  }
359
407
  }
408
+ function sumReportedUsage(attempts, key) {
409
+ const reported = attempts.flatMap((attempt) => {
410
+ const value = attempt[key];
411
+ return value === undefined ? [] : [value];
412
+ });
413
+ return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
414
+ }
415
+ function prRequiredRejectionMessage(err) {
416
+ if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
417
+ return null;
418
+ if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
419
+ return null;
420
+ const body = err.body;
421
+ return body.code === "pr_required" && typeof body.error === "string"
422
+ ? body.error
423
+ : null;
424
+ }
360
425
  /** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
361
- function toEnvMap(secrets) {
426
+ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
427
+ // Keep the existing `(secrets, authorName, authorEmail)` call shape while
428
+ // accepting a refresh config before the author identity for managed repos.
429
+ const gitCredentialRefresh = typeof credentialRefreshOrAuthorName === "string"
430
+ ? undefined
431
+ : credentialRefreshOrAuthorName;
432
+ const gitAuthorName = typeof credentialRefreshOrAuthorName === "string"
433
+ ? credentialRefreshOrAuthorName
434
+ : gitAuthorNameOrEmail;
435
+ const resolvedGitAuthorEmail = typeof credentialRefreshOrAuthorName === "string"
436
+ ? gitAuthorNameOrEmail
437
+ : gitAuthorEmail;
362
438
  const out = {};
363
439
  for (const [name, value] of Object.entries(secrets)) {
364
440
  out[name.toUpperCase().replace(/[^A-Z0-9_]/g, "_")] = value;
365
441
  }
442
+ const gitConfig = [
443
+ ["user.name", gitAuthorName],
444
+ ["user.email", resolvedGitAuthorEmail],
445
+ ];
366
446
  if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
367
447
  out.GITHUB_TOKEN = secrets["github-pat"];
368
- // Make ordinary `git push` calls from the agent use the in-memory token.
369
- // The helper contains only an env-var reference; the token itself never
370
- // lands in argv, git config, or the worktree.
371
- out.GIT_CONFIG_COUNT = "1";
372
- out.GIT_CONFIG_KEY_0 = "credential.helper";
373
- out.GIT_CONFIG_VALUE_0 =
374
- '!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f';
375
- out.GIT_TERMINAL_PROMPT = "0";
448
+ if (gitCredentialRefresh) {
449
+ out.NAVARCH_GIT_CREDENTIAL_API_BASE = gitCredentialRefresh.apiBase;
450
+ out.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN = gitCredentialRefresh.machineToken;
451
+ out.NAVARCH_GIT_CREDENTIAL_LEASE_ID = gitCredentialRefresh.leaseId;
452
+ gitConfig.unshift(["credential.helper", ""], ["credential.helper", `!node ${shellQuote(gitCredentialRefresh.helperPath)}`]);
453
+ out.GIT_TERMINAL_PROMPT = "0";
454
+ }
455
+ else {
456
+ // Make ordinary `git push` calls from the agent use the in-memory token.
457
+ // The helper contains only an env-var reference; the token itself never
458
+ // lands in argv, git config, or the worktree.
459
+ gitConfig.unshift([
460
+ "credential.helper",
461
+ '!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f',
462
+ ]);
463
+ out.GIT_TERMINAL_PROMPT = "0";
464
+ }
376
465
  }
466
+ out.GIT_CONFIG_COUNT = String(gitConfig.length);
467
+ gitConfig.forEach(([key, value], index) => {
468
+ out[`GIT_CONFIG_KEY_${index}`] = key;
469
+ out[`GIT_CONFIG_VALUE_${index}`] = value;
470
+ });
377
471
  return out;
378
472
  }
473
+ function shellQuote(value) {
474
+ return `'${value.replace(/'/g, `'\\''`)}'`;
475
+ }
@@ -6,10 +6,10 @@ const logger_cjs_1 = require("./logger.cjs");
6
6
  const update_installer_cjs_1 = require("./update-installer.cjs");
7
7
  const log = (0, logger_cjs_1.createLogger)("supervisor");
8
8
  const UPDATE_RESTART_EXIT_CODE = 75;
9
- function runWorker(binPath, args, healthTimeoutMs, setCurrentChild, onHealthy) {
9
+ function runWorker(binPath, args, healthTimeoutMs, workerEnv, setCurrentChild, onHealthy) {
10
10
  return new Promise((resolve) => {
11
11
  const child = (0, node_child_process_1.spawn)(process.execPath, [binPath, "start", ...args], {
12
- env: { ...process.env, NAVARCH_SUPERVISED: "1" },
12
+ env: { ...process.env, ...workerEnv, NAVARCH_SUPERVISED: "1" },
13
13
  stdio: ["inherit", "inherit", "inherit", "ipc"],
14
14
  });
15
15
  setCurrentChild(child);
@@ -70,7 +70,7 @@ function runWorker(binPath, args, healthTimeoutMs, setCurrentChild, onHealthy) {
70
70
  });
71
71
  }
72
72
  /** Runs the worker and owns update activation, health checking, and rollback. */
73
- async function superviseRuntime(configDir, initialBinPath, workerArgs = [], healthTimeoutMs = 120_000) {
73
+ async function superviseRuntime(configDir, initialBinPath, workerArgs = [], healthTimeoutMs = 120_000, workerEnv = {}) {
74
74
  let currentBin = initialBinPath;
75
75
  let rollbackBin = null;
76
76
  let candidatePending = null;
@@ -99,7 +99,7 @@ async function superviseRuntime(configDir, initialBinPath, workerArgs = [], heal
99
99
  }
100
100
  while (!stopping) {
101
101
  const candidateThisRun = awaitingCandidateHealth;
102
- const result = await runWorker(currentBin, workerArgs, candidateThisRun ? healthTimeoutMs : null, (child) => {
102
+ const result = await runWorker(currentBin, workerArgs, candidateThisRun ? healthTimeoutMs : null, workerEnv, (child) => {
103
103
  currentChild = child;
104
104
  }, async () => {
105
105
  if (candidateThisRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
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",