@sagentlab/navarch-runtime 0.1.21 → 0.1.23

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.
@@ -5,6 +5,7 @@ exports.extractUsageFromClaudeJson = extractUsageFromClaudeJson;
5
5
  exports.parseCodexJsonEvents = parseCodexJsonEvents;
6
6
  exports.extractUsageFromCodexEvents = extractUsageFromCodexEvents;
7
7
  exports.extractFinalMessageFromCodexEvents = extractFinalMessageFromCodexEvents;
8
+ exports.leadingVerificationVerdict = leadingVerificationVerdict;
8
9
  exports.mapExitCondition = mapExitCondition;
9
10
  /**
10
11
  * Best-effort parse of `claude -p --output-format json` stdout into the
@@ -158,6 +159,21 @@ function extractFinalMessageFromCodexEvents(events) {
158
159
  }
159
160
  return null;
160
161
  }
162
+ /**
163
+ * Parses the leading `Verification verdict:` line convention shared with the
164
+ * control plane (lib/navarch/verification-verdict.ts). Verify completions
165
+ * must open with `Verification verdict: PASS|FAIL|BLOCKED`, and the
166
+ * completion API rejects a completion whose verdict contradicts its lease
167
+ * outcome, keeping the lease active.
168
+ */
169
+ function leadingVerificationVerdict(report) {
170
+ const firstLine = report
171
+ .split(/\r?\n/)
172
+ .map((line) => line.trim())
173
+ .find(Boolean);
174
+ const match = /^Verification verdict:\s*(PASS|FAIL|BLOCKED)\b/i.exec(firstLine ?? "");
175
+ return match?.[1] ? match[1].toLowerCase() : null;
176
+ }
161
177
  function summarize(text, maxLen = 500) {
162
178
  const trimmed = text.trim();
163
179
  if (!trimmed)
@@ -234,6 +250,19 @@ function mapExitCondition(result) {
234
250
  // parseClaudeJsonResult's doc comment).
235
251
  const reportText = result.reportText ?? parsedJson?.result ?? result.stdout;
236
252
  const reportSummary = summarize(reportText) || "Adapter completed with no report text.";
253
+ // A verify agent's FAIL/BLOCKED verdict is a successful agent run whose
254
+ // conclusion is that the delivery did not pass. The completion API only
255
+ // accepts those verdicts with a failed outcome, so map them to a failed
256
+ // lease instead of posting a "completed" the control plane must reject.
257
+ const verdict = leadingVerificationVerdict(reportText);
258
+ if (verdict === "fail" || verdict === "blocked") {
259
+ return {
260
+ leaseOutcome: "failed",
261
+ exitStatus: "failed",
262
+ reportSummary,
263
+ evidenceUrls,
264
+ };
265
+ }
237
266
  // Headless agent CLIs normally exit 0 after producing a final response,
238
267
  // including when that response says the task could not start. Treat an
239
268
  // explicit leading blocked verdict as a failed lease so the dispatcher can
@@ -11,9 +11,9 @@ const sandbox_cjs_1 = require("./sandbox.cjs");
11
11
  const repositoryLocks = new Map();
12
12
  /**
13
13
  * Maintains one bare repository cache per project and checks out each session
14
- * into its own uniquely named worktree. The cache avoids N full clones while
15
- * git's worktree metadata keeps concurrent agents from sharing an index or
16
- * working directory.
14
+ * into its own uniquely named worktree. Sessions that need a repo-local secret
15
+ * use an isolated bare repository under their session root because linked
16
+ * worktrees share their repository-local config.
17
17
  */
18
18
  class GitWorktree {
19
19
  sessionRoot;
@@ -23,16 +23,20 @@ class GitWorktree {
23
23
  runner;
24
24
  cloneUrl;
25
25
  githubToken;
26
+ repoLocalGithubToken;
26
27
  taskBranchSuffix;
27
28
  legacyTaskBranchSuffix;
28
29
  taskOwnershipTrailer;
30
+ sessionTokenMarker;
29
31
  constructor(options) {
30
32
  const projectKey = safePathSegment(options.projectId);
31
33
  const sessionKey = safePathSegment(options.sessionId);
32
34
  const taskKey = safePathSegment(options.taskId);
33
35
  this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
34
36
  this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
35
- this.repositoryPath = node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
37
+ this.repositoryPath = options.repoLocalGithubToken
38
+ ? node_path_1.default.join(this.sessionRoot, "repository.git")
39
+ : node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
36
40
  // One delivery task owns one remote branch across every retry. A
37
41
  // session-scoped branch lets a failed attempt push useful work, then makes
38
42
  // the retry start from main and open a second PR for the same task.
@@ -40,10 +44,12 @@ class GitWorktree {
40
44
  this.taskBranchSuffix = `-${normalizedTaskId.slice(0, 8)}`;
41
45
  this.legacyTaskBranchSuffix = `-${normalizedTaskId}`;
42
46
  this.taskOwnershipTrailer = `Navarch-Task-ID: ${normalizedTaskId}`;
47
+ this.sessionTokenMarker = `navarch-session-github-token:${sessionKey}`;
43
48
  this.branch = `navarch/${branchSlug(options)}${this.taskBranchSuffix}`;
44
49
  this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
45
50
  this.cloneUrl = options.cloneUrl;
46
51
  this.githubToken = options.githubToken;
52
+ this.repoLocalGithubToken = options.repoLocalGithubToken;
47
53
  }
48
54
  async prepare() {
49
55
  await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.repositoryPath), { recursive: true });
@@ -63,8 +69,31 @@ class GitWorktree {
63
69
  `could not resolve the fetched start ref. The shared cache was preserved to avoid invalidating ` +
64
70
  `active worktrees; retry after active sessions finish or repair the cache in place: ${errorMessage(error)}`);
65
71
  }
72
+ await this.installRepoLocalGithubToken();
66
73
  });
67
74
  }
