@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.
- package/bin/worktree-guard-hook.cjs +145 -2
- package/dist/adapters/opencode.cjs +77 -30
- package/package.json +1 -1
|
@@ -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)))
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
80
|
-
// advertised variants, so a present value is
|
|
81
|
-
// use the model's own default.
|
|
82
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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