@sagentlab/navarch-runtime 0.1.78 → 0.1.80

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
@@ -22,6 +22,26 @@ are returned as capture errors; the runtime does not relax host permissions.
22
22
  Docker sessions continue to use their image's tooling, and an operator-supplied
23
23
  `NAVARCH_MCP_CONFIG_PATH` remains authoritative.
24
24
 
25
+ ### Browser capability probe
26
+
27
+ `browser-use` is advertised only after a startup launch check passes. When
28
+ `start` boots, the runtime launches its headless Chromium on the host. Then,
29
+ for every advertised runtime that wraps agent shells in an OS sandbox
30
+ (currently Codex, unless it runs with `--dangerously-bypass-approvals-and-sandbox`
31
+ or `danger-full-access`), it launches Chromium again through `codex sandbox`
32
+ with the same permission policy a session gets. If either launch fails, the
33
+ runtime claims without `browser-use` and reports the reason on every machine
34
+ heartbeat; the Agents page shows it. The result is cached for the process
35
+ lifetime, so restart the agent after fixing the browser to re-probe.
36
+ `navarch-runtime doctor` runs the same check.
37
+
38
+ Codex's macOS seatbelt denies Chromium's Mach port registration
39
+ (`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`), so a
40
+ sandboxed Codex machine on macOS does not take browser tasks. Run those with
41
+ another agent such as Claude Code, or use Docker mode. Docker mode is not
42
+ probed because its image supplies the browser. Set `NAVARCH_BROWSER_PROBE=off`
43
+ to trust the configured capability list without a check.
44
+
25
45
  Call the tool with `url`, `source_commit_sha`, and `evidence_role` (`before` or
26
46
  `after`). Optional `wait_for_selector` and `settle_ms` let a scene finish loading.
27
47
  The URL's deployed SHA must be checked by the agent: the supplied SHA is metadata,
@@ -403,6 +423,7 @@ unchanged across the deployment.
403
423
  | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Persistent bare repo caches plus isolated per-session worktrees. |
404
424
  | `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
405
425
  | `NAVARCH_CAPABILITIES` | `shell,browser-use` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. Docker mode does not advertise browser use until the configured image provides it. |
426
+ | `NAVARCH_BROWSER_PROBE` | on | Advertise `browser-use` only after a startup headless-Chromium launch (host, plus each sandboxed runtime) succeeds. `off` trusts `NAVARCH_CAPABILITIES` without a check. |
406
427
  | `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
407
428
  | `NAVARCH_POLL_INTERVAL_MS` | `30000` | Claim-loop poll interval. |
408
429
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
@@ -920,3 +941,37 @@ over the project's session model for the selected adapter only; switching agent
920
941
  types does not reuse another adapter's pin. Reconnect without `--model` to return
921
942
  to project-controlled model selection. Older identities without a model keep
922
943
  using the control-plane execution policy.