75
+ /**
76
+ * Makes a broker-issued GitHub credential visible to workflows that require
77
+ * `git config --local --get codex.githubToken`.
78
+ *
79
+ * Git ignores command-scope, worktree-scope, and included values when
80
+ * `--local` is requested, so the compatibility key has to live in the bare
81
+ * repository config. Token-bearing sessions use an isolated repository to
82
+ * keep concurrent worktrees from observing the value. The marked block lets
83
+ * cleanup remove exactly this session's value. The secret is written via fs
84
+ * and never passed in argv or emitted by the command runner.
85
+ */
86
+ async installRepoLocalGithubToken() {
87
+ if (!this.repoLocalGithubToken)
88
+ return;
89
+ if (/[\0\r]/.test(this.repoLocalGithubToken)) {
90
+ throw new Error("Broker-issued GitHub credential contains unsupported control characters.");
91
+ }
92
+ const configPath = node_path_1.default.join(this.repositoryPath, "config");
93
+ const block = repoLocalGithubTokenBlock(this.sessionTokenMarker, this.repoLocalGithubToken);
94
+ await node_fs_1.promises.chmod(configPath, 0o600);
95
+ await node_fs_1.promises.appendFile(configPath, block, { encoding: "utf8", mode: 0o600 });
96
+ }
68
97
  /** Clones the bare cache if missing, repoints origin if needed, and fetches all branches. */
