@sagentlab/navarch-runtime 0.1.137 → 0.1.139

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.
@@ -22,18 +22,20 @@ const node_path_1 = __importDefault(require("node:path"));
22
22
  const BROWSER_USE_RUNTIMES = ["codex", "claude-code", "antigravity", "opencode", "acp", "muse"];
23
23
  /**
24
24
  * Which navarch_browser tools a host session gets. `browserUse` adds capture
25
- * and local_browser_* for browser-use tasks. `playwrightSuites` adds
26
- * start_playwright_browser to every macOS Codex build/verify/qa session,
27
- * because Codex's seatbelt cannot launch the project's own Playwright suite's
28
- * Chromium whether or not the task asked for browser-use.
25
+ * and local_browser_* for browser-use build/verify/qa tasks. `playwrightSuites`
26
+ * adds start_playwright_browser to every macOS Codex build/verify/qa/ops
27
+ * session, because Codex's seatbelt cannot launch the project's own Playwright
28
+ * suite's Chromium whether or not the task asked for browser-use. Ops tasks
29
+ * diagnose suite failures too (ShiftSwap issue #154).
29
30
  */
30
31
  function navarchBrowserTools(input) {
31
- if (input.mcpConfigPath || input.sandboxMode !== "host" || !["build", "verify", "qa"].includes(input.taskType)) {
32
+ if (input.mcpConfigPath || input.sandboxMode !== "host")
32
33
  return { browserUse: false, playwrightSuites: false };
33
- }
34
34
  return {
35
- browserUse: input.requiredCapabilities?.includes("browser-use") === true && BROWSER_USE_RUNTIMES.includes(input.runtime),
36
- playwrightSuites: input.runtime === "codex" && input.platform === "darwin",
35
+ browserUse: ["build", "verify", "qa"].includes(input.taskType)
36
+ && input.requiredCapabilities?.includes("browser-use") === true && BROWSER_USE_RUNTIMES.includes(input.runtime),
37
+ playwrightSuites: ["build", "verify", "qa", "ops"].includes(input.taskType)
38
+ && input.runtime === "codex" && input.platform === "darwin",
37
39
  };
38
40
  }
