@jskit-ai/assistant-core 0.1.235 → 0.1.237
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/examples/conversation/README.md +4 -1
- package/examples/conversation/integrations.json +6 -4
- package/examples/conversation/package.json +3 -3
- package/package.json +1 -1
- package/src/client/conversation/messageDelivery.js +13 -3
- package/src/server/conversation/codexProcess.js +8 -3
- package/src/server/conversation/localExecution.js +120 -43
- package/src/server/conversation/openCodePlugin.js +3 -2
- package/src/server/conversation/openCodeProcess.js +11 -3
- package/src/server/conversation/openCodeTurn.js +13 -2
- package/src/server/conversation/runtime.js +13 -7
- package/test/aiConnectionClient.test.js +2 -2
- package/test/claudeTurn.test.js +2 -1
- package/test/codexConversationSession.test.js +2 -2
- package/test/codexProcess.test.js +51 -0
- package/test/conversationReplacement.test.js +1 -1
- package/test/conversationSession.test.js +3 -3
- package/test/localExecution.test.js +150 -0
- package/test/messageDelivery.test.js +17 -0
- package/test/openCodeTools.test.js +10 -0
- package/test/opencodeConversationSession.test.js +28 -3
|
@@ -29,9 +29,12 @@ The example uses JSKIT's existing `ai` integration configuration and authorized
|
|
|
29
29
|
connection resolver. To call the explicitly selected model in `integrations.json`:
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
|
-
ASSISTANT_INTEGRATIONS=./integrations.json npm run server
|
|
32
|
+
APP_AI_API_KEY=... ASSISTANT_INTEGRATIONS=./integrations.json npm run server
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
The sample selects Z.AI's GLM-4.7-Flash, which needs a regular Z.AI API key in
|
|
36
|
+
`APP_AI_API_KEY`. Choose your own provider, model and key; none is preselected.
|
|
37
|
+
|
|
35
38
|
Edit that portable configuration through JSKIT's integration editor or its JSON
|
|
36
39
|
contract. Shared keys use `env:NAME` references; the key stays in the server's
|
|
37
40
|
normal environment. The example binds authorization to its one local user.
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
"accountMode": "shared",
|
|
8
8
|
"scopes": [],
|
|
9
9
|
"settings": {
|
|
10
|
-
"model": "
|
|
10
|
+
"model": "zai/glm-4.7-flash"
|
|
11
11
|
},
|
|
12
12
|
"authentication": {
|
|
13
|
-
"method": "
|
|
13
|
+
"method": "api-key",
|
|
14
|
+
"secretRef": "env:APP_AI_API_KEY"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
16
17
|
"suggestions": {
|
|
@@ -18,10 +19,11 @@
|
|
|
18
19
|
"accountMode": "shared",
|
|
19
20
|
"scopes": [],
|
|
20
21
|
"settings": {
|
|
21
|
-
"model": "
|
|
22
|
+
"model": "zai/glm-4.7-flash"
|
|
22
23
|
},
|
|
23
24
|
"authentication": {
|
|
24
|
-
"method": "
|
|
25
|
+
"method": "api-key",
|
|
26
|
+
"secretRef": "env:APP_AI_API_KEY"
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
}
|
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.237",
|
|
12
12
|
"@jskit-ai/http-runtime": "0.1.242",
|
|
13
13
|
"@jskit-ai/kernel": "0.1.244",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
17
|
"@jskit-ai/connectors-core": "0.1.61",
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.62"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
22
|
-
"vite": "^8.2.
|
|
22
|
+
"vite": "^8.2.4"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/package.json
CHANGED
|
@@ -59,7 +59,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function turns(savedTurns = []) {
|
|
62
|
-
|
|
62
|
+
const shown = [
|
|
63
63
|
...savedTurns.map(turn => {
|
|
64
64
|
const message = state.messages.find(message => message.status !== "accepted" &&
|
|
65
65
|
turnMatchesOptimisticMessage(turn, message) &&
|
|
@@ -67,9 +67,19 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
|
|
|
67
67
|
if (!message) return turn;
|
|
68
68
|
const pending = optimisticTurn(message);
|
|
69
69
|
return { ...turn, optimistic: pending.optimistic, ...(pending.system ? { system: pending.system } : {}) };
|
|
70
|
-
})
|
|
71
|
-
...unmatchedOptimisticMessages(savedTurns, state.messages).map(optimisticTurn)
|
|
70
|
+
})
|
|
72
71
|
];
|
|
72
|
+
// Pending and uncertain messages follow the saved history. A failed message
|
|
73
|
+
// never reached the conversation, so it keeps its own time: newer saved turns
|
|
74
|
+
// and the working indicator stay below it instead of looking blocked by it.
|
|
75
|
+
for (const message of unmatchedOptimisticMessages(savedTurns, state.messages)) {
|
|
76
|
+
const turn = optimisticTurn(message);
|
|
77
|
+
const later = message.status !== "failed" ? -1 : shown.findIndex(saved => !saved.optimistic &&
|
|
78
|
+
Date.parse((saved.user || saved.system)?.at || "") > message.createdAtMs);
|
|
79
|
+
if (later < 0) shown.push(turn);
|
|
80
|
+
else shown.splice(later, 0, turn);
|
|
81
|
+
}
|
|
82
|
+
return shown;
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
function optimisticTurn(message) {
|
|
@@ -572,18 +572,23 @@ export function createCodexAppServerRuntime({ execution, credentials, configurat
|
|
|
572
572
|
return acquireCodexRuntimeLock(runtimeDir, { ...options, busyCode: `${errorPrefix}codex_app_server_runtime_busy` });
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
-
|
|
575
|
+
// An empty or null umask inherits the host's own; hosts that share files set one.
|
|
576
|
+
const startupUmask = normalizeText(umask);
|
|
577
|
+
if (startupUmask && !/^[0-7]{3,4}$/u.test(startupUmask)) {
|
|
578
|
+
throw new TypeError(`Codex app-server umask must be octal, not ${JSON.stringify(startupUmask)}.`);
|
|
579
|
+
}
|
|
580
|
+
const CODEX_APP_SERVER_UMASK_LINES = startupUmask ? [`umask ${startupUmask}`] : [];
|
|
576
581
|
|
|
577
582
|
const CODEX_APP_SERVER_MANAGED_SHELL = "/bin/sh";
|
|
578
583
|
|
|
579
584
|
const CODEX_APP_SERVER_MANAGED_STARTUP_SCRIPT = [
|
|
580
|
-
|
|
585
|
+
...CODEX_APP_SERVER_UMASK_LINES,
|
|
581
586
|
`unset ${[...CODEX_APP_SERVER_DESKTOP_BUS_ENV_NAMES].join(" ")}`,
|
|
582
587
|
'exec "$@"'
|
|
583
588
|
].join("\n");
|
|
584
589
|
|
|
585
590
|
const CODEX_APP_SERVER_HELPER_STARTUP_SCRIPT = [
|
|
586
|
-
|
|
591
|
+
...CODEX_APP_SERVER_UMASK_LINES,
|
|
587
592
|
"exec /usr/bin/env -i \\",
|
|
588
593
|
' HOME="$HOME" LOGNAME="$LOGNAME" USER="$USER" PATH="$PATH" \\',
|
|
589
594
|
' CODEX_HOME="$CODEX_HOME" LANG="$LANG" LC_ALL="$LC_ALL" LC_CTYPE="$LC_CTYPE" \\',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { closeSync, openSync } from "node:fs";
|
|
4
4
|
import { mkdtemp, open, readFile, readdir, rm } from "node:fs/promises";
|
|
@@ -6,6 +6,18 @@ import { isPlainObject, normalizeText } from "./normalize.js";
|
|
|
6
6
|
import { isAbsolute, join } from "node:path";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
// Fixed locale and zone make `lstart` parseable and stable across restarts.
|
|
14
|
+
async function runPs(args) {
|
|
15
|
+
const { stdout } = await execFileAsync("ps", args, {
|
|
16
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
17
|
+
maxBuffer: 64 * 1024 * 1024
|
|
18
|
+
});
|
|
19
|
+
return stdout;
|
|
20
|
+
}
|
|
9
21
|
|
|
10
22
|
/** Owned POSIX process groups for a trusted, single-user application.
|
|
11
23
|
* Managed hosts replace this facility to enforce resource and identity policy.
|
|
@@ -170,12 +182,16 @@ export function createConversationProcessIdentity({
|
|
|
170
182
|
runtimeTokenEnvironmentName = "JSKIT_EXECUTION_RUNTIME_TOKEN",
|
|
171
183
|
commandHashEnvironmentName = "JSKIT_EXECUTION_COMMAND_HASH",
|
|
172
184
|
label = "Native",
|
|
173
|
-
unavailableCode = "native_process_identity_unavailable"
|
|
185
|
+
unavailableCode = "native_process_identity_unavailable",
|
|
186
|
+
platform = process.platform,
|
|
187
|
+
ps = runPs
|
|
174
188
|
} = {}) {
|
|
175
189
|
const PROCESS_IDENTITY_SETTLE_TIMEOUT_MS = 1000;
|
|
176
|
-
|
|
190
|
+
// Darwin inspection spawns ps instead of reading /proc, so it polls less often.
|
|
191
|
+
const PROCESS_IDENTITY_SETTLE_POLL_MS = platform === "darwin" ? 100 : 25;
|
|
177
192
|
const PROCESS_IDENTITY_VERSION = 1;
|
|
178
|
-
const
|
|
193
|
+
const PROCESS_IDENTITY_PLATFORMS = Object.freeze({ linux: "linux-proc", darwin: "darwin" });
|
|
194
|
+
const hostIdentityPlatform = PROCESS_IDENTITY_PLATFORMS[platform] || "";
|
|
179
195
|
const PROCESS_STATE = Object.freeze({ RUNNING: "running", STOPPED: "stopped" });
|
|
180
196
|
const PROCESS_IDENTITY_STATUS = Object.freeze({
|
|
181
197
|
ABSENT: "absent", AMBIGUOUS: "ambiguous", EXACT: "exact", INVALID: "invalid", MISMATCH: "mismatch"
|
|
@@ -281,21 +297,17 @@ export function createConversationProcessIdentity({
|
|
|
281
297
|
const identity = normalizeProcessIdentity(value);
|
|
282
298
|
return Boolean(
|
|
283
299
|
identity.version === PROCESS_IDENTITY_VERSION &&
|
|
284
|
-
identity.platform
|
|
300
|
+
Object.values(PROCESS_IDENTITY_PLATFORMS).includes(identity.platform) &&
|
|
285
301
|
/^[a-f0-9]{12}$/u.test(identity.commandHash) &&
|
|
286
302
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(identity.runtimeToken) &&
|
|
287
303
|
/^\d+$/u.test(identity.startTimeTicks)
|
|
288
304
|
);
|
|
289
305
|
}
|
|
290
306
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
return entry.slice(prefix.length);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return "";
|
|
307
|
+
/** The value of `name` in a list of NAME=value environment entries. */
|
|
308
|
+
function environmentValue(entries = [], name = "") {
|
|
309
|
+
const entry = entries.find((assignment) => assignment.startsWith(`${name}=`));
|
|
310
|
+
return entry ? entry.slice(name.length + 1) : "";
|
|
299
311
|
}
|
|
300
312
|
|
|
301
313
|
async function linuxProcessRecords() {
|
|
@@ -323,11 +335,80 @@ export function createConversationProcessIdentity({
|
|
|
323
335
|
}
|
|
324
336
|
environmentReadable = false;
|
|
325
337
|
}
|
|
338
|
+
const entries = environmentReadable ? environment.toString("utf8").split("\0") : [];
|
|
326
339
|
records.push({
|
|
327
340
|
...processStat,
|
|
328
|
-
|
|
341
|
+
commandHash: environmentValue(entries, commandHashEnvironmentName),
|
|
329
342
|
environmentReadable,
|
|
330
|
-
pid
|
|
343
|
+
pid,
|
|
344
|
+
runtimeToken: environmentValue(entries, runtimeTokenEnvironmentName)
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return records;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const DARWIN_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
351
|
+
|
|
352
|
+
/** `ps -o lstart=` in the C locale and UTC, as whole epoch seconds. */
|
|
353
|
+
function darwinStartTime(value = "") {
|
|
354
|
+
const match = /^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/u.exec(String(value).trim());
|
|
355
|
+
const month = match ? DARWIN_MONTHS.indexOf(match[1]) : -1;
|
|
356
|
+
if (month < 0) {
|
|
357
|
+
return "";
|
|
358
|
+
}
|
|
359
|
+
const [, , day, hours, minutes, seconds, year] = match.map(Number);
|
|
360
|
+
return String(Date.UTC(year, month, day, hours, minutes, seconds) / 1000);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function darwinCommandsByPid(output = "") {
|
|
364
|
+
const commands = new Map();
|
|
365
|
+
for (const line of String(output).split("\n")) {
|
|
366
|
+
const match = /^\s*(\d+) (.*)$/u.exec(line);
|
|
367
|
+
if (match) {
|
|
368
|
+
commands.set(Number(match[1]), match[2]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return commands;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The `ps -E` text after the argument list, as its assignments, or null when no environment is shown. */
|
|
375
|
+
function darwinEnvironment(command, commandWithEnvironment) {
|
|
376
|
+
if (command === undefined || !commandWithEnvironment?.startsWith(command)) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const assignments = commandWithEnvironment.slice(command.length).split(/\s+/u).filter((entry) => /^[^=\s]+=/u.test(entry));
|
|
380
|
+
return assignments.length ? assignments : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The darwin counterpart of the /proc scan. Only the current user's environments are readable;
|
|
384
|
+
* a listing without a separate environment section stays unreadable so inspection degrades to ambiguous. */
|
|
385
|
+
async function darwinProcessRecords() {
|
|
386
|
+
const currentUid = process.getuid();
|
|
387
|
+
const ownProcesses = ["-x", "-U", String(currentUid), "-ww", "-o", "pid=,command="];
|
|
388
|
+
const [table, commandOutput, environmentOutput] = await Promise.all([
|
|
389
|
+
ps(["-ax", "-o", "pid=,pgid=,uid=,stat=,lstart="]),
|
|
390
|
+
ps(ownProcesses),
|
|
391
|
+
ps(["-E", ...ownProcesses])
|
|
392
|
+
]);
|
|
393
|
+
const commands = darwinCommandsByPid(commandOutput);
|
|
394
|
+
const commandsWithEnvironment = darwinCommandsByPid(environmentOutput);
|
|
395
|
+
const records = [];
|
|
396
|
+
for (const line of String(table).split("\n")) {
|
|
397
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+[\d:]{8}\s+\d{4})\s*$/u.exec(line);
|
|
398
|
+
if (!match || match[4].startsWith("Z")) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const pid = Number(match[1]);
|
|
402
|
+
const environment = Number(match[3]) === currentUid
|
|
403
|
+
? darwinEnvironment(commands.get(pid), commandsWithEnvironment.get(pid))
|
|
404
|
+
: null;
|
|
405
|
+
records.push({
|
|
406
|
+
commandHash: environmentValue(environment || [], commandHashEnvironmentName),
|
|
407
|
+
environmentReadable: Boolean(environment),
|
|
408
|
+
pid,
|
|
409
|
+
processGroupId: Number(match[2]),
|
|
410
|
+
runtimeToken: environmentValue(environment || [], runtimeTokenEnvironmentName),
|
|
411
|
+
startTimeTicks: darwinStartTime(match[5])
|
|
331
412
|
});
|
|
332
413
|
}
|
|
333
414
|
return records;
|
|
@@ -347,7 +428,7 @@ export function createConversationProcessIdentity({
|
|
|
347
428
|
};
|
|
348
429
|
}
|
|
349
430
|
|
|
350
|
-
async function
|
|
431
|
+
async function inspectHostProcessIdentity(metadata = {}) {
|
|
351
432
|
if (!processMetadataIsIdentifiable(metadata)) {
|
|
352
433
|
return {
|
|
353
434
|
processGroupIds: [],
|
|
@@ -360,14 +441,18 @@ export function createConversationProcessIdentity({
|
|
|
360
441
|
status: PROCESS_IDENTITY_STATUS.ABSENT
|
|
361
442
|
};
|
|
362
443
|
}
|
|
363
|
-
|
|
444
|
+
const identity = metadata.processIdentity;
|
|
445
|
+
const records = identity.platform !== hostIdentityPlatform
|
|
446
|
+
? null
|
|
447
|
+
: platform === "darwin"
|
|
448
|
+
? await darwinProcessRecords().catch(() => null)
|
|
449
|
+
: await linuxProcessRecords();
|
|
450
|
+
if (!records) {
|
|
364
451
|
return {
|
|
365
452
|
processGroupIds: [],
|
|
366
453
|
status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
|
|
367
454
|
};
|
|
368
455
|
}
|
|
369
|
-
const identity = metadata.processIdentity;
|
|
370
|
-
const records = await linuxProcessRecords();
|
|
371
456
|
const rootProcessGroupId = Number(metadata.pid);
|
|
372
457
|
const rootGroupMembers = records.filter(({ processGroupId }) => processGroupId === rootProcessGroupId);
|
|
373
458
|
const exactRecords = [];
|
|
@@ -376,15 +461,7 @@ export function createConversationProcessIdentity({
|
|
|
376
461
|
if (!record.environmentReadable) {
|
|
377
462
|
continue;
|
|
378
463
|
}
|
|
379
|
-
|
|
380
|
-
record.environment,
|
|
381
|
-
runtimeTokenEnvironmentName
|
|
382
|
-
);
|
|
383
|
-
const commandHash = linuxProcessEnvironmentValue(
|
|
384
|
-
record.environment,
|
|
385
|
-
commandHashEnvironmentName
|
|
386
|
-
);
|
|
387
|
-
if (runtimeToken === identity.runtimeToken && commandHash === identity.commandHash) {
|
|
464
|
+
if (record.runtimeToken === identity.runtimeToken && record.commandHash === identity.commandHash) {
|
|
388
465
|
exactRecords.push(record);
|
|
389
466
|
}
|
|
390
467
|
}
|
|
@@ -438,7 +515,7 @@ export function createConversationProcessIdentity({
|
|
|
438
515
|
async function inspectProcessIdentity(metadata = {}, options = {}) {
|
|
439
516
|
const inspector = typeof options.processIdentityInspector === "function"
|
|
440
517
|
? options.processIdentityInspector
|
|
441
|
-
:
|
|
518
|
+
: inspectHostProcessIdentity;
|
|
442
519
|
return normalizeProcessIdentityInspection(await inspector(metadata));
|
|
443
520
|
}
|
|
444
521
|
|
|
@@ -610,6 +687,15 @@ export function createConversationProcessIdentity({
|
|
|
610
687
|
};
|
|
611
688
|
}
|
|
612
689
|
|
|
690
|
+
async function captureProcessStart(pid) {
|
|
691
|
+
if (platform !== "darwin") {
|
|
692
|
+
return linuxProcessStat(await readFile(`/proc/${pid}/stat`, "utf8").catch(() => ""));
|
|
693
|
+
}
|
|
694
|
+
const output = String(await ps(["-o", "pgid=,lstart=", "-p", String(pid)]).catch(() => ""));
|
|
695
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/u.exec(output);
|
|
696
|
+
return match ? { processGroupId: Number(match[1]), startTimeTicks: darwinStartTime(match[2]) } : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
613
699
|
async function captureProcessIdentity({
|
|
614
700
|
commandHash = "",
|
|
615
701
|
pid = null,
|
|
@@ -627,26 +713,17 @@ export function createConversationProcessIdentity({
|
|
|
627
713
|
return reported;
|
|
628
714
|
}
|
|
629
715
|
const normalizedPid = Number(pid);
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
) {
|
|
635
|
-
const error = new Error(`${label} process identity could not be captured.`);
|
|
636
|
-
error.code = unavailableCode;
|
|
637
|
-
throw error;
|
|
638
|
-
}
|
|
639
|
-
const processStat = linuxProcessStat(
|
|
640
|
-
await readFile(`/proc/${normalizedPid}/stat`, "utf8").catch(() => "")
|
|
641
|
-
);
|
|
642
|
-
if (!processStat || processStat.processGroupId !== normalizedPid) {
|
|
716
|
+
const processStat = hostIdentityPlatform && Number.isSafeInteger(normalizedPid) && normalizedPid > 0
|
|
717
|
+
? await captureProcessStart(normalizedPid)
|
|
718
|
+
: null;
|
|
719
|
+
if (!processStat?.startTimeTicks || processStat.processGroupId !== normalizedPid) {
|
|
643
720
|
const error = new Error(`${label} process identity could not be captured.`);
|
|
644
721
|
error.code = unavailableCode;
|
|
645
722
|
throw error;
|
|
646
723
|
}
|
|
647
724
|
return {
|
|
648
725
|
commandHash: expectedCommandHash,
|
|
649
|
-
platform:
|
|
726
|
+
platform: hostIdentityPlatform,
|
|
650
727
|
runtimeToken: expectedRuntimeToken,
|
|
651
728
|
startTimeTicks: processStat.startTimeTicks,
|
|
652
729
|
version: PROCESS_IDENTITY_VERSION
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createOpenCodeConversationAdapter } from "./providers/opencode.js";
|
|
2
2
|
import { wrapNativeCommand } from "./commandWrapper.js";
|
|
3
|
+
import { OPENCODE_REFUSED_TOOL_ERROR } from "./openCodeTurn.js";
|
|
3
4
|
import { readOpenCodeEnvironments, openCodeEnvironmentForSession, openCodeEnvironmentForDirectory,
|
|
4
5
|
assertOpenCodeModelProvider, limitOpenCodeModelOutput } from "./openCodeRuntime.js";
|
|
5
6
|
|
|
@@ -44,7 +45,7 @@ export default async function conversationInstructions({ client, registryPath =
|
|
|
44
45
|
? conversation?.tools?.schemas.some(({ function: definition }) => definition.name === tool)
|
|
45
46
|
: conversation?.nativeTools;
|
|
46
47
|
if (!allowed) {
|
|
47
|
-
throw new Error(
|
|
48
|
+
throw new Error(OPENCODE_REFUSED_TOOL_ERROR);
|
|
48
49
|
}
|
|
49
50
|
if (tool === "bash" && conversation.commandWrapper) {
|
|
50
51
|
output.args.command = wrapNativeCommand(conversation.commandWrapper, output.args.command);
|
|
@@ -70,7 +71,7 @@ export default async function conversationInstructions({ client, registryPath =
|
|
|
70
71
|
}
|
|
71
72
|
const connection = (await readBinding(context.sessionID)).conversation?.tools;
|
|
72
73
|
if (!connection?.schemas.some(({ function: definition }) => definition.name === name)) {
|
|
73
|
-
throw new Error(
|
|
74
|
+
throw new Error(OPENCODE_REFUSED_TOOL_ERROR);
|
|
74
75
|
}
|
|
75
76
|
const response = await fetch(connection.url, { method: "POST", signal: context.abort,
|
|
76
77
|
headers: { authorization: `Bearer ${connection.token}`, "content-type": "application/json" },
|
|
@@ -118,7 +118,7 @@ async function waitForOpenCodeReady({
|
|
|
118
118
|
throw error;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
const actionPermission = Object.fromEntries(DISCOVERY_TOOL_DESCRIPTORS.map(tool => [tool.name,
|
|
121
|
+
const actionPermission = action => Object.fromEntries(DISCOVERY_TOOL_DESCRIPTORS.map(tool => [tool.name, action]));
|
|
122
122
|
const nativePermission = { bash: "allow", read: "allow", write: "allow", edit: "allow",
|
|
123
123
|
glob: "allow", grep: "allow", list: "allow", patch: "allow", apply_patch: "allow", todoread: "allow", todowrite: "allow" };
|
|
124
124
|
export const openCodeApplicationToolSchemas = DISCOVERY_TOOL_DESCRIPTORS.map(({ name, description, parameters }) => ({
|
|
@@ -136,12 +136,20 @@ export function openCodeConversationAgents({ sessionEnvironmentRegistry = "" } =
|
|
|
136
136
|
hidden: true, mode: "primary",
|
|
137
137
|
// Keep native definitions behind approval, as in the original host agents.
|
|
138
138
|
// The registered plugin rejects every ungranted native call before execution.
|
|
139
|
-
permission: nativeTools || tools || guarded ?
|
|
140
|
-
...(nativeTools ? nativePermission : {}), ...(tools ? actionPermission : {}) } : "deny"
|
|
139
|
+
permission: nativeTools || tools || guarded ? agentPermission(!nativeTools && guarded, nativeTools, tools) : "deny"
|
|
141
140
|
}
|
|
142
141
|
])));
|
|
143
142
|
}
|
|
144
143
|
|
|
144
|
+
// OpenCode applies an agent's last matching rule, so its "*": "ask" overrides the
|
|
145
|
+
// global policy. Restate two denials after it: application tools the agent does not
|
|
146
|
+
// own are not offered to the model, and the repeated-call check does not wait for
|
|
147
|
+
// an approval that no unattended conversation can give.
|
|
148
|
+
function agentPermission(ask, nativeTools, tools) {
|
|
149
|
+
return { "*": ask ? "ask" : "deny", ...(nativeTools ? nativePermission : {}),
|
|
150
|
+
...(tools ? actionPermission("allow") : ask ? actionPermission("deny") : {}), ...(ask ? { doom_loop: "deny" } : {}) };
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
/** Shared conversation configuration around the extracted server lifecycle. */
|
|
146
154
|
export async function createOpenCodeConversationServer({
|
|
147
155
|
command = "opencode", execution = createLocalConversationExecution(), env = process.env,
|
|
@@ -223,10 +223,21 @@ export function openCodePersistentConversationMessages(rows, conversationMessage
|
|
|
223
223
|
});
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// The plugin refuses every native call this conversation was not granted. OpenCode
|
|
227
|
+
// returns each refusal to the model, which can keep retrying without ever answering.
|
|
228
|
+
export const OPENCODE_REFUSED_TOOL_ERROR = "This conversation has no authorization for that native tool.";
|
|
229
|
+
const OPENCODE_REFUSED_TOOL_LIMIT = 5;
|
|
230
|
+
|
|
226
231
|
export async function projectOpenCodeConversationMessages(messages, expectedId, {
|
|
227
232
|
maximumOutput, outputSchema, previous, onMessage
|
|
228
233
|
}) {
|
|
229
|
-
|
|
234
|
+
const rows = openCodeRowsForInput(messages, expectedId);
|
|
235
|
+
const refused = rows.flatMap(message => message.content || []).filter(part =>
|
|
236
|
+
part.type === "tool" && part.state?.status === "error" && part.state.error === OPENCODE_REFUSED_TOOL_ERROR).length;
|
|
237
|
+
if (refused >= OPENCODE_REFUSED_TOOL_LIMIT) {
|
|
238
|
+
throw new Error("The model kept calling tools that this conversation does not provide and gave no answer. No tool ran. Your message is kept; retry or choose another model.");
|
|
239
|
+
}
|
|
240
|
+
for (const message of rows) {
|
|
230
241
|
if (message.type !== "assistant" || message.summary || message.error) continue;
|
|
231
242
|
const text = assistantMessageText(message);
|
|
232
243
|
const thinking = (message.content || []).filter(part => part.type === "reasoning").map(part => part.text || "").join("\n\n");
|
|
@@ -243,7 +254,7 @@ export async function projectOpenCodeConversationMessages(messages, expectedId,
|
|
|
243
254
|
}
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
|
-
const result = openCodeLastAssistantResult(
|
|
257
|
+
const result = openCodeLastAssistantResult(rows);
|
|
247
258
|
return { failure: result.error, providerApiFailure: text(result.message?.error?.name) === "APIError" };
|
|
248
259
|
}
|
|
249
260
|
|
|
@@ -442,13 +442,19 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
442
442
|
: Math.max(0, initialWindow - (request?.input.origin === "user" ? 1 : 0)),
|
|
443
443
|
maximumInitialMessageCharacters: limits.maxInitialNativeHistoryMessageCharacters,
|
|
444
444
|
...(budget ? { maximumHistoryBytes: async options => {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
445
|
+
try {
|
|
446
|
+
await access(context, entry.id, request.operation, entry);
|
|
447
|
+
signal.throwIfAborted();
|
|
448
|
+
// A host may free native context first; show that as compacting.
|
|
449
|
+
const bytes = await budget({ ...options, context, signal,
|
|
450
|
+
setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
|
|
451
|
+
signal.throwIfAborted();
|
|
452
|
+
return bytes;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
// Sizing missed history precedes native dispatch, so this message
|
|
455
|
+
// was definitely not sent. Hosts and clients report it as such.
|
|
456
|
+
throw Object.assign(error, { delivery: "not-sent" });
|
|
457
|
+
}
|
|
452
458
|
} } : {}),
|
|
453
459
|
presentation: entry.conversation?.presentation || { unconfirmedCode: "conversation_delivery_uncertain",
|
|
454
460
|
unconfirmedMessage: "The previous submission has uncertain delivery. Retry this message to check its native receipt; it will not be resent automatically." },
|
|
@@ -4,7 +4,7 @@ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/
|
|
|
4
4
|
import { createAiConnectionClient } from "../src/server/lib/aiConnectionClient.js";
|
|
5
5
|
import { createAiClient } from "../src/server/lib/aiClient.js";
|
|
6
6
|
|
|
7
|
-
function publicConnection(model = "opencode/
|
|
7
|
+
function publicConnection(model = "opencode/mimo-v2.5-free") {
|
|
8
8
|
return createAiConnectionResolver({
|
|
9
9
|
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
10
10
|
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model } }
|
|
@@ -28,7 +28,7 @@ test("the configured public AI integration streams its first chunk before comple
|
|
|
28
28
|
assert.equal(String(url), "https://opencode.ai/zen/v1/chat/completions");
|
|
29
29
|
assert.equal(new Headers(options.headers).get("authorization"), "Bearer public");
|
|
30
30
|
const body = JSON.parse(options.body);
|
|
31
|
-
assert.equal(body.model, "
|
|
31
|
+
assert.equal(body.model, "mimo-v2.5-free");
|
|
32
32
|
assert.equal(body.stream, true);
|
|
33
33
|
return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
|
|
34
34
|
}
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -2361,7 +2361,8 @@ test("bound Main cannot dispatch or retain a pending prompt when its history bud
|
|
|
2361
2361
|
f.binding.transcript.history = async () => [{ id: "earlier", version: "v1", role: "user", text: "Earlier work." }];
|
|
2362
2362
|
await f.binding.state.write({ lastEngine: "codex", engines: { codex: { seen: {} } } });
|
|
2363
2363
|
f.binding.maximumHistoryBytes = async () => { throw new Error("Current native context usage unavailable"); };
|
|
2364
|
-
await assert.rejects(f.conversation.send({ messageId: "no-budget", text: "Continue" }),
|
|
2364
|
+
await assert.rejects(f.conversation.send({ messageId: "no-budget", text: "Continue" }), error =>
|
|
2365
|
+
/Current native context usage unavailable/u.test(error.message) && error.delivery === "not-sent");
|
|
2365
2366
|
assert.equal(f.processes.length, 0);
|
|
2366
2367
|
assert.equal(f.messages.length, 0);
|
|
2367
2368
|
assert.equal((await f.binding.state.read())?.engines?.claude?.pending, undefined);
|
|
@@ -842,7 +842,7 @@ test("Codex receives authorized image content and retains receipts through uncer
|
|
|
842
842
|
|
|
843
843
|
function apiOptions() {
|
|
844
844
|
return { connections: createAiConnectionResolver({ configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
845
|
-
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/
|
|
845
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
|
|
846
846
|
} }, authorize: () => ({ applicationId: "test", subjectId: "owner" }) }), fetch: async () => {
|
|
847
847
|
const chunk = { choices: [{ index: 0, delta: { content: "API answer" }, finish_reason: "stop" }] };
|
|
848
848
|
return new Response(`data: ${JSON.stringify(chunk)}\n\n`, { headers: { "content-type": "text/event-stream" } });
|
|
@@ -1998,7 +1998,7 @@ test("a failed write while recording rejected steering can be recovered when the
|
|
|
1998
1998
|
test("one logical conversation switches between Codex and API across restart without duplicating the displayed history", async t => {
|
|
1999
1999
|
const requests = [];
|
|
2000
2000
|
const connections = createAiConnectionResolver({ configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
2001
|
-
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/
|
|
2001
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
|
|
2002
2002
|
} }, authorize: () => ({ applicationId: "test", subjectId: "owner" }) });
|
|
2003
2003
|
const f = await fixture(t, { connections, fetch: async (_url, init) => {
|
|
2004
2004
|
requests.push(JSON.parse(init.body));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { createCodexAppServerRuntime } from "../src/server/conversation/codexProcess.js";
|
|
7
|
+
|
|
8
|
+
/** Starts a runtime whose execution host records the launch and reports failure. */
|
|
9
|
+
async function launchRequest(t, configuration, executionMode = "interactive") {
|
|
10
|
+
const runtimeDir = await mkdtemp(join(tmpdir(), "jskit-codex-"));
|
|
11
|
+
t.after(() => rm(runtimeDir, { recursive: true, force: true }));
|
|
12
|
+
let request;
|
|
13
|
+
const runtime = createCodexAppServerRuntime({
|
|
14
|
+
configuration,
|
|
15
|
+
credentials: { identity: async () => "account", generation: async () => "generation", assertCurrent: async () => {} },
|
|
16
|
+
execution: {
|
|
17
|
+
async run(value) {
|
|
18
|
+
request = value;
|
|
19
|
+
return { ok: false, output: "recorded launch" };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
await assert.rejects(runtime.start({ codexCommand: process.execPath, executionMode, runtimeDir }), /recorded launch/);
|
|
24
|
+
return request;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("the default startup script keeps the shared-file umask in launch and process profile", async (t) => {
|
|
28
|
+
for (const executionMode of ["interactive", "helper"]) {
|
|
29
|
+
const request = await launchRequest(t, {}, executionMode);
|
|
30
|
+
assert.match(request.args[1], /^umask 0007\n/);
|
|
31
|
+
assert.equal(request.processProfile.startup, request.args[1]);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("an empty umask inherits the host's own and emits no umask line", async (t) => {
|
|
36
|
+
for (const executionMode of ["interactive", "helper"]) {
|
|
37
|
+
const request = await launchRequest(t, { umask: "" }, executionMode);
|
|
38
|
+
assert.doesNotMatch(request.args[1], /umask/);
|
|
39
|
+
assert.equal(request.processProfile.startup, request.args[1]);
|
|
40
|
+
}
|
|
41
|
+
assert.doesNotMatch((await launchRequest(t, { umask: null })).args[1], /umask/);
|
|
42
|
+
assert.match((await launchRequest(t, { umask: "0022" })).args[1], /^umask 0022\n/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a non-octal umask is refused before it reaches the startup script", () => {
|
|
46
|
+
assert.throws(() => createCodexAppServerRuntime({
|
|
47
|
+
configuration: { umask: "0007; touch /tmp/x" },
|
|
48
|
+
credentials: {},
|
|
49
|
+
execution: {}
|
|
50
|
+
}), /umask must be octal/);
|
|
51
|
+
});
|
|
@@ -9,7 +9,7 @@ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/
|
|
|
9
9
|
const configuration = { systemPrompt: "Answer the current question.", integrationId: "assistant" };
|
|
10
10
|
const connections = createAiConnectionResolver({
|
|
11
11
|
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
12
|
-
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/
|
|
12
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
|
|
13
13
|
} }, authorize: () => ({ applicationId: "test", subjectId: "owner" })
|
|
14
14
|
});
|
|
15
15
|
|
|
@@ -21,7 +21,7 @@ async function fixture(t, options = {}) {
|
|
|
21
21
|
const storage = options.storage || createMemoryConversationStorage();
|
|
22
22
|
const connections = createAiConnectionResolver({
|
|
23
23
|
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
24
|
-
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/
|
|
24
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
|
|
25
25
|
} },
|
|
26
26
|
authorize: actor => actor === context && state.modelAllowed ? actor : null
|
|
27
27
|
});
|
|
@@ -96,10 +96,10 @@ test("saved history and current instructions use the existing authorized model c
|
|
|
96
96
|
|
|
97
97
|
test("API configuration uses the common model and effort fields without overriding the authorized selection", async t => {
|
|
98
98
|
const f = await fixture(t);
|
|
99
|
-
await f.conversation.configure({ model: "
|
|
99
|
+
await f.conversation.configure({ model: "mimo-v2.5-free", effort: "high" });
|
|
100
100
|
await f.conversation.send(input);
|
|
101
101
|
assert.equal((await f.conversation.wait()).conversationLog[0].metadata.runtime.status, "complete");
|
|
102
|
-
assert.equal(f.requests[0].model, "
|
|
102
|
+
assert.equal(f.requests[0].model, "mimo-v2.5-free");
|
|
103
103
|
assert.equal(f.requests[0].reasoning_effort, "high");
|
|
104
104
|
await assert.rejects(f.conversation.configure({ effort: "arbitrary" }), /does not support/);
|
|
105
105
|
await assert.rejects(f.conversation.configure({ systemPrompt: "a".repeat(128001) }), /configuration requires/);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { createConversationProcessIdentity } from "../src/server/conversation/localExecution.js";
|
|
6
|
+
|
|
7
|
+
const TOKEN_ENV = "JSKIT_EXECUTION_RUNTIME_TOKEN";
|
|
8
|
+
const HASH_ENV = "JSKIT_EXECUTION_COMMAND_HASH";
|
|
9
|
+
const runtimeToken = "0b6c8f8e-3f7a-4c1e-9d2b-5a4e6f7a8b9c";
|
|
10
|
+
const commandHash = "abcdef012345";
|
|
11
|
+
// Process-group ids far above any live pid, so liveness probes find nothing.
|
|
12
|
+
const LEADER = 9_000_001;
|
|
13
|
+
const CHILD = 9_000_002;
|
|
14
|
+
const STARTED = "Wed Oct 21 10:00:00 2026";
|
|
15
|
+
const STARTED_SECONDS = String(Date.UTC(2026, 9, 21, 10, 0, 0) / 1000);
|
|
16
|
+
const uid = process.getuid?.() ?? 0;
|
|
17
|
+
|
|
18
|
+
const IDENTITY_ENV = `PATH=/usr/bin ${TOKEN_ENV}=${runtimeToken} ${HASH_ENV}=${commandHash}`;
|
|
19
|
+
|
|
20
|
+
function row(pid, pgid, { owner = uid, stat = "Ss", started = STARTED, args = "/usr/bin/codex app-server", env = IDENTITY_ENV } = {}) {
|
|
21
|
+
return { pid, pgid, owner, stat, started, args, env };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Renders the three darwin listings: every process, then the user's commands without and with `-E`. */
|
|
25
|
+
function psListing(rows, args) {
|
|
26
|
+
if (args[0] === "-ax") {
|
|
27
|
+
return rows.map(({ pid, pgid, owner, stat, started }) =>
|
|
28
|
+
`${String(pid).padStart(7)} ${String(pgid).padStart(7)} ${String(owner).padStart(5)} ${stat.padEnd(4)} ${started}`).join("\n");
|
|
29
|
+
}
|
|
30
|
+
const withEnvironment = args[0] === "-E";
|
|
31
|
+
return rows.filter(({ owner }) => owner === uid).map(({ pid, args: command, env }) =>
|
|
32
|
+
`${String(pid).padStart(7)} ${command}${withEnvironment && env ? ` ${env}` : ""}`).join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function darwinIdentity(table, captureOutput = `${LEADER} ${STARTED}`) {
|
|
36
|
+
const calls = [];
|
|
37
|
+
const identity = createConversationProcessIdentity({
|
|
38
|
+
platform: "darwin",
|
|
39
|
+
async ps(args) {
|
|
40
|
+
calls.push(args);
|
|
41
|
+
if (args.includes("-p")) return captureOutput;
|
|
42
|
+
if (table instanceof Error) throw table;
|
|
43
|
+
return psListing(table, args);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return { calls, identity };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const metadata = (overrides = {}) => ({
|
|
50
|
+
pid: LEADER,
|
|
51
|
+
processState: "running",
|
|
52
|
+
processIdentity: { commandHash, platform: "darwin", runtimeToken, startTimeTicks: STARTED_SECONDS, version: 1, ...overrides }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("darwin capture records the group leader's ps start time as epoch seconds", async () => {
|
|
56
|
+
const { calls, identity } = darwinIdentity([]);
|
|
57
|
+
const captured = await identity.capture({ commandHash, pid: LEADER, runtimeToken });
|
|
58
|
+
assert.deepEqual(captured, { commandHash, platform: "darwin", runtimeToken, startTimeTicks: STARTED_SECONDS, version: 1 });
|
|
59
|
+
assert.deepEqual(calls, [["-o", "pgid=,lstart=", "-p", String(LEADER)]]);
|
|
60
|
+
assert.equal(identity.isValid(captured), true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("darwin capture parses the padded BSD ps `pgid= lstart=` line", async () => {
|
|
64
|
+
// BSD ps prints lstart with strftime("%c"), which space-pads single-digit days in the C locale.
|
|
65
|
+
const { identity } = darwinIdentity([], ` ${LEADER} Tue Oct 7 09:05:03 2026\n`);
|
|
66
|
+
const captured = await identity.capture({ commandHash, pid: LEADER, runtimeToken });
|
|
67
|
+
assert.equal(captured.startTimeTicks, String(Date.UTC(2026, 9, 7, 9, 5, 3) / 1000));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("darwin capture refuses a non-leader, an unparsable line or a failed ps", async (t) => {
|
|
71
|
+
for (const [name, output] of [
|
|
72
|
+
["another group", `${CHILD} ${STARTED}`],
|
|
73
|
+
["a non-numeric group", `pgid ${STARTED}`],
|
|
74
|
+
["an unknown start format", `${LEADER} 2026-10-21T10:00:00Z`],
|
|
75
|
+
["empty output", ""]
|
|
76
|
+
]) {
|
|
77
|
+
await t.test(name, async () => {
|
|
78
|
+
const { identity } = darwinIdentity([], output);
|
|
79
|
+
await assert.rejects(identity.capture({ commandHash, pid: LEADER, runtimeToken }),
|
|
80
|
+
{ code: "native_process_identity_unavailable" });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("darwin inspection is exact only when every group member carries the identity", async () => {
|
|
86
|
+
const { calls, identity } = darwinIdentity([
|
|
87
|
+
row(LEADER, LEADER),
|
|
88
|
+
row(CHILD, CHILD),
|
|
89
|
+
row(9_000_003, 9_000_003, { env: "" })
|
|
90
|
+
]);
|
|
91
|
+
assert.deepEqual(await identity.inspect(metadata()), { processGroupIds: [LEADER, CHILD], status: "exact" });
|
|
92
|
+
assert.deepEqual(calls, [
|
|
93
|
+
["-ax", "-o", "pid=,pgid=,uid=,stat=,lstart="],
|
|
94
|
+
["-x", "-U", String(uid), "-ww", "-o", "pid=,command="],
|
|
95
|
+
["-E", "-x", "-U", String(uid), "-ww", "-o", "pid=,command="]
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("darwin inspection keeps the absent, mismatch and ambiguous outcomes", async (t) => {
|
|
100
|
+
const cases = [
|
|
101
|
+
["no process in the group", [], {}, "absent"],
|
|
102
|
+
["zombies are ignored", [row(LEADER, LEADER, { stat: "Z" })], {}, "absent"],
|
|
103
|
+
["an unrelated process reuses the group", [row(LEADER, LEADER, { env: "PATH=/usr/bin" })], {}, "mismatch"],
|
|
104
|
+
["the token only in the arguments", [row(LEADER, LEADER, {
|
|
105
|
+
args: `/usr/bin/env -i ${TOKEN_ENV}=${runtimeToken} ${HASH_ENV}=${commandHash} codex`, env: "PATH=/usr/bin"
|
|
106
|
+
})], {}, "mismatch"],
|
|
107
|
+
["a hidden environment", [row(LEADER, LEADER, { env: "" })], {}, "ambiguous"],
|
|
108
|
+
["the token only in the arguments of a hidden environment", [row(LEADER, LEADER, {
|
|
109
|
+
args: `/usr/bin/env ${TOKEN_ENV}=${runtimeToken} ${HASH_ENV}=${commandHash} codex`, env: ""
|
|
110
|
+
})], {}, "ambiguous"],
|
|
111
|
+
["another runtime token", [row(LEADER, LEADER)], { runtimeToken: randomUUID() }, "mismatch"],
|
|
112
|
+
["the leader restarted", [row(LEADER, LEADER, { started: "Wed Oct 21 10:00:01 2026" })], {}, "ambiguous"],
|
|
113
|
+
["another user's process shares the group", [row(LEADER, LEADER), row(CHILD, LEADER, { owner: uid + 1 })], {}, "ambiguous"],
|
|
114
|
+
["another user's environment is not trusted", [row(LEADER, LEADER, { owner: uid + 1 })], {}, "ambiguous"],
|
|
115
|
+
["an unmarked member shares the group", [row(LEADER, LEADER), row(CHILD, LEADER, { env: "PATH=/usr/bin" })], {}, "ambiguous"],
|
|
116
|
+
["ps fails", new Error("ps unavailable"), {}, "ambiguous"],
|
|
117
|
+
["a Linux identity", [row(LEADER, LEADER)], { platform: "linux-proc" }, "ambiguous"]
|
|
118
|
+
];
|
|
119
|
+
for (const [name, table, overrides, status] of cases) {
|
|
120
|
+
await t.test(name, async () => {
|
|
121
|
+
const { identity } = darwinIdentity(table);
|
|
122
|
+
assert.equal((await identity.inspect(metadata(overrides))).status, status);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
async function liveGroup(t, env) {
|
|
128
|
+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
129
|
+
detached: true, stdio: "ignore", env: { ...process.env, ...env }
|
|
130
|
+
});
|
|
131
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
132
|
+
t.after(() => { try { process.kill(-child.pid, "SIGKILL"); } catch {} });
|
|
133
|
+
return child;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const nativePlatform = { linux: "linux-proc", darwin: "darwin" }[process.platform];
|
|
137
|
+
|
|
138
|
+
test("the host platform captures, inspects and stops a real owned process group", { skip: !nativePlatform }, async (t) => {
|
|
139
|
+
const token = randomUUID();
|
|
140
|
+
const child = await liveGroup(t, { [TOKEN_ENV]: token, [HASH_ENV]: commandHash });
|
|
141
|
+
const identity = createConversationProcessIdentity();
|
|
142
|
+
const captured = await identity.capture({ commandHash, pid: child.pid, runtimeToken: token });
|
|
143
|
+
assert.equal(captured.platform, nativePlatform);
|
|
144
|
+
const owned = { pid: child.pid, processState: "running", processIdentity: captured };
|
|
145
|
+
assert.deepEqual(await identity.inspect(owned), { processGroupIds: [child.pid], status: "exact" });
|
|
146
|
+
assert.equal((await identity.inspect({ ...owned, processIdentity: { ...captured, runtimeToken: randomUUID() } })).status, "mismatch");
|
|
147
|
+
const stopped = await identity.stop(owned, { termTimeoutMs: 2000 });
|
|
148
|
+
assert.equal(stopped.processExitVerified, true);
|
|
149
|
+
assert.deepEqual(stopped.signaledProcessGroups, [child.pid]);
|
|
150
|
+
});
|
|
@@ -146,6 +146,23 @@ test("a failed message retains its retry identity while later guidance is pendin
|
|
|
146
146
|
assert.equal(delivery.state.sending, false);
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test("a failed client-only message keeps its time while newer saved turns and pending messages follow it", async () => {
|
|
150
|
+
const delivery = createAssistantMessageDelivery({ deliver: async () => ({ ok: false, error: "Unavailable" }) });
|
|
151
|
+
await delivery.send({ message: "Lost" }, { messageId: "lost" });
|
|
152
|
+
const sentAt = delivery.find("lost").createdAtMs;
|
|
153
|
+
const earlier = { turnId: "1", user: { messageId: "earlier", text: "Earlier", at: new Date(sentAt - 1000).toISOString() } };
|
|
154
|
+
const newer = { turnId: "2", user: { messageId: "newer", text: "Newer", at: new Date(sentAt + 1000).toISOString() }, pending: true };
|
|
155
|
+
assert.deepEqual(delivery.turns([earlier, newer]).map(turn => turn.turnId), ["1", "lost", "2"]);
|
|
156
|
+
assert.deepEqual(delivery.turns([earlier]).map(turn => turn.turnId), ["1", "lost"]);
|
|
157
|
+
const waiting = Promise.withResolvers();
|
|
158
|
+
const queued = createAssistantMessageDelivery({ deliver: () => waiting.promise });
|
|
159
|
+
const sending = queued.send({ message: "Queued" }, { messageId: "queued" });
|
|
160
|
+
assert.deepEqual(queued.turns([{ ...newer, user: { ...newer.user, at: new Date(Date.now() + 60000).toISOString() } }])
|
|
161
|
+
.map(turn => turn.turnId), ["2", "queued"]);
|
|
162
|
+
waiting.resolve({ ok: true });
|
|
163
|
+
await sending;
|
|
164
|
+
});
|
|
165
|
+
|
|
149
166
|
test("reset discards queued delivery without waiting for the old transport", async () => {
|
|
150
167
|
const request = Promise.withResolvers();
|
|
151
168
|
const sent = [];
|
|
@@ -242,6 +242,16 @@ test("non-project common OpenCode tools stay behind approval and require the exe
|
|
|
242
242
|
const guarded = openCodeConversationAgents({ sessionEnvironmentRegistry: "/private/sessions.json" });
|
|
243
243
|
assert.equal(guarded["jskit-assistant"].permission["*"], "ask");
|
|
244
244
|
assert.equal(guarded["jskit-assistant-actions"].permission["*"], "ask");
|
|
245
|
+
// OpenCode applies the agent's last matching rule: an agent without application
|
|
246
|
+
// tools must not be offered them, and no agent waits on a repeated-call approval.
|
|
247
|
+
for (const name of ["assistant_action_search", "assistant_action_contract", "assistant_action_execute"]) {
|
|
248
|
+
assert.equal(guarded["jskit-assistant"].permission[name], "deny");
|
|
249
|
+
assert.equal(guarded["jskit-assistant-actions"].permission[name], "allow");
|
|
250
|
+
assert.equal(Object.keys(guarded["jskit-assistant"].permission).indexOf(name) > 0, true);
|
|
251
|
+
}
|
|
252
|
+
assert.equal(guarded["jskit-assistant"].permission.doom_loop, "deny");
|
|
253
|
+
assert.equal(guarded["jskit-assistant-actions"].permission.doom_loop, "deny");
|
|
254
|
+
assert.equal(guarded["jskit-assistant-coding"].permission["*"], "deny");
|
|
245
255
|
});
|
|
246
256
|
|
|
247
257
|
test("common non-project OpenCode calls retain the original fail-closed execution guard", async t => {
|
|
@@ -194,6 +194,18 @@ async function fixture(t, options = {}) {
|
|
|
194
194
|
session.messages.push(row("assistant", randomUUID(), [], { error: { data: { message: error.message } } })); save(); return;
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
|
+
if (text === "refused-tools") {
|
|
198
|
+
// A model that keeps retrying an ungranted native tool and never answers.
|
|
199
|
+
while (busy.has(session.id)) {
|
|
200
|
+
const part = { type: "tool", callID: randomUUID(), tool: "assistant_action_search", state: { status: "running", input: {} } };
|
|
201
|
+
session.messages.push(row("assistant", randomUUID(), [part], { parentID: body.messageID, finish: "tool-calls" }));
|
|
202
|
+
try { await hooks["tool.execute.before"]({ tool: part.tool, sessionID: session.id }, { args: {} }); }
|
|
203
|
+
catch (error) { part.state = { ...part.state, status: "error", error: error.message }; }
|
|
204
|
+
save();
|
|
205
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
197
209
|
if (text === "compact") {
|
|
198
210
|
session.messages.push(row("user", randomUUID(), [{ type: "compaction", auto: true }]));
|
|
199
211
|
session.messages.push(row("assistant", randomUUID(), [{ type: "text", text: "Native summary" }], { summary: true, finish: "stop" }));
|
|
@@ -327,7 +339,8 @@ test("OpenCode admits once, streams and saves native reasoning through the commo
|
|
|
327
339
|
assert.equal(trace.find(item => item.url === "/api/session").body.model.id, "test-model");
|
|
328
340
|
assert.equal(trace.find(item => item.url?.endsWith("/prompt_async")).body.model.modelID, "test-model");
|
|
329
341
|
assert.equal(trace[0].inheritedSecret, null);
|
|
330
|
-
assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask"
|
|
342
|
+
assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask",
|
|
343
|
+
assistant_action_search: "deny", assistant_action_contract: "deny", assistant_action_execute: "deny", doom_loop: "deny" });
|
|
331
344
|
assert.match(trace[0].configuration.plugin[0], /openCodePlugin\.js$/u);
|
|
332
345
|
assert.deepEqual(trace[0].configuration.provider.test.models["test-model"].limit, { context: 32000, output: 4000 });
|
|
333
346
|
assert.equal(trace[0].configuration.provider.test.models["test-model"].id, "test-model");
|
|
@@ -423,6 +436,17 @@ test("the production recovery stops after two reasoning-only completions through
|
|
|
423
436
|
assert.equal(state.conversationLog[0].assistant, null);
|
|
424
437
|
});
|
|
425
438
|
|
|
439
|
+
test("a model that keeps calling refused native tools fails visibly and stops its native turn", async t => {
|
|
440
|
+
const f = await fixture(t);
|
|
441
|
+
await f.conversation.send({ messageId: "client-message-refused-tools", text: "refused-tools" });
|
|
442
|
+
const state = await f.conversation.wait();
|
|
443
|
+
assert.equal(state.conversationLog.length, 1);
|
|
444
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "failed");
|
|
445
|
+
assert.equal(state.error, "The model kept calling tools that this conversation does not provide and gave no answer. No tool ran. Your message is kept; retry or choose another model.");
|
|
446
|
+
assert.equal(state.conversationLog[0].assistant, null);
|
|
447
|
+
assert.ok((await f.trace()).some(row => row.url?.endsWith("/abort")), "The refused native loop is stopped");
|
|
448
|
+
});
|
|
449
|
+
|
|
426
450
|
test("OpenCode steering follows the newly admitted input in the same native conversation", async t => {
|
|
427
451
|
const f = await fixture(t);
|
|
428
452
|
const started = Promise.withResolvers();
|
|
@@ -520,7 +544,7 @@ test("OpenCode shares discovery, saved results and current permissions across na
|
|
|
520
544
|
assert.equal(executions, 1);
|
|
521
545
|
const trace = await f.trace();
|
|
522
546
|
assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask", assistant_action_search: "allow",
|
|
523
|
-
assistant_action_contract: "allow", assistant_action_execute: "allow" });
|
|
547
|
+
assistant_action_contract: "allow", assistant_action_execute: "allow", doom_loop: "deny" });
|
|
524
548
|
const original = await f.binding();
|
|
525
549
|
await f.first.close();
|
|
526
550
|
const resumed = await f.runtime().open({ id: "conversation", context });
|
|
@@ -1625,7 +1649,8 @@ test("completed OpenCode caller markers preserve ordinary native discovery witho
|
|
|
1625
1649
|
assert.equal(ordinaryPrompt.body.agent, "jskit-assistant-actions");
|
|
1626
1650
|
assert.match(ordinaryPrompt.body.parts[0].text, /\[Application data\]/);
|
|
1627
1651
|
assert.deepEqual(initialTrace[0].configuration.agent[ordinaryPrompt.body.agent].permission, {
|
|
1628
|
-
"*": "ask", assistant_action_search: "allow", assistant_action_contract: "allow", assistant_action_execute: "allow"
|
|
1652
|
+
"*": "ask", assistant_action_search: "allow", assistant_action_contract: "allow", assistant_action_execute: "allow",
|
|
1653
|
+
doom_loop: "deny"
|
|
1629
1654
|
});
|
|
1630
1655
|
await assert.rejects(f.prepare({ messageId: "ordinary-opencode", turnId: receipt.turnId }), /no current verified receipt/);
|
|
1631
1656
|
// The original native fixture invokes discovery only for exact plain tools.
|