69
98
  async ensureRepositoryCache() {
70
99
  if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
@@ -231,6 +260,7 @@ class GitWorktree {
231
260
  }
232
261
  async cleanup() {
233
262
  await withRepositoryLock(this.repositoryPath, async () => {
263
+ await this.removeRepoLocalGithubToken();
234
264
  await this.runner
235
265
  .run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", this.worktreePath])
236
266
  .catch(() => undefined);
@@ -242,6 +272,21 @@ class GitWorktree {
242
272
  .catch(() => undefined);
243
273
  });
244
274
  }
275
+ async removeRepoLocalGithubToken() {
276
+ if (!this.repoLocalGithubToken)
277
+ return;
278
+ const configPath = node_path_1.default.join(this.repositoryPath, "config");
279
+ try {
280
+ const config = await node_fs_1.promises.readFile(configPath, "utf8");
281
+ const cleaned = removeMarkedConfigBlock(config, this.sessionTokenMarker);
282
+ if (cleaned !== config)
283
+ await node_fs_1.promises.writeFile(configPath, cleaned, { mode: 0o600 });
284
+ }
285
+ catch (error) {
286
+ if (error.code !== "ENOENT")
287
+ throw error;
288
+ }
289
+ }
245
290
  async runGit(args, authenticated) {
246
291
  const credentialArgs = authenticated && this.githubToken
247
292
  ? [
@@ -263,6 +308,31 @@ class GitWorktree {
263
308
  }
264
309
  }
265
310
  exports.GitWorktree = GitWorktree;
311
+ function repoLocalGithubTokenBlock(marker, token) {
312
+ const escaped = token
313
+ .replace(/\\/g, "\\\\")
314
+ .replace(/"/g, '\\"')
315
+ .replace(/\n/g, "\\n")
316
+ .replace(/\t/g, "\\t")
317
+ .replace(/\u0008/g, "\\b");
318
+ return (`\n# ${marker}:begin\n` +
319
+ `[codex]\n` +
320
+ `\tgithubToken = "${escaped}"\n` +
321
+ `# ${marker}:end\n`);
322
+ }
323
+ function removeMarkedConfigBlock(config, marker) {
324
+ const begin = `# ${marker}:begin`;
325
+ const end = `# ${marker}:end`;
326
+ const start = config.indexOf(begin);
327
+ if (start < 0)
328
+ return config;
329
+ const blockStart = start > 0 && config[start - 1] === "\n" ? start - 1 : start;
330
+ const endStart = config.indexOf(end, start + begin.length);
331
+ if (endStart < 0)
332
+ return config;
333
+ const endNewline = config.indexOf("\n", endStart + end.length);
334
+ return config.slice(0, blockStart) + config.slice(endNewline < 0 ? config.length : endNewline + 1);
335
+ }
266
336
  async function withRepositoryLock(key, work) {
267
337
  const previous = repositoryLocks.get(key) ?? Promise.resolve();
268
338
  let release;
package/dist/session.cjs CHANGED
@@ -71,9 +71,11 @@ async function runSession(deps, claimed, sessionId) {
71
71
  await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
72
72
  const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
73
73
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
74
- // Secrets: initially fetched once, held only in memory (registry + env map below),
75
- // never written to disk on the host. They only ever reach disk inside the
76
- // sandbox's tmpfs (sandbox.cts injectEnv), which is wiped with the container.
74
+ // Secrets: initially fetched once and held in the registry + child env map.
75
+ // The broker-issued github-pat is additionally installed in the project-local
76
+ // git config after worktree creation for the mandated Codex gh bootstrap;
77
+ // GitWorktree.cleanup removes that session-marked entry before teardown.
78
+ // Docker env injection remains tmpfs-backed (sandbox.cts injectEnv).
77
79
  const registry = new redact_cjs_1.SecretRegistry();
78
80
  let secrets = {};
79
81
  let managedGithubCredential = false;
@@ -105,7 +107,7 @@ async function runSession(deps, claimed, sessionId) {
105
107
  const failureSummary = `Project ${task.project_id} has no GitHub repository URL. Set it in Project settings before dispatching work.`;
106
108
  await api.completeLease(leaseId, {
107
109
  status: "failed",
108
- report: failureSummary,
110
+ report: verificationFailureReport(task.task_type, failureSummary),
109
111
  failure_summary: failureSummary,
110
112
  evidence_urls: [],
111
113
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
@@ -127,6 +129,7 @@ async function runSession(deps, claimed, sessionId) {
127
129
  sessionId,
128
130
  cloneUrl,
129
131
  githubToken,
132
+ repoLocalGithubToken: secrets["github-pat"],
130
133
  });
131
134
  const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
132
135
  const deliveredGuidance = [...(bundle.guidance ?? [])];
@@ -178,7 +181,7 @@ async function runSession(deps, claimed, sessionId) {
178
181
  await api
179
182
  .completeLease(leaseId, {
180
183
  status: "failed",
181
- report: "Docker sandbox unavailable on this machine.",
184
+ report: verificationFailureReport(task.task_type, "Docker sandbox unavailable on this machine."),
182
185
  failure_summary: "Docker sandbox unavailable on this machine.",
183
186
  evidence_urls: [],
184
187
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
@@ -392,9 +395,12 @@ async function runSession(deps, claimed, sessionId) {
392
395
  // redact it like every other warn in this block.
393
396
  log.warn(`transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
394
397
  }
398
+ const redactedReport = (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets);
395
399
  const completion = {
396
400
  status: mapping.leaseOutcome,
397
- report: (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets),
401
+ report: mapping.leaseOutcome === "failed"
402
+ ? verificationFailureReport(task.task_type, redactedReport)
403
+ : redactedReport,
398
404
  evidence_urls: mapping.evidenceUrls,
399
405
  cost: {
400
406
  ...(result.tokensIn !== undefined ? { tokens_in: result.tokensIn } : {}),
@@ -445,9 +451,9 @@ async function runSession(deps, claimed, sessionId) {
445
451
  await api.completeLease(leaseId, {
446
452
  ...completion,
447
453
  status: "failed",
448
- report: remediable
454
+ report: verificationFailureReport(task.task_type, remediable
449
455
  ? redactedRejection
450
- : `${redactedRejection}\n\n---\n\n${completion.report}`,
456
+ : `${redactedRejection}\n\n---\n\n${completion.report}`),
451
457
  failure_summary: redactedRejection,
452
458
  exit_status: "failed",
453
459
  });
@@ -479,9 +485,9 @@ async function runSession(deps, claimed, sessionId) {
479
485
  await api
480
486
  .completeLease(leaseId, {
481
487
  status: "failed",
482
- report: lastCompletion
488
+ report: verificationFailureReport(task.task_type, lastCompletion
483
489
  ? `${lastCompletion.report}\n\n---\n\n${failureSummary}`
484
- : failureSummary,
490
+ : failureSummary),
485
491
  failure_summary: failureSummary,
486
492
  evidence_urls: lastCompletion?.evidence_urls ?? [],
487
493
  cost: lastCompletion?.cost ?? { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
@@ -522,6 +528,24 @@ function sumReportedUsage(attempts, key) {
522
528
  });
523
529
  return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
524
530
  }
531
+ /**
532
+ * The control plane refuses to release a verify lease whose failed completion
533
+ * does not open with `Verification verdict: FAIL` or `BLOCKED`
534
+ * (verification_verdict_invalid), and that gate applies to runtime-authored
535
+ * failure reports too. When the report carries no usable verdict — the agent
536
+ * crashed, timed out, exited non-zero, or opened with PASS despite the failed
537
+ * outcome — label it BLOCKED: the failure prevented an acceptance decision.
538
+ * Without this, the failure completion is itself rejected, the session
539
+ * crashes, and the lease dangles until expiry.
540
+ */
541
+ function verificationFailureReport(taskType, report) {
542
+ if (taskType !== "verify")
543
+ return report;
544
+ const verdict = (0, exit_conditions_cjs_1.leadingVerificationVerdict)(report);
545
+ if (verdict === "fail" || verdict === "blocked")
546
+ return report;
547
+ return `Verification verdict: BLOCKED — the session ended before verification reached an acceptance decision.\n\n${report}`;
548
+ }
525
549
  /** Rejection codes another agent turn in the same worktree can plausibly fix. */
526
550
  const REMEDIABLE_REJECTION_CODES = new Set([
527
551
  "pr_required",
@@ -533,6 +557,12 @@ const REMEDIABLE_REJECTION_CODES = new Set([
533
557
  // The marker is already on the right head; the reviewer only has to relabel
534
558
  // the body, which is exactly what a remediation turn can do.
535
559
  "review_evidence_mislabeled",
560
+ // A verify agent that exited zero without the mandated leading
561
+ // `Verification verdict:` line only has to restate its report; the
562
+ // rejection prose spells out the exact format. Failed outcomes never reach
563
+ // remediation (see the leaseOutcome === "completed" gate) — their reports
564
+ // are verdict-labelled by verificationFailureReport before posting.
565
+ "verification_verdict_invalid",
536
566
  ]);
537
567
  /**
538
568
  * A 409 the control plane raised to reject *this* completion's contents (as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",