@sagentlab/navarch-runtime 0.1.80 → 0.1.82

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
@@ -543,6 +543,15 @@ The Claude hook is a strong guardrail rather than a hard security boundary
543
543
  because shell paths are screened lexically. Codex's permission profile is
544
544
  enforced by its OS sandbox. For container-grade whole-process isolation use
545
545
  `NAVARCH_SANDBOX_MODE=docker`; neither host guard is installed in Docker mode.
546
+ On Linux, Codex uses an explicit allowlist without a denied workspace-parent
547
+ mount, avoiding bubblewrap startup failures with linked Git worktrees. Its
548
+ `TMPDIR`, `TMP`, and `TEMP` point to a private `tmp` directory inside the session;
549
+ the profile does not grant all of `/tmp`. Extra/tool roots that contain the
550
+ workspace are omitted on Linux so they cannot expose sibling sessions or
551
+ session metadata. This also applies when the workspace itself lives under `/tmp`.
552
+ To verify the linked-worktree boundary on a Linux host with Codex installed,
553
+ run `NAVARCH_TEST_REAL_CODEX=1 npm test -- tests/codex-linux-sandbox.test.cts`
554
+ from `runtime/`. The smoke test uses local shell commands and makes no model calls.
546
555
  An operator-supplied `--settings` in `NAVARCH_CLAUDE_EXTRA_ARGS`, or an
547
556
  explicit Codex permission/sandbox option in `NAVARCH_CODEX_EXTRA_ARGS`, takes
548
557
  precedence over the generated policy.