39
41
  async function installEvidenceBrowser() {
@@ -214,23 +216,25 @@ async function startBrowserEvidence(options) {
214
216
  }
215
217
  const server = new mcp_js_1.McpServer({ name: "navarch-browser", version: "1.0.0" });
216
218
  const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
217
- server.registerTool("capture_visual_evidence", {
218
- description: "Capture and upload desktop and mobile screenshots using Navarch's managed local browser. Works outside the coding-agent sandbox. Returns durable artifact URLs. Capture does not certify acceptance or that the URL serves the supplied commit. Evidence is automatically attached to the delivery PR at session completion.",
219
- inputSchema: captureShape,
220
- }, async (args) => {
221
- try {
222
- if (busy)
223
- throw new Error("A capture is already running. Wait for it to finish.");
224
- const pending = capture(args);
225
- activeCapture = pending;
226
- const { captured, images } = await pending;
227
- return { content: [{ type: "text", text: JSON.stringify(captured) }, ...images] };
228
- }
229
- catch (error) {
230
- return { isError: true, content: [{ type: "text", text: String(error) }] };
231
- }
232
- });
233
- (0, local_browser_cjs_1.registerLocalBrowserTools)(server, localBrowser);
219
+ if (options.browserUse !== false) {
220
+ server.registerTool("capture_visual_evidence", {
221
+ description: "Capture and upload desktop and mobile screenshots using Navarch's managed local browser. Works outside the coding-agent sandbox. Returns durable artifact URLs. Capture does not certify acceptance or that the URL serves the supplied commit. Evidence is automatically attached to the delivery PR at session completion.",
222
+ inputSchema: captureShape,
223
+ }, async (args) => {
224
+ try {
225
+ if (busy)
226
+ throw new Error("A capture is already running. Wait for it to finish.");
227
+ const pending = capture(args);
228
+ activeCapture = pending;
229
+ const { captured, images } = await pending;
230
+ return { content: [{ type: "text", text: JSON.stringify(captured) }, ...images] };
231
+ }
232
+ catch (error) {
233
+ return { isError: true, content: [{ type: "text", text: String(error) }] };
234
+ }
235
+ });
236
+ (0, local_browser_cjs_1.registerLocalBrowserTools)(server, localBrowser);
237
+ }
234
238
  if (options.playwrightServer)
235
239
  (0, playwright_server_cjs_1.registerPlaywrightServerTool)(server, options.playwrightServer);
236
240
  res.on("close", () => { void transport.close(); void server.close(); });
@@ -48,6 +48,8 @@ class GitWorktree {
48
48
  checkpointRoot;
49
49
  cloneUrl;
50
50
  githubToken;
51
+ githubTokenProvider;
52
+ repoLocalTokenInstalled = false;
51
53
  gitGrant;
52
54
  workingBranch;
53
55
  repoLocalGithubToken;
@@ -92,6 +94,7 @@ class GitWorktree {
92
94
  ? proxyCloneUrl(options.cloneUrl, options.gitGrant.proxyBaseUrl)
93
95
  : options.cloneUrl;
94
96
  this.githubToken = options.gitGrant ? undefined : options.githubToken;
97
+ this.githubTokenProvider = options.gitGrant ? undefined : options.githubTokenProvider;
95
98
  this.workingBranch = options.workingBranch?.trim() || null;
96
99
  this.repoLocalGithubToken = options.repoLocalGithubToken;
97
100
  this.prContinuation = options.prContinuation ?? false;
@@ -135,13 +138,44 @@ class GitWorktree {
135
138
  async installRepoLocalGithubToken() {
136
139
  if (!this.repoLocalGithubToken)
137
140
  return;
138
- if (/[\0\r]/.test(this.repoLocalGithubToken)) {
141
+ const token = this.githubTokenProvider?.() ?? this.repoLocalGithubToken;
142
+ if (/[\0\r\n]/.test(token)) {
139
143
  throw new Error("Broker-issued GitHub credential contains unsupported control characters.");
140
144
  }
141
145
  const configPath = node_path_1.default.join(this.repositoryPath, "config");
142
- const block = repoLocalGithubTokenBlock(this.sessionTokenMarker, this.repoLocalGithubToken);
146
+ const block = repoLocalGithubTokenBlock(this.sessionTokenMarker, token);
143
147
  await node_fs_1.promises.chmod(configPath, 0o600);
144
148
  await node_fs_1.promises.appendFile(configPath, block, { encoding: "utf8", mode: 0o600 });
149
+ this.repoLocalTokenInstalled = true;
150
+ }
151
+ /** Serializes rotation with prepare/cleanup; never recreates a removed repository. */
152
+ async refreshRepoLocalGithubToken(token) {
153
+ if (!this.repoLocalGithubToken)
154
+ return;
155
+ if (/[\0\r\n]/.test(token))
156
+ throw new Error("Invalid refreshed GitHub credential");
157
+ await withRepositoryLock(this.repositoryPath, async () => {
158
+ if (!this.repoLocalTokenInstalled)
159
+ return;
160
+ const configPath = node_path_1.default.join(this.repositoryPath, "config");
161
+ // Cooperate with git config writers in the agent as well as parent operations.
162
+ const lockPath = `${configPath}.lock`;
163
+ const lock = await node_fs_1.promises.open(lockPath, "wx", 0o600);
164
+ let renamed = false;
165
+ try {
166
+ const config = await node_fs_1.promises.readFile(configPath, "utf8");
167
+ const clean = removeMarkedConfigBlock(config, this.sessionTokenMarker);
168
+ await lock.writeFile(clean + repoLocalGithubTokenBlock(this.sessionTokenMarker, token), "utf8");
169
+ await lock.close();
170
+ await node_fs_1.promises.rename(lockPath, configPath);
171
+ renamed = true;
172
+ }
173
+ finally {
174
+ await lock.close().catch(() => undefined);
175
+ if (!renamed)
176
+ await node_fs_1.promises.rm(lockPath, { force: true });
177
+ }
178
+ });
145
179
  }
146
180
  /**
147
181
  * Installs the delivery-branch guard shared by every worktree of this cache.
@@ -688,6 +722,7 @@ class GitWorktree {
688
722
  async removeRepoLocalGithubToken() {
689
723
  if (!this.repoLocalGithubToken)
690
724
  return;
725
+ this.repoLocalTokenInstalled = false;
691
726
  const configPath = node_path_1.default.join(this.repositoryPath, "config");
692
727
  try {
693
728
  const config = await node_fs_1.promises.readFile(configPath, "utf8");
@@ -710,7 +745,8 @@ class GitWorktree {
710
745
  }
711
746
  return grantResult;
712
747
  }
713
- const credentialArgs = authenticated && this.githubToken
748
+ const githubToken = this.githubTokenProvider ? this.githubTokenProvider() : this.githubToken;
749
+ const credentialArgs = authenticated && githubToken
714
750
  ? [
715
751
  "-c",
716
752
  "credential.helper=",
@@ -719,8 +755,8 @@ class GitWorktree {
719
755
  ]
720
756
  : [];
721
757
  const result = await this.runner.run("git", [...credentialArgs, ...args], {
722
- env: this.githubToken
723
- ? { ...process.env, GITHUB_TOKEN: this.githubToken, GIT_TERMINAL_PROMPT: "0" }
758
+ env: githubToken
759
+ ? { ...process.env, GITHUB_TOKEN: githubToken, GIT_TERMINAL_PROMPT: "0" }
724
760
  : { ...process.env, GIT_TERMINAL_PROMPT: "0" },
725
761
  });
726
762
  if (result.code !== 0) {
@@ -52,14 +52,15 @@ exports.GITHUB_TOKEN_FILE_REFRESH_MS = 10 * 60 * 1000;
52
52
  exports.GITHUB_TOKEN_FILE_RETRY_MS = 60 * 1000;
53
53
  exports.GITHUB_TOKEN_FILENAME = "github-token";
54
54
  /**
55
- * Keeps a managed repository's installation token in a session file for
56
- * agents whose OS sandbox cannot reach the Navarch API (the Codex worktree
57
- * guard allows only GitHub hosts). The runtime parent fetches from the broker,
58
- * so the machine token never enters the agent environment, and the credential
59
- * helper only reads the file. Mirrors GitGrantRefresher.
55
+ * Keeps managed repository credentials current for git, gh, and parent operations.
56
+ * The runtime parent fetches from the broker, so the machine token never enters
57
+ * the agent environment. Helpers and gh only read session-owned files, including
58
+ * under the Codex worktree guard. Mirrors GitGrantRefresher.
60
59
  */
61
60
  class GithubTokenFileRefresher {
62
61
  options;
62
+ token;
63
+ get current() { return this.token; }
63
64
  timer = null;
64
65
  stopped = false;
65
66
  inflight = null;
@@ -91,6 +92,10 @@ class GithubTokenFileRefresher {
91
92
  this.timer = null;
92
93
  await this.inflight?.catch(() => undefined);
93
94
  await promises_1.default.rm(this.options.tokenFile, { force: true }).catch(() => undefined);
95
+ if (this.options.ghConfigDir) {
96
+ await promises_1.default.rm(node_path_1.default.join(this.options.ghConfigDir, "hosts.yml"), { force: true }).catch(() => undefined);
97
+ }
98
+ this.token = undefined;
94
99
  }
95
100
  async write(token) {
96
101
  // Register before writing so a redaction pass never misses a live value.
@@ -99,6 +104,16 @@ class GithubTokenFileRefresher {
99
104
  throw new Error("github token contains unsupported control characters");
100
105
  await promises_1.default.mkdir(node_path_1.default.dirname(this.options.tokenFile), { recursive: true, mode: 0o700 });
101
106
  await (0, git_grant_cjs_1.writePrivateFile)(this.options.tokenFile, `${token}\n`);
107
+ if (this.options.ghConfigDir) {
108
+ await promises_1.default.mkdir(this.options.ghConfigDir, { recursive: true, mode: 0o700 });
109
+ // gh otherwise tries to migrate (and rewrite) hosts.yml inside the read-only sandbox.
110
+ await promises_1.default.writeFile(node_path_1.default.join(this.options.ghConfigDir, "config.yml"), "version: 1\n", { mode: 0o600, flag: "wx" })
111
+ .catch((error) => { if (error.code !== "EEXIST")
112
+ throw error; });
113
+ await (0, git_grant_cjs_1.writePrivateFile)(node_path_1.default.join(this.options.ghConfigDir, "hosts.yml"), (0, git_grant_cjs_1.renderGhHostsYml)("github.com", token));
114
+ }
115
+ this.token = token;
116
+ await this.options.onToken?.(token);
102
117
  }
103
118
  schedule(delayMs) {
104
119
  if (this.stopped)
@@ -259,7 +259,11 @@ function createPlaywrightServer(options) {
259
259
  canonicalCore,
260
260
  token,
261
261
  ];
262
- const childEnv = { TMPDIR: params.PRIVATE };
262
+ // Chromium's macOS temp dir ignores TMPDIR and falls back to the user's
263
+ // /var/folders/.../T, which the profile does not let it write. Every
264
+ // download creates a temp file there first, so without this each one
265
+ // ended `canceled` (ShiftSwap issue #154).
266
+ const childEnv = { TMPDIR: params.PRIVATE, MAC_CHROMIUM_TMPDIR: params.PRIVATE };
263
267
  for (const name of ["HOME", "PATH", "LANG", "PLAYWRIGHT_BROWSERS_PATH"]) {
264
268
  if (env[name] !== undefined)
265
269
  childEnv[name] = env[name];
package/dist/session.cjs CHANGED
@@ -106,13 +106,19 @@ function resolveSessionExecution(config, claimed) {
106
106
  * isolation instead; see runtime/README.md.
107
107
  */
108
108
  async function runSession(deps, claimed, sessionId) {
109
- const lifecycle = { enteredMainSessionTry: false };
109
+ const lifecycle = { enteredMainSessionTry: false, refreshers: [] };
110
110
  try {
111
111
  return await runClaimedSession(deps, claimed, sessionId, lifecycle);
112
112
  }
113
113
  catch (err) {
114
114
  if (lifecycle.enteredMainSessionTry)
115
115
  throw err;
116
+ // Stop credential refreshers first: they would otherwise keep calling the
117
+ // broker for the lease completed below and recreate the removed files.
118
+ // stopAndClean waits for an in-flight refresh and is idempotent.
119
+ for (const refresher of lifecycle.refreshers) {
120
+ await refresher.stopAndClean().catch((stopErr) => log.warn(`credential refresher cleanup failed after initialization crash: ${String(stopErr)}`));
121
+ }
116
122
  // Initialization before the main worktree/adapter try (prompt creation,
117
123
  // broker access, MCP/guard setup) must still release the lease with the
118
124
  // explicit pre-start signal. Otherwise it waits for expiry and bypasses
@@ -192,6 +198,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
192
198
  const codexGuarded = config.sandboxMode === "host" && config.worktreeGuard && runtime === "codex";
193
199
  const gitCredentialDir = (0, worktree_guard_cjs_1.sessionGitCredentialDir)(workDir);
194
200
  let githubTokenRefresher;
201
+ const credentialConsumer = {};
195
202
  let gitGrantRefresher;
196
203
  let gitGrantEnv;
197
204
  if (bundleGitMode !== "off") {
@@ -211,6 +218,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
211
218
  const helperPath = node_path_1.default.join(gitCredentialDir, GIT_GRANT_HELPER_FILENAME);
212
219
  await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch-grant.cjs"), helperPath);
213
220
  gitGrantRefresher = refresher;
221
+ lifecycle.refreshers.push(refresher);
214
222
  gitGrantEnv = {
215
223
  proxyHost: issue.proxy_host,
216
224
  proxyBaseUrl: issue.proxy_base_url,
@@ -267,20 +275,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
267
275
  await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
268
276
  const helperPath = node_path_1.default.join(gitCredentialDir, GIT_CREDENTIAL_HELPER_FILENAME);
269
277
  await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch.cjs"), helperPath);
270
- if (codexGuarded) {
271
- const tokenFile = node_path_1.default.join(gitCredentialDir, github_credential_cjs_1.GITHUB_TOKEN_FILENAME);
272
- githubTokenRefresher = new github_credential_cjs_1.GithubTokenFileRefresher({ api, leaseId, tokenFile, registry, log });
273
- await githubTokenRefresher.start(secrets["github-pat"]);
274
- gitCredentialRefresh = { helperPath, tokenFile };
275
- }
276
- else {
277
- gitCredentialRefresh = {
278
- helperPath,
279
- apiBase: api.getBaseUrl(),
280
- machineToken: api.getToken() ?? "",
281
- leaseId,
282
- };
283
- }
278
+ const tokenFile = node_path_1.default.join(gitCredentialDir, github_credential_cjs_1.GITHUB_TOKEN_FILENAME);
279
+ githubTokenRefresher = new github_credential_cjs_1.GithubTokenFileRefresher({
280
+ api, leaseId, tokenFile, registry, log,
281
+ ghConfigDir: (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir),
282
+ onToken: async (token) => { await credentialConsumer.worktree?.refreshRepoLocalGithubToken(token); },
283
+ });
284
+ lifecycle.refreshers.push(githubTokenRefresher);
285
+ await githubTokenRefresher.start(secrets["github-pat"]);
286
+ gitCredentialRefresh = { helperPath, tokenFile };
284
287
  }
285
288
  const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail, gitGrantEnv);
286
289
  // Proxied projects (CX-3.7): the control-plane identity replaces the
@@ -343,6 +346,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
343
346
  sessionId,
344
347
  cloneUrl,
345
348
  githubToken,
349
+ githubTokenProvider: githubTokenRefresher ? () => githubTokenRefresher.current : undefined,
346
350
  workingBranch: bundle.repository?.working_branch,
347
351
  // Grant modes never expose a GitHub token, so the Codex repo-local
348
352
  // github-pat bootstrap is not installed.
@@ -357,6 +361,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
357
361
  restoreHostedCheckpoint: !allowBranchRename && task.task_type !== "verify",
358
362
  ...(commitIdentity ? { ownershipCommitIdentity: commitIdentity.committer } : {}),
359
363
  });
364
+ credentialConsumer.worktree = gitWorktree;
360
365
  const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
361
366
  const deliveredGuidance = [...(bundle.guidance ?? [])];
362
367
  let guidanceCursor = bundle.guidance_cursor ?? null;
@@ -726,7 +731,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
726
731
  registry.register(playwrightServer.token);
727
732
  browserEvidence = await (0, browser_evidence_cjs_1.startBrowserEvidence)({
728
733
  apiBase: api.getBaseUrl(), machineToken: api.getToken() ?? "", leaseId,
729
- isActive, playwrightServer,
734
+ isActive, playwrightServer, browserUse,
730
735
  });
731
736
  registry.register(browserEvidence.token);
732
737
  const mcp = JSON.parse(await node_fs_1.promises.readFile(mcpConfigPath, "utf8"));
@@ -1159,7 +1164,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1159
1164
  // Keep SHA verification on the exact branch, but do not adopt a
1160
1165
  // foreign-branch PR as delivery evidence for a root task.
1161
1166
  allowShaFallback: allowBranchRename,
1162
- githubToken: gitGrantMode ? gitGrantRefresher?.current?.grant : githubToken,
1167
+ githubToken: gitGrantMode ? gitGrantRefresher?.current?.grant : githubTokenRefresher?.current ?? githubToken,
1163
1168
  // Grant modes read through the proxy and never use the host's own gh login.
1164
1169
  apiBaseUrl: gitGrantEnv ? `${gitGrantEnv.proxyBaseUrl.replace(/\/+$/, "")}/api/v3` : undefined,
1165
1170
  hostTokenProvider: gitGrantMode ? async () => undefined : undefined,
@@ -1698,11 +1703,15 @@ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail =
1698
1703
  ["user.name", gitAuthorName],
1699
1704
  ["user.email", resolvedGitAuthorEmail],
1700
1705
  ];
1701
- if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
1706
+ if (secrets["github-pat"] && (!out.GITHUB_TOKEN || (gitCredentialRefresh && "tokenFile" in gitCredentialRefresh))) {
1702
1707
  out.GITHUB_TOKEN = secrets["github-pat"];
1703
1708
  if (gitCredentialRefresh) {
1704
1709
  if ("tokenFile" in gitCredentialRefresh) {
1705
1710
  out.NAVARCH_GIT_CREDENTIAL_TOKEN_FILE = gitCredentialRefresh.tokenFile;
1711
+ // gh must read the rotating hosts.yml, including when a parent has GH_TOKEN.
1712
+ out.GITHUB_TOKEN = "";
1713
+ out.GH_TOKEN = "";
1714
+ out.GH_HOST = "github.com";
1706
1715
  }
1707
1716
  else {
1708
1717
  out.NAVARCH_GIT_CREDENTIAL_API_BASE = gitCredentialRefresh.apiBase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.137",
3
+ "version": "0.1.139",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",