944
+
945
+
946
+ ### Pi on your own machine
947
+
948
+ Select **Pi (local)** in Connect an agent, or set `NAVARCH_AGENT=pi-local`
949
+ and `NAVARCH_RUNTIMES=pi-local`. Use a current Pi CLI with native MCP support
950
+ and Node.js 22.19 or newer. Host mode requires `NAVARCH_WORKTREE_GUARD=off`:
951
+ Pi's shell tools do not enforce the runtime's worktree boundary. The UI's
952
+ connect and supervise commands include this opt-out. Use a dedicated worker.
953
+ Docker is not advertised for this adapter yet.
954
+
955
+ The initial model is `qwen3.8-flash-next-iq2_xs`, served by a local
956
+ OpenAI-compatible endpoint at `http://127.0.0.1:8080/v1`. Start that model server
957
+ separately. Set `NAVARCH_PI_LOCAL_BASE_URL` in the worker environment for a
958
+ different endpoint (for example, Ollama's `http://127.0.0.1:11434/v1`).
959
+ `NAVARCH_PI_BIN` overrides the executable; `NAVARCH_PI_EXTRA_ARGS` adds CLI
960
+ arguments. `--model` at connect pins this machine's model as for other agents.
961
+
962
+ Bare model IDs use the generated `local` provider. Exact `provider/model`
963
+ references use the machine's existing Pi catalog and credentials from
964
+ `~/.pi/agent/models.json` and `auth.json` (or `PI_CODING_AGENT_DIR`). Run
965
+ `pi --list-models` to inspect that catalog. The UI puts the local default first
966
+ and labels public cloud suggestions by provider; suggestions do not imply
967
+ credentials or access. Custom model IDs remain editable.
968
+
969
+ Per-session configuration lives in a temporary agent directory, including
970
+ Navarch's authenticated `mcp.json`. Pi runs without persisted sessions,
971
+ automatically loaded extensions, skills, or project configuration. Temporary
972
+ configuration is removed at exit. Hosted Pi keeps its separate `pi` runtime,
973
+ DeepSeek provider, and billing policy; local sessions use `pi-local` and the
974
+ project's `pi_local_model` policy.
975
+
976
+ Pi CLI and model configuration reference:
977
+ https://github.com/earendil-works/pi/tree/main/packages/coding-agent/docs
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runQoderAdapter = exports.qoderAdapter = exports.runAcpAdapter = exports.acpAdapter = exports.runPiAdapter = exports.piAdapter = exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
3
+ exports.runQoderAdapter = exports.qoderAdapter = exports.runAcpAdapter = exports.acpAdapter = exports.runPiAdapter = exports.piAdapter = exports.runLocalPiAdapter = exports.localPiAdapter = exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
4
4
  exports.selectAdapter = selectAdapter;
5
5
  const claude_cjs_1 = require("./claude.cjs");
6
6
  Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
@@ -14,6 +14,9 @@ Object.defineProperty(exports, "runGeminiAdapter", { enumerable: true, get: func
14
14
  const opencode_cjs_1 = require("./opencode.cjs");
15
15
  Object.defineProperty(exports, "openCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.openCodeAdapter; } });
16
16
  Object.defineProperty(exports, "runOpenCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.runOpenCodeAdapter; } });
17
+ const pi_local_cjs_1 = require("./pi-local.cjs");
18
+ Object.defineProperty(exports, "localPiAdapter", { enumerable: true, get: function () { return pi_local_cjs_1.localPiAdapter; } });
19
+ Object.defineProperty(exports, "runLocalPiAdapter", { enumerable: true, get: function () { return pi_local_cjs_1.runLocalPiAdapter; } });
17
20
  const pi_cjs_1 = require("./pi.cjs");
18
21
  Object.defineProperty(exports, "piAdapter", { enumerable: true, get: function () { return pi_cjs_1.piAdapter; } });
19
22
  Object.defineProperty(exports, "runPiAdapter", { enumerable: true, get: function () { return pi_cjs_1.runPiAdapter; } });
@@ -44,6 +47,8 @@ function selectAdapter(agentType) {
44
47
  return qoder_cjs_1.qoderAdapter;
45
48
  case "claude-code":
46
49
  return claude_cjs_1.claudeCodeAdapter;
50
+ case "pi-local":
51
+ return pi_local_cjs_1.localPiAdapter;
47
52
  case "pi":
48
53
  // Managed hosted runtime (#885): reachable only on a managed runner
49
54
  // executing a `runtime: "pi"` claim — config.cts never produces "pi"
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localPiAdapter = void 0;
4
+ exports.runLocalPiAdapter = runLocalPiAdapter;
5
+ const pi_cjs_1 = require("./pi.cjs");
6
+ /** BYO Pi: native MCP, operator provider catalog, and a local OpenAI-compatible default. */
7
+ async function runLocalPiAdapter(options) {
8
+ if (options.dockerExec)
9
+ throw new Error("Pi (local) requires host mode");
10
+ if (options.signal?.aborted)
11
+ return { exitCode: null, timedOut: false, killedByLeaseLoss: true, stdout: "", stderr: "" };
12
+ const model = options.model ?? "qwen3.8-flash-next-iq2_xs";
13
+ const args = ["--mode", "json", "--no-session", "--no-extensions", "--no-skills", "--no-approve", ...options.extraArgs,
14
+ "--model", model.includes("/") ? model : `local/${model}`];
15
+ if (options.reasoningEffort)
16
+ args.push("--thinking", options.reasoningEffort === "max" ? "xhigh" : options.reasoningEffort);
17
+ args.push("-p", options.prompt);
18
+ return (0, pi_cjs_1.attachPiOutput)(await (0, pi_cjs_1.runOnHost)(options, args, true));
19
+ }
20
+ exports.localPiAdapter = { agentType: "pi-local", run: runLocalPiAdapter };
@@ -8,7 +8,9 @@ exports.buildHostedPiBudgetExtension = buildHostedPiBudgetExtension;
8
8
  exports.resolveHostedPiModel = resolveHostedPiModel;
9
9
  exports.buildPiProviderConfig = buildPiProviderConfig;
10
10
  exports.parsePiJsonEvents = parsePiJsonEvents;
11
+ exports.attachPiOutput = attachPiOutput;
11
12
  exports.runPiAdapter = runPiAdapter;
13
+ exports.runOnHost = runOnHost;
12
14
  const node_child_process_1 = require("node:child_process");
13
15
  const node_fs_1 = require("node:fs");
14
16
  const node_os_1 = __importDefault(require("node:os"));
@@ -245,18 +247,51 @@ async function runPiAdapter(options) {
245
247
  : await runOnHost(options, args);
246
248
  return attachPiOutput(raw);
247
249
  }
248
- async function runOnHost(options, args) {
249
- // Isolated agent dir so the generated provider config never touches (or
250
- // reads) an operator's ~/.pi configuration/credentials.
250
+ async function runOnHost(options, args, local = false) {
251
+ // Hosted sessions never read operator auth; BYO sessions copy only provider
252
+ // config and credentials. Both paths clean up their isolated agent directory.
251
253
  const agentDir = await node_fs_1.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "navarch-pi-agent-"));
252
- await node_fs_1.promises.writeFile(node_path_1.default.join(agentDir, "models.json"), buildPiProviderConfig(), { mode: 0o600 });
253
- const budgetExtensionPath = node_path_1.default.join(agentDir, BUDGET_EXTENSION_NAME);
254
- await node_fs_1.promises.writeFile(budgetExtensionPath, buildHostedPiBudgetExtension(), {
255
- mode: 0o600,
256
- });
257
- const env = { ...options.env, PI_CODING_AGENT_DIR: agentDir };
258
- const boundedArgs = withBudgetExtension(args, budgetExtensionPath);
259
254
  try {
255
+ if (local) {
256
+ const sourceDir = options.env.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? node_path_1.default.join(node_os_1.default.homedir(), ".pi", "agent");
257
+ for (const name of ["models.json", "auth.json"]) {
258
+ try {
259
+ await node_fs_1.promises.copyFile(node_path_1.default.join(sourceDir, name), node_path_1.default.join(agentDir, name));
260
+ await node_fs_1.promises.chmod(node_path_1.default.join(agentDir, name), 0o600);
261
+ }
262
+ catch (error) {
263
+ if (error.code !== "ENOENT")
264
+ throw error;
265
+ }
266
+ }
267
+ const model = options.model ?? "qwen3.8-flash-next-iq2_xs";
268
+ if (!model.includes("/")) {
269
+ const configPath = node_path_1.default.join(agentDir, "models.json");
270
+ const config = JSON.parse(await node_fs_1.promises.readFile(configPath, "utf8").catch(() => "{}"));
271
+ config.providers ??= {};
272
+ // Explicit provider/model references use the machine's catalog unchanged.
273
+ config.providers.local = {
274
+ baseUrl: options.env.NAVARCH_PI_LOCAL_BASE_URL ?? process.env.NAVARCH_PI_LOCAL_BASE_URL ?? "http://127.0.0.1:8080/v1",
275
+ api: "openai-completions", apiKey: "local",
276
+ models: [{ id: model, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
277
+ };
278
+ await node_fs_1.promises.writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
279
+ }
280
+ if (options.mcpConfigPath) {
281
+ await node_fs_1.promises.copyFile(options.mcpConfigPath, node_path_1.default.join(agentDir, "mcp.json"));
282
+ await node_fs_1.promises.chmod(node_path_1.default.join(agentDir, "mcp.json"), 0o600);
283
+ }
284
+ }
285
+ else {
286
+ await node_fs_1.promises.writeFile(node_path_1.default.join(agentDir, "models.json"), buildPiProviderConfig(), { mode: 0o600 });
287
+ }
288
+ const budgetExtensionPath = node_path_1.default.join(agentDir, BUDGET_EXTENSION_NAME);
289
+ if (!local)
290
+ await node_fs_1.promises.writeFile(budgetExtensionPath, buildHostedPiBudgetExtension(), {
291
+ mode: 0o600,
292
+ });
293
+ const env = { ...options.env, PI_CODING_AGENT_DIR: agentDir };
294
+ const boundedArgs = local ? args : withBudgetExtension(args, budgetExtensionPath);
260
295
  return await new Promise((resolve) => {
261
296
  let stdout = "";
262
297
  let stderr = "";
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.captureSchema = void 0;
7
7
  exports.installEvidenceBrowser = installEvidenceBrowser;
8
+ exports.launchCaptureBrowser = launchCaptureBrowser;
8
9
  exports.startBrowserEvidence = startBrowserEvidence;
9
10
  const node_http_1 = require("node:http");
10
11
  const node_crypto_1 = require("node:crypto");
@@ -21,6 +22,17 @@ async function installEvidenceBrowser() {
21
22
  timeout: 180_000,
22
23
  });
23
24
  }
25
+ /** The runtime's own headless Chromium, shared by evidence capture and the startup probe. */
26
+ function launchCaptureBrowser() {
27
+ return playwright_1.chromium.launch({
28
+ headless: true,
29
+ chromiumSandbox: true,
30
+ timeout: 20_000,
31
+ // Do not pass the supervisor's provider or machine credentials to Chrome.
32
+ env: Object.fromEntries(["HOME", "PATH", "TMPDIR", "TEMP", "TMP", "SystemRoot"]
33
+ .flatMap((key) => process.env[key] ? [[key, process.env[key]]] : [])),
34
+ });
35
+ }
24
36
  const captureShape = {
25
37
  url: zod_1.z.string().url().refine((value) => {
26
38
  const url = new URL(value);
@@ -60,14 +72,7 @@ async function startBrowserEvidence(options) {
60
72
  const images = [];
61
73
  try {
62
74
  try {
63
- browser = await (options.launch?.() ?? playwright_1.chromium.launch({
64
- headless: true,
65
- chromiumSandbox: true,
66
- timeout: 20_000,
67
- // Do not pass the supervisor's provider or machine credentials to Chrome.
68
- env: Object.fromEntries(["HOME", "PATH", "TMPDIR", "TEMP", "TMP", "SystemRoot"]
69
- .flatMap((key) => process.env[key] ? [[key, process.env[key]]] : [])),
70
- }));
75
+ browser = await (options.launch?.() ?? launchCaptureBrowser());
71
76
  }
72
77
  catch {
73
78
  throw new Error("Runtime browser could not start. On the agent host run navarch-runtime browser-install. Check host browser permissions; do not change the task repository or CI to install capture tooling.");
@@ -0,0 +1,277 @@
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.BROWSER_CAPABILITY = void 0;
7
+ exports.probeBrowserCapability = probeBrowserCapability;
8
+ exports.capabilitiesAfterBrowserProbe = capabilitiesAfterBrowserProbe;
9
+ exports.codexSandboxNeedsProbe = codexSandboxNeedsProbe;
10
+ exports.codexSandboxProbeArgs = codexSandboxProbeArgs;
11
+ exports.summarizeLaunchFailure = summarizeLaunchFailure;
12
+ const node_child_process_1 = require("node:child_process");
13
+ const node_fs_1 = require("node:fs");
14
+ const node_os_1 = __importDefault(require("node:os"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ const codex_cjs_1 = require("./adapters/codex.cjs");
17
+ const browser_evidence_cjs_1 = require("./browser-evidence.cjs");
18
+ const logger_cjs_1 = require("./logger.cjs");
19
+ const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
20
+ const log = (0, logger_cjs_1.createLogger)("browser-probe");
21
+ exports.BROWSER_CAPABILITY = "browser-use";
22
+ const HOST_PROBE_TIMEOUT_MS = 30_000;
23
+ const SANDBOX_PROBE_TIMEOUT_MS = 45_000;
24
+ const PROBE_START_MARKER = "NAVARCH_BROWSER_PROBE_START";
25
+ const PROBE_OK_MARKER = "NAVARCH_BROWSER_PROBE_OK";
26
+ const MAX_REASON_LENGTH = 480;
27
+ /**
28
+ * Probes once per runtime process (the result is cached by the caller for the
29
+ * process lifetime; a restart re-probes). A machine on 2026-10-01 advertised
30
+ * browser-use by default while Codex's macOS seatbelt killed every Chromium
31
+ * launch at `bootstrap_check_in ... MachPortRendezvousServer: Permission
32
+ * denied`, so nine milestone QA runs ended BLOCKED for environment reasons.
33
+ * The runtime's own capture browser still worked there because it runs
34
+ * outside the agent sandbox, which is why the probe also launches Chromium
35
+ * through each sandbox an advertised runtime wraps its shell in.
36
+ */
37
+ async function probeBrowserCapability(config, deps = {}) {
38
+ const now = deps.now ?? (() => new Date());
39
+ const skipped = (reason) => ({
40
+ status: "skipped",
41
+ reason,
42
+ checked_at: now().toISOString(),
43
+ });
44
+ if (!config.capabilities.includes(exports.BROWSER_CAPABILITY)) {
45
+ return skipped("browser-use is not in this machine's configured capabilities.");
46
+ }
47
+ if (!config.browserProbe) {
48
+ return skipped("NAVARCH_BROWSER_PROBE=off: browser-use is advertised without a launch check.");
49
+ }
50
+ if (config.sandboxMode === "docker") {
51
+ return skipped("Docker sessions use the sandbox image's browser; the host was not probed.");
52
+ }
53
+ const launchHost = deps.launchHost ?? (async () => {
54
+ const browser = await (0, browser_evidence_cjs_1.launchCaptureBrowser)();
55
+ try {
56
+ const page = await browser.newPage();
57
+ await page.setContent("<title>navarch-probe</title>");
58
+ }
59
+ finally {
60
+ await browser.close();
61
+ }
62
+ });
63
+ try {
64
+ await withTimeout(launchHost(), HOST_PROBE_TIMEOUT_MS, "headless Chromium launch timed out");
65
+ }
66
+ catch (error) {
67
+ return {
68
+ status: "failed",
69
+ reason: truncate(`Headless Chromium could not start on this machine: ${summarizeLaunchFailure(String(error))} ` +
70
+ "Run `navarch-runtime browser-install`, then restart the agent."),
71
+ checked_at: now().toISOString(),
72
+ };
73
+ }
74
+ const platform = deps.platform ?? process.platform;
75
+ if (codexSandboxNeedsProbe(config, platform)) {
76
+ const sandboxFailure = await probeCodexSandbox(config, deps);
77
+ if (sandboxFailure) {
78
+ return { status: "failed", reason: truncate(sandboxFailure), checked_at: now().toISOString() };
79
+ }
80
+ }
81
+ return { status: "passed", checked_at: now().toISOString() };
82
+ }
83
+ /** Withholds `browser-use` when the probe failed; any other verdict keeps the configured list. */
84
+ function capabilitiesAfterBrowserProbe(capabilities, probe) {
85
+ return probe.status === "failed"
86
+ ? capabilities.filter((capability) => capability !== exports.BROWSER_CAPABILITY)
87
+ : capabilities;
88
+ }
89
+ /**
90
+ * Codex runs agent shell commands under its own OS sandbox (seatbelt on
91
+ * macOS, Landlock/seccomp on Linux) unless the operator bypassed it. Every
92
+ * advertised runtime must be able to run a browser task: the control plane,
93
+ * not this machine, picks which adapter a claimed task runs under.
94
+ */
95
+ function codexSandboxNeedsProbe(config, platform) {
96
+ if (config.sandboxMode !== "host" || platform === "win32")
97
+ return false;
98
+ const runtimes = new Set([config.agentType, ...(config.runtimes ?? [])]);
99
+ return runtimes.has("codex") && !codexSandboxBypassed(config.codexExtraArgs);
100
+ }
101
+ function codexSandboxBypassed(args) {
102
+ for (let index = 0; index < args.length; index += 1) {
103
+ const arg = args[index];
104
+ if (arg === "--dangerously-bypass-approvals-and-sandbox")
105
+ return true;
106
+ const value = arg === "--sandbox" || arg === "-s"
107
+ ? args[index + 1]
108
+ : arg.startsWith("--sandbox=") || arg.startsWith("-s=")
109
+ ? arg.slice(arg.indexOf("=") + 1)
110
+ : (arg === "-c" || arg === "--config") && /^sandbox_mode=/.test(args[index + 1] ?? "")
111
+ ? args[index + 1].slice("sandbox_mode=".length)
112
+ : undefined;
113
+ if (value?.replace(/["']/g, "") === "danger-full-access")
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ /**
119
+ * `codex sandbox` arguments reproducing the policy a host session runs under:
120
+ * the operator's explicit policy when NAVARCH_CODEX_EXTRA_ARGS has one,
121
+ * otherwise Navarch's generated worktree-guard profile, otherwise the user's
122
+ * own Codex configuration (guard off). `--ignore-user-config` and approval
123
+ * settings only affect `codex exec`, so they are not forwarded.
124
+ */
125
+ function codexSandboxProbeArgs(config, roots) {
126
+ if ((0, codex_cjs_1.hasExplicitPermissionPolicy)(config.codexExtraArgs)) {
127
+ const forwarded = [];
128
+ const args = config.codexExtraArgs;
129
+ for (let index = 0; index < args.length; index += 1) {
130
+ const arg = args[index];
131
+ const next = args[index + 1];
132
+ if ((arg === "-c" || arg === "--config" || arg === "-p" || arg === "--profile") && next !== undefined) {
133
+ forwarded.push(arg, next);
134
+ index += 1;
135
+ }
136
+ else if (arg.startsWith("--config=")) {
137
+ forwarded.push("-c", arg.slice("--config=".length));
138
+ }
139
+ else if ((arg === "--sandbox" || arg === "-s") && next !== undefined) {
140
+ forwarded.push("-c", `sandbox_mode=${JSON.stringify(next)}`);
141
+ index += 1;
142
+ }
143
+ else if (arg.startsWith("--sandbox=") || arg.startsWith("-s=")) {
144
+ forwarded.push("-c", `sandbox_mode=${JSON.stringify(arg.slice(arg.indexOf("=") + 1))}`);
145
+ }
146
+ }
147
+ return forwarded;
148
+ }
149
+ if (!config.worktreeGuard)
150
+ return [];
151
+ const workspaceRoot = node_path_1.default.join(roots.probeRoot, "workspace");
152
+ return (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
153
+ workDir: node_path_1.default.join(workspaceRoot, "session"),
154
+ worktreePath: roots.worktreePath,
155
+ repositoryPath: node_path_1.default.join(workspaceRoot, "repo"),
156
+ workspaceRoot,
157
+ extraRoots: [...config.guardExtraRoots, ...roots.readRoots],
158
+ }).filter((arg) => arg !== "--ignore-user-config");
159
+ }
160
+ const PROBE_SCRIPT = `
161
+ process.stdout.write(${JSON.stringify(PROBE_START_MARKER)} + "\\n");
162
+ (async () => {
163
+ const { chromium } = require(process.env.NAVARCH_PROBE_PLAYWRIGHT);
164
+ const browser = await chromium.launch({ headless: true, timeout: 20000 });
165
+ try {
166
+ const page = await browser.newPage();
167
+ await page.setContent("<title>navarch-probe</title>");
168
+ } finally {
169
+ await browser.close();
170
+ }
171
+ process.stdout.write(${JSON.stringify(PROBE_OK_MARKER)} + "\\n");
172
+ })().catch((error) => {
173
+ process.stderr.write(String((error && error.stack) || error));
174
+ process.exit(1);
175
+ });
176
+ `;
177
+ async function probeCodexSandbox(config, deps) {
178
+ const probeRoot = await node_fs_1.promises.mkdtemp(node_path_1.default.join(deps.tmpDir ?? node_os_1.default.tmpdir(), "navarch-browser-probe-"));
179
+ try {
180
+ const worktreePath = node_path_1.default.join(probeRoot, "workspace", "worktree");
181
+ await node_fs_1.promises.mkdir(worktreePath, { recursive: true });
182
+ const scriptPath = node_path_1.default.join(worktreePath, "probe.cjs");
183
+ await node_fs_1.promises.writeFile(scriptPath, PROBE_SCRIPT, "utf8");
184
+ const playwrightDir = await node_fs_1.promises.realpath(deps.playwrightDir?.() ?? node_path_1.default.dirname(require.resolve("playwright/package.json")));
185
+ const args = codexSandboxProbeArgs(config, {
186
+ probeRoot,
187
+ worktreePath,
188
+ // Node and the runtime's Playwright install (with its playwright-core
189
+ // sibling) must be readable for the probe to start; the browser cache
190
+ // itself is a guard read root already.
191
+ readRoots: [node_path_1.default.dirname(await node_fs_1.promises.realpath(process.execPath)), node_path_1.default.dirname(playwrightDir)],
192
+ });
193
+ const env = { NAVARCH_PROBE_PLAYWRIGHT: playwrightDir };
194
+ for (const key of ["HOME", "PATH", "TMPDIR", "CODEX_HOME", "PLAYWRIGHT_BROWSERS_PATH", "XDG_CACHE_HOME"]) {
195
+ if (process.env[key])
196
+ env[key] = process.env[key];
197
+ }
198
+ const result = await (deps.run ?? runToCompletion)(config.codexBin, ["sandbox", ...args, "--", process.execPath, scriptPath], { cwd: worktreePath, env, timeoutMs: SANDBOX_PROBE_TIMEOUT_MS });
199
+ if (result.code === 0 && result.stdout.includes(PROBE_OK_MARKER))
200
+ return null;
201
+ const output = `${result.stderr}\n${result.stdout}`;
202
+ if (!result.stdout.includes(PROBE_START_MARKER)) {
203
+ return ("Could not verify a browser inside the Codex sandbox: `codex sandbox` did not run the probe " +
204
+ `(${result.timedOut ? "timed out" : output.trim() ? summarizeLaunchFailure(output) : `exit ${result.code}`}). ` +
205
+ "Update the Codex CLI, or run browser tasks with another agent.");
206
+ }
207
+ const detail = result.timedOut ? "the launch timed out." : summarizeLaunchFailure(output);
208
+ const machPort = /bootstrap_check_in|MachPortRendezvous|mach_port_rendezvous/i.test(output);
209
+ return (`Headless Chromium cannot start inside the Codex sandbox: ${detail} ` +
210
+ (machPort
211
+ ? "Codex's macOS sandbox denies Chromium's Mach port registration. "
212
+ : "") +
213
+ "Run browser tasks with another agent (for example Claude Code), use NAVARCH_SANDBOX_MODE=docker, " +
214
+ "or run Codex without its sandbox.");
215
+ }
216
+ catch (error) {
217
+ log.warn(`codex sandbox browser probe could not run: ${String(error)}`);
218
+ return `Could not verify a browser inside the Codex sandbox: ${summarizeLaunchFailure(String(error))}`;
219
+ }
220
+ finally {
221
+ await node_fs_1.promises.rm(probeRoot, { recursive: true, force: true }).catch(() => undefined);
222
+ }
223
+ }
224
+ /** The one line of a Chromium/Playwright launch failure an operator can act on. */
225
+ function summarizeLaunchFailure(output) {
226
+ const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
227
+ const fatal = lines.find((line) => /FATAL:|bootstrap_check_in/.test(line));
228
+ if (fatal) {
229
+ // "[pid=1][err] [1001/210406.593317:FATAL:base/apple/mach_port_rendezvous_mac.cc:159] Check failed: ..."
230
+ return fatal.replace(/^.*FATAL:[^\]]*\]\s*/, "");
231
+ }
232
+ const missing = lines.find((line) => /Executable doesn't exist/.test(line));
233
+ if (missing)
234
+ return "Chromium is not installed for the runtime's Playwright.";
235
+ const message = lines.find((line) => /Error:/.test(line)) ?? lines[0] ?? "unknown error";
236
+ return message.replace(/^.*?Error:\s*/, "");
237
+ }
238
+ function truncate(text) {
239
+ return text.length > MAX_REASON_LENGTH ? `${text.slice(0, MAX_REASON_LENGTH - 1)}…` : text;
240
+ }
241
+ function withTimeout(promise, ms, message) {
242
+ let timer;
243
+ return Promise.race([
244
+ promise,
245
+ new Promise((_, reject) => {
246
+ timer = setTimeout(() => reject(new Error(message)), ms);
247
+ }),
248
+ ]).finally(() => clearTimeout(timer));
249
+ }
250
+ function runToCompletion(bin, args, options) {
251
+ return new Promise((resolve) => {
252
+ let stdout = "";
253
+ let stderr = "";
254
+ let timedOut = false;
255
+ const child = (0, node_child_process_1.spawn)(bin, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
256
+ const timer = setTimeout(() => {
257
+ timedOut = true;
258
+ child.kill("SIGKILL");
259
+ }, options.timeoutMs);
260
+ child.stdout.on("data", (chunk) => {
261
+ if (stdout.length < 64_000)
262
+ stdout += chunk.toString();
263
+ });
264
+ child.stderr.on("data", (chunk) => {
265
+ if (stderr.length < 64_000)
266
+ stderr += chunk.toString();
267
+ });
268
+ child.on("error", (error) => {
269
+ clearTimeout(timer);
270
+ resolve({ code: null, stdout, stderr: `${stderr}\n${String(error)}`, timedOut });
271
+ });
272
+ child.on("close", (code) => {
273
+ clearTimeout(timer);
274
+ resolve({ code, stdout, stderr, timedOut });
275
+ });
276
+ });
277
+ }
package/dist/cli.cjs CHANGED
@@ -19,6 +19,7 @@ const update_coordinator_cjs_1 = require("./update-coordinator.cjs");
19
19
  const supervisor_cjs_1 = require("./supervisor.cjs");
20
20
  const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
21
21
  const browser_evidence_cjs_1 = require("./browser-evidence.cjs");
22
+ const browser_probe_cjs_1 = require("./browser-probe.cjs");
22
23
  const log = (0, logger_cjs_1.createLogger)("cli");
23
24
  const PACKAGE_NAME = "@sagentlab/navarch-runtime";
24
25
  /** Accepted `--agent` ids as usage metavariables, generated from one source of truth. */
@@ -235,8 +236,22 @@ async function connectCommand(flags) {
235
236
  async function startCommand(flags) {
236
237
  const baseConfig = configFromFlags(flags);
237
238
  const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
238
- const config = configFromFlags(flags, identity);
239
- warnUnknownRuntimes(config);
239
+ const configuredConfig = configFromFlags(flags, identity);
240
+ warnUnknownRuntimes(configuredConfig);
241
+ // Probe before the first heartbeat or claim so browser-use is never
242
+ // advertised on a machine whose agents cannot start a browser. The verdict
243
+ // holds for this process; restarting the runtime probes again.
244
+ const browserProbe = await (0, browser_probe_cjs_1.probeBrowserCapability)(configuredConfig);
245
+ if (browserProbe.status === "failed") {
246
+ log.warn(`not advertising browser-use: ${browserProbe.reason}`);
247
+ }
248
+ else if (browserProbe.status === "passed") {
249
+ log.info("browser probe passed; advertising browser-use");
250
+ }
251
+ const config = {
252
+ ...configuredConfig,
253
+ capabilities: (0, browser_probe_cjs_1.capabilitiesAfterBrowserProbe)(configuredConfig.capabilities, browserProbe),
254
+ };
240
255
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
241
256
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
242
257
  const runtimeShutdown = new AbortController();
@@ -279,7 +294,7 @@ async function startCommand(flags) {
279
294
  // capacity slot.
280
295
  runtimeShutdown.abort();
281
296
  void capacity.waitForIdle().then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
282
- });
297
+ }, browserProbe);
283
298
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
284
299
  config,
285
300
  claimLoop,
@@ -377,6 +392,8 @@ async function doctorCommand(flags) {
377
392
  console.log(`worktree_guard: ${config.worktreeGuard ? "on" : "off"}`);
378
393
  console.log(`runtimes: ${config.runtimes?.join(", ") ?? config.agentType}`);
379
394
  console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
395
+ const browserProbe = await (0, browser_probe_cjs_1.probeBrowserCapability)(config);
396
+ console.log(`browser: ${browserProbe.status.toUpperCase()}${browserProbe.reason ? ` — ${browserProbe.reason}` : ""}`);
380
397
  if (!identity) {
381
398
  console.log(`machine: not registered — run \`${invocation("register")}\``);
382
399
  return;
package/dist/config.cjs CHANGED
@@ -25,6 +25,7 @@ exports.RUNTIME_AGENT_TYPES = [
25
25
  "opencode",
26
26
  "acp",
27
27
  "qoder",
28
+ "pi-local",
28
29
  ];
29
30
  function isRuntimeAgentType(value) {
30
31
  return typeof value === "string" && exports.RUNTIME_AGENT_TYPES.includes(value);
@@ -105,7 +106,12 @@ function loadRuntimeConfig(env = process.env) {
105
106
  // fails closed there (adapters/opencode.cts). Advertising it anyway meant
106
107
  // the machine claimed OpenCode tasks it could only fail, one lease at a
107
108
  // time; keep it out of the claim instead of discovering this per session.
108
- const runnableRuntimes = requestedRuntimes.filter((runtime) => runtime !== "opencode" || !opencodeBlockedOnHost(sandboxMode, worktreeGuard));
109
+ const runnableRuntimes = requestedRuntimes.filter((runtime) => runtime === "pi-local"
110
+ ? sandboxMode === "host" && !worktreeGuard
111
+ : runtime !== "opencode" || !opencodeBlockedOnHost(sandboxMode, worktreeGuard));
112
+ if (requestedRuntimes.includes("pi-local") && runnableRuntimes.length === 0) {
113
+ throw new Error("Pi (local) requires NAVARCH_SANDBOX_MODE=host and NAVARCH_WORKTREE_GUARD=off");
114
+ }
109
115
  if (requestedRuntimes.includes("opencode") && runnableRuntimes.length === 0) {
110
116
  throw new Error(exports.OPENCODE_HOST_GUARD_MESSAGE);
111
117
  }
@@ -143,6 +149,8 @@ function loadRuntimeConfig(env = process.env) {
143
149
  opencodeExtraArgs: envList(env, "NAVARCH_OPENCODE_EXTRA_ARGS", []),
144
150
  acpBin: env.NAVARCH_ACP_BIN ?? "dsh",
145
151
  acpExtraArgs: envList(env, "NAVARCH_ACP_EXTRA_ARGS", ["--profile", "acp"]),
152
+ piBin: env.NAVARCH_PI_BIN ?? "pi",
153
+ piExtraArgs: envList(env, "NAVARCH_PI_EXTRA_ARGS", []),
146
154
  qoderBin: env.NAVARCH_QODER_BIN ?? "qoder",
147
155
  qoderExtraArgs: envList(env, "NAVARCH_QODER_EXTRA_ARGS", []),
148
156
  gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
@@ -169,6 +177,7 @@ function loadRuntimeConfig(env = process.env) {
169
177
  // Reads only the quota numbers providers already write to disk, so it is on
170
178
  // by default; operators who would rather share nothing can opt out.
171
179
  reportUsageLimits: !["off", "false", "0"].includes(env.NAVARCH_USAGE_LIMITS ?? ""),
180
+ browserProbe: !["off", "false", "0"].includes(env.NAVARCH_BROWSER_PROBE ?? ""),
172
181
  codexHome: defaultCodexHome(env),
173
182
  claudeConfigPath: defaultClaudeConfigPath(env),
174
183
  };
@@ -34,6 +34,7 @@ class MachineHeartbeatLoop {
34
34
  onResult;
35
35
  onHealthy;
36
36
  onCredentialsRejected;
37
+ browserProbe;
37
38
  timer = null;
38
39
  heartbeatInFlight = false;
39
40
  heartbeatPending = false;
@@ -48,7 +49,9 @@ class MachineHeartbeatLoop {
48
49
  * then: without this the runtime would warn every interval forever while
49
50
  * claiming nothing, which is indistinguishable from an idle agent.
50
51
  */
51
- onCredentialsRejected) {
52
+ onCredentialsRejected,
53
+ /** Cached for the process lifetime; a restart re-probes. */
54
+ browserProbe) {
52
55
  this.api = api;
53
56
  this.machineId = machineId;
54
57
  this.config = config;
@@ -57,6 +60,7 @@ class MachineHeartbeatLoop {
57
60
  this.onResult = onResult;
58
61
  this.onHealthy = onHealthy;
59
62
  this.onCredentialsRejected = onCredentialsRejected;
63
+ this.browserProbe = browserProbe;
60
64
  }
61
65
  start() {
62
66
  if (this.timer)
@@ -101,6 +105,7 @@ class MachineHeartbeatLoop {
101
105
  const result = await this.api.machineHeartbeat(this.machineId, {
102
106
  available_capacity: this.draining ? 0 : this.capacity.available(),
103
107
  capabilities: this.config.capabilities,
108
+ ...(this.browserProbe ? { browser_probe: this.browserProbe } : {}),
104
109
  ...(agentUsage ? { agent_usage: agentUsage } : {}),
105
110
  // Explicit null when unpinned, so the control plane clears a model
106
111
  // this machine reported before its pin was removed.
package/dist/session.cjs CHANGED
@@ -46,9 +46,10 @@ function resolveSessionExecution(config, claimed) {
46
46
  const execution = claimed.context_bundle.execution ?? {
47
47
  profile: claimed.task.execution_profile ?? "standard",
48
48
  model: runtime === "codex" ? "gpt-6-astra"
49
- : runtime === "gemini" ? "auto"
50
- : runtime === "opencode" || runtime === "acp" || runtime === "qoder" ? "default"
51
- : "claude-opus-5-5",
49
+ : runtime === "pi-local" ? "qwen3.8-flash-next-iq2_xs"
50
+ : runtime === "gemini" ? "auto"
51
+ : runtime === "opencode" || runtime === "acp" || runtime === "qoder" ? "default"
52
+ : "claude-opus-5-5",
52
53
  reasoning_effort: "medium",
53
54
  };
54
55
  return {
@@ -865,6 +866,8 @@ function adapterCommand(config, runtime) {
865
866
  return { bin: config.opencodeBin, extraArgs: config.opencodeExtraArgs };
866
867
  case "acp":
867
868
  return { bin: config.acpBin, extraArgs: config.acpExtraArgs };
869
+ case "pi-local":
870
+ return { bin: config.piBin, extraArgs: config.piExtraArgs };
868
871
  case "qoder":
869
872
  return { bin: config.qoderBin, extraArgs: config.qoderExtraArgs };
870
873
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
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",