@@ -959,7 +968,10 @@ different endpoint (for example, Ollama's `http://127.0.0.1:11434/v1`).
959
968
  `NAVARCH_PI_BIN` overrides the executable; `NAVARCH_PI_EXTRA_ARGS` adds CLI
960
969
  arguments. `--model` at connect pins this machine's model as for other agents.
961
970
 
962
- Bare model IDs use the generated `local` provider. Exact `provider/model`
971
+ Bare model IDs first resolve a unique matching model in the operator's catalog;
972
+ ambiguous matches require an explicit `provider/model`. Without a match, they
973
+ use the generated `local` provider. Setting `NAVARCH_PI_LOCAL_BASE_URL` explicitly
974
+ selects that generated provider. Exact `provider/model`
963
975
  references use the machine's existing Pi catalog and credentials from
964
976
  `~/.pi/agent/models.json` and `auth.json` (or `PI_CODING_AGENT_DIR`). Run
965
977
  `pi --list-models` to inspect that catalog. The UI puts the local default first
@@ -165,10 +165,20 @@ function attachPiOutput(result) {
165
165
  let costUsd;
166
166
  let sawUsage = false;
167
167
  let reportText = "";
168
+ let terminalError;
168
169
  for (const event of events) {
169
170
  const message = event.message;
171
+ if (event.type === "error") {
172
+ terminalError = typeof event.error === "string" ? event.error :
173
+ typeof event.errorMessage === "string" ? event.errorMessage : "Pi provider error";
174
+ }
170
175
  if (event.type !== "message_end" || !message || message.role !== "assistant")
171
176
  continue;
177
+ // Pi can settle an exhausted provider retry with process exit code zero.
178
+ // A later successful assistant message supersedes a recovered error.
179
+ terminalError = message.stopReason === "error" || message.errorMessage
180
+ ? typeof message.errorMessage === "string" ? message.errorMessage : "Pi provider error"
181
+ : undefined;
172
182
  const text = extractText(message.content);
173
183
  if (text.trim())
174
184
  reportText = text.trim();
@@ -193,6 +203,9 @@ function attachPiOutput(result) {
193
203
  }
194
204
  return {
195
205
  ...result,
206
+ ...(terminalError && result.exitCode === 0 && !result.timedOut && !result.killedByLeaseLoss
207
+ ? { exitCode: 1, stderr: `${result.stderr}${result.stderr ? "\n" : ""}Pi provider failed: ${terminalError}` }
208
+ : {}),
196
209
  ...(sawUsage
197
210
  ? {
198
211
  // AdapterResult semantics: tokensIn folds every input category
@@ -269,13 +282,23 @@ async function runOnHost(options, args, local = false) {
269
282
  const configPath = node_path_1.default.join(agentDir, "models.json");
270
283
  const config = JSON.parse(await node_fs_1.promises.readFile(configPath, "utf8").catch(() => "{}"));
271
284
  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 });
285
+ const endpointOverride = options.env.NAVARCH_PI_LOCAL_BASE_URL ?? process.env.NAVARCH_PI_LOCAL_BASE_URL;
286
+ const matches = endpointOverride ? [] : Object.entries(config.providers).filter(([, provider]) => provider.models?.some((entry) => entry.id === model)).map(([name]) => name);
287
+ if (matches.length > 1)
288
+ throw new Error(`Pi model ${model} matches multiple providers; select an explicit provider/model.`);
289
+ if (matches.length === 1) {
290
+ args = [...args];
291
+ args[args.lastIndexOf("--model") + 1] = `${matches[0]}/${model}`;
292
+ }
293
+ else {
294
+ // Without a catalog match, retain the default local endpoint behavior.
295
+ config.providers.local = {
296
+ baseUrl: endpointOverride ?? "http://127.0.0.1:8080/v1",
297
+ api: "openai-completions", apiKey: "local",
298
+ models: [{ id: model, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
299
+ };
300
+ await node_fs_1.promises.writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
301
+ }
279
302
  }
280
303
  if (options.mcpConfigPath) {
281
304
  await node_fs_1.promises.copyFile(options.mcpConfigPath, node_path_1.default.join(agentDir, "mcp.json"));
package/dist/session.cjs CHANGED
@@ -378,6 +378,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
378
378
  }
379
379
  }
380
380
  else if (runtime === "codex") {
381
+ if (process.platform === "linux") {
382
+ const tmpDir = (0, worktree_guard_cjs_1.sessionCodexTmpDir)(workDir);
383
+ await node_fs_1.promises.mkdir(tmpDir, { recursive: true, mode: 0o700 });
384
+ Object.assign(sessionEnv, { TMPDIR: tmpDir, TMP: tmpDir, TEMP: tmpDir });
385
+ }
381
386
  codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
382
387
  workDir,
383
388
  worktreePath: gitWorktree.worktreePath,
@@ -9,6 +9,7 @@ exports.prepareWorktreeGuard = prepareWorktreeGuard;
9
9
  exports.codexToolReadRoots = codexToolReadRoots;
10
10
  exports.macosDeveloperWriteRoots = macosDeveloperWriteRoots;
11
11
  exports.sessionGhConfigDir = sessionGhConfigDir;
12
+ exports.sessionCodexTmpDir = sessionCodexTmpDir;
12
13
  exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
13
14
  exports.geminiSandboxMounts = geminiSandboxMounts;
14
15
  const node_path_1 = __importDefault(require("node:path"));
@@ -172,6 +173,9 @@ function macosDeveloperWriteRoots(env = process.env, platform = process.platform
172
173
  function sessionGhConfigDir(workDir) {
173
174
  return node_path_1.default.join(workDir, SESSION_GH_CONFIG_DIRNAME);
174
175
  }
176
+ function sessionCodexTmpDir(workDir) {
177
+ return node_path_1.default.resolve(workDir, "tmp");
178
+ }
175
179
  /**
176
180
  * Builds one-off Codex permission-profile arguments for a host session.
177
181
  *
@@ -180,33 +184,46 @@ function sessionGhConfigDir(workDir) {
180
184
  * this boundary. Authentication still comes from CODEX_HOME. The profile
181
185
  * grants only the runtime paths common tools need, the session worktree, the
182
186
  * shared Git directory, temp space, and operator-approved extra roots. The
183
- * surrounding Navarch workspace is denied, then the more-specific current
184
- * worktree/repository grants reopen only this session's paths. The worktree
187
+ * surrounding Navarch workspace is denied (implicitly on Linux), with only
188
+ * this session's paths granted access. The worktree
185
189
  * is marked untrusted for configuration purposes so a checked-in legacy
186
190
  * sandbox_mode cannot disable the generated permission profile; repository
187
191
  * instructions such as AGENTS.md still load normally.
188
192
  */
189
- function codexWorktreeGuardArgs(options) {
193
+ function codexWorktreeGuardArgs(options, platform = process.platform) {
194
+ const workspaceRoot = node_path_1.default.resolve(options.workspaceRoot);
195
+ const linux = platform === "linux";
196
+ // On Linux, a denied ancestor is an empty read-only bwrap mount. Codex
197
+ // can then try to protect a linked worktree's gitdir before reopening the
198
+ // bare repository, failing to mkdir repository.git/worktrees/repo beneath
199
+ // that mount. Use restricted-read's implicit deny instead. Broad temp or
200
+ // ancestor grants would expose the workspace without the explicit mask,
201
+ // so Linux gets only a session temp root and non-overlapping extra roots.
190
202
  const filesystem = {
191
203
  ":minimal": "read",
192
- ":tmpdir": "write",
193
- ":slash_tmp": "write",
194
- [node_path_1.default.resolve(options.workspaceRoot)]: "deny",
204
+ ...(linux
205
+ ? { [sessionCodexTmpDir(options.workDir)]: "write" }
206
+ : {
207
+ ":tmpdir": "write",
208
+ ":slash_tmp": "write",
209
+ [workspaceRoot]: "deny",
210
+ }),
195
211
  [node_path_1.default.resolve(options.worktreePath)]: "write",
196
212
  [node_path_1.default.resolve(options.repositoryPath)]: "write",
197
213
  [sessionGhConfigDir(options.workDir)]: "read",
198
214
  };
199
- for (const root of codexToolReadRoots()) {
215
+ const overlapsWorkspace = (root) => isPathInside(root, workspaceRoot) || (linux && isPathInside(workspaceRoot, root));
216
+ for (const root of codexToolReadRoots(process.env, platform)) {
200
217
  const resolved = node_path_1.default.resolve(root);
201
- if (isPathInside(resolved, node_path_1.default.resolve(options.workspaceRoot)))
218
+ if (overlapsWorkspace(resolved))
202
219
  continue;
203
220
  filesystem[resolved] = "read";
204
221
  }
205
- for (const root of [...macosDeveloperWriteRoots(), ...(options.extraRoots ?? [])]) {
222
+ for (const root of [...macosDeveloperWriteRoots(process.env, platform), ...(options.extraRoots ?? [])]) {
206
223
  const resolved = node_path_1.default.resolve(root);
207
224
  // Match the Claude hook's denied-root precedence: an extra root cannot
208
225
  // reopen sibling sessions or metadata inside the Navarch workspace.
209
- if (isPathInside(resolved, node_path_1.default.resolve(options.workspaceRoot)))
226
+ if (overlapsWorkspace(resolved))
210
227
  continue;
211
228
  filesystem[resolved] = "write";
212
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
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",