@sagentlab/navarch-runtime 0.1.117 → 0.1.120

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.
@@ -173,12 +173,151 @@ function expandHome(value) {
173
173
  return value;
174
174
  }
175
175
 
176
+ /**
177
+ * Previous character on the same line, ignoring blanks; "" at a true line
178
+ * start. A line that ends in an unescaped backslash continues onto the next
179
+ * line in the shell, so that backslash is returned instead.
180
+ */
181
+ function previousSignificantChar(command, index) {
182
+ for (let i = index - 1; i >= 0; i--) {
183
+ const ch = command[i];
184
+ if (ch === "\n") {
185
+ let j = i - 1;
186
+ while (j >= 0 && (command[j] === "\r" || command[j] === " " || command[j] === "\t")) j--;
187
+ let slashes = 0;
188
+ while (j >= 0 && command[j] === "\\") {
189
+ slashes++;
190
+ j--;
191
+ }
192
+ return slashes % 2 === 1 ? "\\" : "";
193
+ }
194
+ if (ch !== " " && ch !== "\t") return ch;
195
+ }
196
+ return "";
197
+ }
198
+
199
+ /**
200
+ * True when the token sits directly after a quote that opens a word
201
+ * (`"/** x *\/"`). A closing quote (`"" /*`, `"x"/*`) leaves the token as a
202
+ * bare shell argument, so it does not count.
203
+ */
204
+ function isInsideOpeningQuote(command, start) {
205
+ const quote = command[start - 1];
206
+ // Backticks run a command, so never use them to exempt a path token.
207
+ if (quote !== '"' && quote !== "'") return false;
208
+ // Only the command's first quote can prove an opening quoted word without
209
+ // parsing shell comments, heredocs and nested substitutions. Earlier quote
210
+ // characters make this ambiguous: keep screening rather than risk treating
211
+ // a closing quote after a continuation as an opening one.
212
+ if (/["'`]/.test(command.slice(0, start - 1))) return false;
213
+ const beforeQuote = command[start - 2];
214
+ return beforeQuote === undefined || /[\s=(<>;|&]/.test(beforeQuote);
215
+ }
216
+
217
+ /**
218
+ * Returns [open, close, segmentStart] for the quoted word containing `index`,
219
+ * or null when it sits outside quotes or the quote state cannot be trusted.
220
+ * `segmentStart` follows the last unquoted, unescaped `;`, `|`, `&` or newline
221
+ * before the word, so a separator inside quotes never starts a segment. Only
222
+ * plain single and double quotes are tracked. Constructs that nest or hide
223
+ * quotes (backticks, `$(`, `$'`, heredocs, shell comments) make the scan bail
224
+ * out, so a misread quote never exempts a token.
225
+ */
226
+ function quotedSpanAt(command, index) {
227
+ let quote = "";
228
+ let open = -1;
229
+ let segmentStart = 0;
230
+ for (let i = 0; i < command.length; i++) {
231
+ const ch = command[i];
232
+ if (quote === "'") {
233
+ if (ch === "'") {
234
+ if (index > open && index < i) return [open, i, segmentStart];
235
+ quote = "";
236
+ }
237
+ continue;
238
+ }
239
+ if (ch === "`" || (ch === "$" && (command[i + 1] === "(" || command[i + 1] === "'"))) return null;
240
+ if (ch === "\\") {
241
+ i++;
242
+ continue;
243
+ }
244
+ if (quote === '"') {
245
+ if (ch === '"') {
246
+ if (index > open && index < i) return [open, i, segmentStart];
247
+ quote = "";
248
+ }
249
+ continue;
250
+ }
251
+ if (/[;|&\n]/.test(ch)) segmentStart = i + 1;
252
+ if (ch === "<" && command[i + 1] === "<") return null;
253
+ if (ch === "#" && (i === 0 || /[\s;|&(]/.test(command[i - 1]))) return null;
254
+ if (ch === "'" || ch === '"') {
255
+ quote = ch;
256
+ open = i;
257
+ }
258
+ }
259
+ return null;
260
+ }
261
+
262
+ /**
263
+ * True for the jq alternative operator (`//`, `//=`) used as its own word inside
264
+ * a quoted jq program: `jq '.a // "x"' f.json`, `gh ... --jq '(.x) // "none"'`.
265
+ * Narrow on purpose. The token must be whitespace-delimited, sit inside a
266
+ * plain-quoted word in a shell segment whose command word is `jq`, or is `gh`
267
+ * with the word given to `--jq`/`-q`. Unquoted tokens (`find // -x`),
268
+ * quoted bare roots (`ls "//"`) and `//` in other quoted text stay screened.
269
+ */
270
+ function isJqAlternativeOperator(command, start, token) {
271
+ if (token !== "//" && token !== "//=") return false;
272
+ const next = command[start + token.length];
273
+ if (!/\s/.test(command[start - 1] ?? "") || next === undefined || !/\s/.test(next)) return false;
274
+ const span = quotedSpanAt(command, start);
275
+ if (!span) return false;
276
+ const segment = command.slice(span[2], span[0]);
277
+ const commandWord = /^\s*\(?\s*(?:[A-Za-z_]\w*=\S*\s+)*/;
278
+ if (new RegExp(commandWord.source + "jq\\s").test(segment)) return true;
279
+ return (
280
+ new RegExp(commandWord.source + "gh\\s").test(segment) && /\s(?:--jq|-q)(?:=|\s+)$/.test(segment)
281
+ );
282
+ }
283
+
284
+ /**
285
+ * True when a "/"-leading token is comment or markup text rather than a path:
286
+ * a closing tag (`</p>`), a block-comment opener (`/**`, `/*`), or a line
287
+ * comment (`// note`). Each rule is deliberately narrow so real escapes stay
288
+ * screened: tag names that exist at the filesystem root (`</home>`) are still
289
+ * checked, `/*` and `//` only count at a comment position (line start, right
290
+ * after an opening quote, or after `;`) so `rm -rf /*`, `find // -name x`
291
+ * and `rm -rf "" /*` stay blocked, and a block comment needs a closing
292
+ * `*` + `/` later in the text. The jq alternative operator inside a quoted jq
293
+ * program is exempt under the separate, narrower rule in
294
+ * isJqAlternativeOperator.
295
+ */
296
+ function isCommentOrMarkupToken(command, start, token) {
297
+ const end = start + token.length;
298
+ const next = command[end];
299
+
300
+ if (isJqAlternativeOperator(command, start, token)) return true;
301
+
302
+ if (command[start - 1] === "<" && next === ">" && /^\/[A-Za-z][\w:.-]*$/.test(token)) {
303
+ return !fs.existsSync(token);
304
+ }
305
+
306
+ const atCommentPosition =
307
+ isInsideOpeningQuote(command, start) ||
308
+ ["", ";"].includes(previousSignificantChar(command, start));
309
+ if (!atCommentPosition || next === undefined || !/\s/.test(next)) return false;
310
+ if (token === "/*" || token === "/**") return command.indexOf("*/", end) !== -1;
311
+ return token === "//" && /\S/.test(command.slice(end));
312
+ }
313
+
176
314
  /**
177
315
  * Pulls path-like references out of a shell command: absolute paths,
178
316
  * home-anchored paths (`~...`, `$HOME/...`), and relative `..` traversals.
179
317
  * Lexical by design — see the file header for why this is a guardrail, not
180
318
  * a parser. URLs survive untouched (the char before `//` in `https://` is
181
- * `:`, which is not a path delimiter here).
319
+ * `:`, which is not a path delimiter here). Comment and markup text that
320
+ * merely starts with "/" (`/** doc *\/`, `// note`, `</p>`) is not a path.
182
321
  */
183
322
  function extractBashPathCandidates(command) {
184
323
  const candidates = [];
@@ -193,7 +332,11 @@ function extractBashPathCandidates(command) {
193
332
  const absoluteRe = /(?:^|[\s'"`=(<>;|&])(\/[^\s'"`<>;|&)]*)/g;
194
333
  const homeRe = /(?:^|[\s'"`=(<>;|&])((?:~|\$\{?HOME\}?)[^\s'"`<>;|&)]*)/g;
195
334
  let match;
196
- while ((match = absoluteRe.exec(command))) add(match[1]);
335
+ while ((match = absoluteRe.exec(command))) {
336
+ const start = match.index + match[0].length - match[1].length;
337
+ if (isCommentOrMarkupToken(command, start, match[1])) continue;
338
+ add(match[1]);
339
+ }
197
340
  while ((match = homeRe.exec(command))) add(match[1]);
198
341
 
199
342
  for (const token of command.split(/[\s'"`;|&<>()]+/)) {
@@ -13,11 +13,18 @@ const node_path_1 = __importDefault(require("node:path"));
13
13
  const config_cjs_1 = require("../config.cjs");
14
14
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
15
15
  /**
16
- * Headless OpenCode adapter, fixture-pinned to the OpenCode JSON/config
17
- * contract shared by V1 and V2 (verified against 2.0.6):
16
+ * Headless OpenCode adapter, fixture-pinned to the OpenCode JSON event
17
+ * contract shared by V1 and V2 (verified against 1.19.1, 2.0.6 and 2.0.17):
18
18
  *
19
19
  * opencode run --format json [--model provider/model[#variant]] < prompt
20
20
  *
21
+ * V1 (still npm's `latest`) differs in two ways the adapter must follow: its
22
+ * config takes V1 `permission` rules and flat `mcp` entries, and it rejects
23
+ * V2's `permissions`/`mcp.servers` outright ("V2 permissions are not
24
+ * supported by OpenCode V1"); and it takes the reasoning variant as
25
+ * `--variant`, reporting `model#variant` as an unknown model. A `--version`
26
+ * probe picks the dialect per session, defaulting to V2.
27
+ *
21
28
  * The prompt goes in on stdin, which `opencode run` reads as the message when
22
29
  * none is given (verified against 2.0.17). Linux rejects any single argv
23
30
  * string over 128 KiB (`spawn E2BIG`), and an argv prompt also let an
@@ -45,16 +52,20 @@ async function runOpenCodeAdapter(options) {
45
52
  // supervisor's PWD in the inherited environment (often the home directory).
46
53
  if (!options.dockerExec && options.cwd)
47
54
  env.PWD = node_path_1.default.resolve(options.cwd);
48
- await prepareOpenCodeConfig(options.mcpConfigPath, env, Boolean(options.dockerExec));
55
+ const probe = (probeArgs) => {
56
+ const probeOptions = { ...options, env, timeoutMs: 10_000, onProcessStarted: undefined };
57
+ return options.dockerExec ? runViaDocker(probeOptions, probeArgs) : runOnHost(probeOptions, probeArgs);
58
+ };
59
+ // Neither generation loads config for --version, so the probe runs before
60
+ // the config is written in the matching dialect.
61
+ const generation = openCodeGeneration(await probe(["--version"]));
62
+ await prepareOpenCodeConfig(options.mcpConfigPath, env, Boolean(options.dockerExec), generation);
49
63
  // V2's default background service owns its own cwd, environment and MCP
50
64
  // configuration. A client process's isolated config does not isolate that
51
65
  // service. Probe help without starting a turn, so older in-process CLIs
52
66
  // remain supported and upgrades are detected on the next session.
53
67
  const runOptions = { ...options, env };
54
- const probeOptions = { ...runOptions, timeoutMs: 10_000, onProcessStarted: undefined };
55
- const help = options.dockerExec
56
- ? await runViaDocker(probeOptions, ["run", "--help"])
57
- : await runOnHost(probeOptions, ["run", "--help"]);
68
+ const help = await probe(["run", "--help"]);
58
69
  if (help.exitCode !== 0 || help.timedOut || help.killedByLeaseLoss) {
59
70
  return { ...help, stderr: `OpenCode CLI capability check failed. ${help.stderr}` };
60
71
  }
@@ -76,10 +87,18 @@ async function runOpenCodeAdapter(options) {
76
87
  if (options.model && options.model !== "default" && !hasArg(options.extraArgs, "--model", "-m")) {
77
88
  // OpenCode V2 folds the reasoning variant into the model reference
78
89
  // (`provider/model#variant`) and hard-fails on a variant the model does
79
- // not advertise. The claim route clamps the effort to the model's
80
- // advertised variants, so a present value is always valid; absent means
81
- // use the model's own default.
82
- args.push("--model", options.reasoningEffort ? `${options.model}#${options.reasoningEffort}` : options.model);
90
+ // not advertise; V1 takes it as `--variant`. The claim route clamps the
91
+ // effort to the model's advertised variants, so a present value is
92
+ // always valid; absent means use the model's own default.
93
+ if (generation === 1) {
94
+ args.push("--model", options.model);
95
+ if (options.reasoningEffort && !hasArg(options.extraArgs, "--variant")) {
96
+ args.push("--variant", options.reasoningEffort);
97
+ }
98
+ }
99
+ else {
100
+ args.push("--model", options.reasoningEffort ? `${options.model}#${options.reasoningEffort}` : options.model);
101
+ }
83
102
  }
84
103
  const run = (runArgs) => runOptions.dockerExec
85
104
  ? runViaDocker(runOptions, runArgs, options.prompt)
@@ -110,10 +129,20 @@ function isLogLevelRejection(stderr) {
110
129
  function resetOpenCodeLogLevelCache() {
111
130
  openCodeLogLevels.clear();
112
131
  }
132
+ /**
133
+ * V1 prints a bare `1.19.1`, V2 `opencode v2.0.17`. Anything unrecognized
134
+ * keeps the V2 dialect the adapter has always written.
135
+ */
136
+ function openCodeGeneration(version) {
137
+ if (version.exitCode !== 0)
138
+ return 2;
139
+ const major = version.stdout.match(/^\s*(?:opencode\s+)?v?(\d+)\.\d+\.\d+/i)?.[1];
140
+ return major !== undefined && Number(major) < 2 ? 1 : 2;
141
+ }
113
142
  function hasArg(args, ...flags) {
114
143
  return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
115
144
  }
116
- async function prepareOpenCodeConfig(mcpConfigPath, env, inDocker) {
145
+ async function prepareOpenCodeConfig(mcpConfigPath, env, inDocker, generation) {
117
146
  if (!mcpConfigPath) {
118
147
  throw new Error("OpenCode requires a per-session MCP config path for isolated execution.");
119
148
  }
@@ -143,28 +172,46 @@ async function prepareOpenCodeConfig(mcpConfigPath, env, inDocker) {
143
172
  mcp[name] = {
144
173
  type: "remote",
145
174
  url,
146
- disabled: false,
175
+ ...(generation === 1 ? { enabled: true } : { disabled: false }),
147
176
  oauth: false,
148
177
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
149
178
  };
150
179
  }
151
- const config = {
152
- $schema: "https://opencode.ai/config.json",
153
- update: "disable",
154
- share: "disabled",
155
- plugins: [],
156
- mcp: { servers: mcp },
157
- permissions: [
158
- { action: "*", resource: "*", effect: "allow" },
159
- { action: "external_directory", resource: "*", effect: "deny" },
160
- ...(await openCodeManagedDirectories(env, inDocker)).map((resource) => ({
161
- action: "external_directory",
162
- resource,
163
- effect: "allow",
164
- })),
165
- { action: "question", resource: "*", effect: "deny" },
166
- ],
167
- };
180
+ const managedDirectories = await openCodeManagedDirectories(env, inDocker);
181
+ // Same policy in both dialects; in each the last matching rule wins.
182
+ const config = generation === 1
183
+ ? {
184
+ $schema: "https://opencode.ai/config.json",
185
+ autoupdate: false,
186
+ share: "disabled",
187
+ plugin: [],
188
+ mcp,
189
+ permission: {
190
+ "*": "allow",
191
+ external_directory: {
192
+ "*": "deny",
193
+ ...Object.fromEntries(managedDirectories.map((resource) => [resource, "allow"])),
194
+ },
195
+ question: "deny",
196
+ },
197
+ }
198
+ : {
199
+ $schema: "https://opencode.ai/config.json",
200
+ update: "disable",
201
+ share: "disabled",
202
+ plugins: [],
203
+ mcp: { servers: mcp },
204
+ permissions: [
205
+ { action: "*", resource: "*", effect: "allow" },
206
+ { action: "external_directory", resource: "*", effect: "deny" },
207
+ ...managedDirectories.map((resource) => ({
208
+ action: "external_directory",
209
+ resource,
210
+ effect: "allow",
211
+ })),
212
+ { action: "question", resource: "*", effect: "deny" },
213
+ ],
214
+ };
168
215
  const configPath = `${mcpConfigPath}.opencode.json`;
169
216
  const configDir = `${mcpConfigPath}.opencode-config`;
170
217
  const xdgConfigHome = `${mcpConfigPath}.opencode-xdg`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.117",
3
+ "version": "0.1.120",
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",