@sagentlab/navarch-runtime 0.1.118 → 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.
@@ -214,6 +214,73 @@ function isInsideOpeningQuote(command, start) {
214
214
  return beforeQuote === undefined || /[\s=(<>;|&]/.test(beforeQuote);
215
215
  }
216
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
+
217
284
  /**
218
285
  * True when a "/"-leading token is comment or markup text rather than a path:
219
286
  * a closing tag (`</p>`), a block-comment opener (`/**`, `/*`), or a line
@@ -222,12 +289,16 @@ function isInsideOpeningQuote(command, start) {
222
289
  * checked, `/*` and `//` only count at a comment position (line start, right
223
290
  * after an opening quote, or after `;`) so `rm -rf /*`, `find // -name x`
224
291
  * and `rm -rf "" /*` stay blocked, and a block comment needs a closing
225
- * `*` + `/` later in the text.
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.
226
295
  */
227
296
  function isCommentOrMarkupToken(command, start, token) {
228
297
  const end = start + token.length;
229
298
  const next = command[end];
230
299
 
300
+ if (isJqAlternativeOperator(command, start, token)) return true;
301
+
231
302
  if (command[start - 1] === "<" && next === ">" && /^\/[A-Za-z][\w:.-]*$/.test(token)) {
232
303
  return !fs.existsSync(token);
233
304
  }
@@ -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.118",
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",