@zvada/agent-server 0.3.1 → 0.3.3
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/AGENTS.md +34 -0
- package/CHANGELOG.md +11 -0
- package/README.md +14 -3
- package/docs/consuming.md +13 -0
- package/docs/deploy.md +21 -2
- package/docs/harnesses.md +43 -0
- package/package.json +2 -2
- package/src/client/transports.ts +6 -1
- package/src/core/agents/acp/acp-agent.ts +57 -24
- package/src/core/agents/acp/adapter.ts +4 -0
- package/src/core/agents/claude-code/claude-agent.ts +24 -4
- package/src/core/agents/claude-code/generator-session.ts +1 -1
- package/src/core/agents/claude-code/session-manager.ts +15 -4
- package/src/core/agents/codex-app-server/adapter.ts +8 -5
- package/src/core/agents/codex-app-server/client.ts +33 -5
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +256 -74
- package/src/core/agents/codex-sdk/adapter.ts +8 -8
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +71 -29
- package/src/core/agents/session-store.ts +35 -8
- package/src/core/index.ts +3 -0
- package/src/core/presets.ts +7 -2
- package/src/core/provision/provisioner.ts +140 -55
- package/src/core/runtime/event-processor.ts +6 -1
- package/src/protocol/reduce.ts +5 -1
- package/src/protocol/verify.ts +36 -0
- package/src/protocol/wire.ts +11 -5
- package/src/server/acp/translate.ts +32 -22
- package/src/server/agent-server.ts +23 -5
- package/src/server/bin.ts +40 -5
- package/src/server/feedback.ts +76 -0
|
@@ -12,12 +12,38 @@ export interface StoredSession {
|
|
|
12
12
|
*/
|
|
13
13
|
export class SessionStore<S extends StoredSession> {
|
|
14
14
|
private readonly sessions = new Map<string, S>();
|
|
15
|
+
private readonly pending = new Map<string, Set<S["client"]>>();
|
|
16
|
+
private closed = false;
|
|
17
|
+
|
|
18
|
+
/** Own a client before its initialization RPCs have completed. */
|
|
19
|
+
trackPending(sessionId: string, client: S["client"]): () => void {
|
|
20
|
+
if (this.closed) {
|
|
21
|
+
client.close();
|
|
22
|
+
throw new Error("session store is closed");
|
|
23
|
+
}
|
|
24
|
+
let clients = this.pending.get(sessionId);
|
|
25
|
+
if (!clients) {
|
|
26
|
+
clients = new Set();
|
|
27
|
+
this.pending.set(sessionId, clients);
|
|
28
|
+
}
|
|
29
|
+
clients.add(client);
|
|
30
|
+
return () => {
|
|
31
|
+
clients.delete(client);
|
|
32
|
+
if (!clients.size && this.pending.get(sessionId) === clients) {
|
|
33
|
+
this.pending.delete(sessionId);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
15
37
|
|
|
16
38
|
get(sessionId: string): S | undefined {
|
|
17
39
|
return this.sessions.get(sessionId);
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
set(sessionId: string, session: S): void {
|
|
43
|
+
if (this.closed || session.client.closed) {
|
|
44
|
+
session.client.close();
|
|
45
|
+
throw new Error("cannot register a closed session");
|
|
46
|
+
}
|
|
21
47
|
this.sessions.set(sessionId, session);
|
|
22
48
|
}
|
|
23
49
|
|
|
@@ -36,20 +62,21 @@ export class SessionStore<S extends StoredSession> {
|
|
|
36
62
|
|
|
37
63
|
/** Tear down one session: cancel its idle timer, unmap it, close its client. */
|
|
38
64
|
close(sessionId: string): void {
|
|
65
|
+
const pending = this.pending.get(sessionId);
|
|
66
|
+
this.pending.delete(sessionId);
|
|
39
67
|
const session = this.sessions.get(sessionId);
|
|
40
|
-
if (!session) return;
|
|
41
|
-
this.clearIdle(session);
|
|
42
68
|
this.sessions.delete(sessionId);
|
|
43
|
-
session.
|
|
69
|
+
if (session) this.clearIdle(session);
|
|
70
|
+
for (const client of pending ?? []) client.close();
|
|
71
|
+
session?.client.close();
|
|
44
72
|
}
|
|
45
73
|
|
|
46
|
-
/** Tear down
|
|
74
|
+
/** Tear down ready and initializing clients; refuse subsequent registrations. */
|
|
47
75
|
closeAll(): void {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
76
|
+
this.closed = true;
|
|
77
|
+
for (const id of new Set([...this.sessions.keys(), ...this.pending.keys()])) {
|
|
78
|
+
this.close(id);
|
|
51
79
|
}
|
|
52
|
-
this.sessions.clear();
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
/** Shut the session down after `idleMs` unless another turn claims it first. */
|
package/src/core/index.ts
CHANGED
|
@@ -75,6 +75,9 @@ export { CodexSdkTransformer, createCodexSdkTransformer } from "./agents/codex-s
|
|
|
75
75
|
export {
|
|
76
76
|
CodexAppServerAgent,
|
|
77
77
|
type CodexAppServerAgentOptions,
|
|
78
|
+
type CodexAppServerAuth,
|
|
79
|
+
type CodexChatGptAuth,
|
|
80
|
+
type CodexChatGptAuthTokens,
|
|
78
81
|
} from "./agents/codex-app-server/codex-app-server-agent.ts";
|
|
79
82
|
export {
|
|
80
83
|
CodexAppServerTransformer,
|
package/src/core/presets.ts
CHANGED
|
@@ -4,7 +4,10 @@ import { createAcpTransformer } from "./agents/acp/adapter.ts";
|
|
|
4
4
|
import { createClaudeCodeTransformer } from "./agents/claude-code/adapter.ts";
|
|
5
5
|
import { ClaudeCodeAgent, type ClaudeCodeAgentOptions } from "./agents/claude-code/claude-agent.ts";
|
|
6
6
|
import { createCodexAppServerTransformer } from "./agents/codex-app-server/adapter.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CodexAppServerAgent,
|
|
9
|
+
type CodexAppServerAgentOptions,
|
|
10
|
+
} from "./agents/codex-app-server/codex-app-server-agent.ts";
|
|
8
11
|
import { createCodexSdkTransformer } from "./agents/codex-sdk/adapter.ts";
|
|
9
12
|
import { CodexSdkAgent } from "./agents/codex-sdk/codex-sdk-agent.ts";
|
|
10
13
|
import { AgentRegistry } from "./agents/registry.ts";
|
|
@@ -33,6 +36,8 @@ export interface CreateRegistryOptions {
|
|
|
33
36
|
* `resolveCliPath` stays provisioner-wired here.
|
|
34
37
|
*/
|
|
35
38
|
claudeCode?: Omit<ClaudeCodeAgentOptions, "resolveCliPath">;
|
|
39
|
+
/** Codex embed-tier authentication and process options; CLI resolution stays provisioner-owned. */
|
|
40
|
+
codexAppServer?: Omit<CodexAppServerAgentOptions, "resolveCliPath">;
|
|
36
41
|
/**
|
|
37
42
|
* Operational diagnostics port (see `EngineDiagnostic`): interrupt
|
|
38
43
|
* timeouts, resume fallbacks, sink failures — signals the engine must not
|
|
@@ -72,7 +77,7 @@ export function createAgentRegistry(opts: CreateRegistryOptions = {}): AgentRegi
|
|
|
72
77
|
}
|
|
73
78
|
if (enabled.has("codex-app-server")) {
|
|
74
79
|
registry.register(
|
|
75
|
-
new CodexAppServerAgent({ resolveCliPath: codexCli }),
|
|
80
|
+
new CodexAppServerAgent({ ...opts.codexAppServer, resolveCliPath: codexCli }),
|
|
76
81
|
createCodexAppServerTransformer,
|
|
77
82
|
);
|
|
78
83
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
type Stats,
|
|
3
4
|
chmodSync,
|
|
4
5
|
existsSync,
|
|
5
6
|
lstatSync,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
} from "node:fs";
|
|
11
12
|
import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
14
15
|
import { CliNotFoundError, CliProvisionError } from "../utils/errors.ts";
|
|
15
16
|
import { extractTarGz } from "./extract.ts";
|
|
16
17
|
import { DEFAULT_NPM_REGISTRY, downloadWithIntegrity, fetchDist } from "./npm.ts";
|
|
@@ -147,7 +148,7 @@ function moduleDir(): string {
|
|
|
147
148
|
*/
|
|
148
149
|
export class CliProvisioner {
|
|
149
150
|
private readonly mode: ProvisionMode;
|
|
150
|
-
private
|
|
151
|
+
private cacheDir: string;
|
|
151
152
|
private readonly registry: string;
|
|
152
153
|
/** Explicit pins only — see `versionFor` for the per-tool default chain. */
|
|
153
154
|
private readonly pins: Partial<Record<CliTool, string>>;
|
|
@@ -159,15 +160,15 @@ export class CliProvisioner {
|
|
|
159
160
|
private readonly extract: (tgzPath: string, destDir: string) => Promise<void>;
|
|
160
161
|
private readonly hostResolve: (tool: CliTool) => ResolvedCli | undefined;
|
|
161
162
|
private readonly memo = new Map<CliTool, Promise<ResolvedCli | undefined>>();
|
|
162
|
-
private cacheRootTrusted = false;
|
|
163
163
|
|
|
164
164
|
constructor(options: ProvisionOptions = {}, deps: ProvisionerDeps = {}) {
|
|
165
165
|
this.env = deps.env ?? process.env;
|
|
166
166
|
this.mode = options.mode ?? this.modeFromEnv();
|
|
167
|
-
this.cacheDir =
|
|
167
|
+
this.cacheDir = resolve(
|
|
168
168
|
options.cacheDir ??
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
this.env.AGENT_SERVER_CACHE_DIR ??
|
|
170
|
+
join(this.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "agent-server", "cli"),
|
|
171
|
+
);
|
|
171
172
|
this.registry = options.registry ?? this.env.AGENT_SERVER_NPM_REGISTRY ?? DEFAULT_NPM_REGISTRY;
|
|
172
173
|
this.pins = options.pins ?? {};
|
|
173
174
|
this.overrides = options.overrides ?? {};
|
|
@@ -184,17 +185,30 @@ export class CliProvisioner {
|
|
|
184
185
|
* finds its sibling platform package and has the clearest failure message.
|
|
185
186
|
*/
|
|
186
187
|
resolve(tool: CliTool): Promise<ResolvedCli | undefined> {
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
pending
|
|
197
|
-
|
|
188
|
+
let pending = this.memo.get(tool);
|
|
189
|
+
if (!pending) {
|
|
190
|
+
pending = this.doResolve(tool).then((resolved) => {
|
|
191
|
+
if (resolved)
|
|
192
|
+
this.log(`${tool} CLI: ${resolved.path} (${resolved.source}${withVersion(resolved)})`);
|
|
193
|
+
return resolved;
|
|
194
|
+
});
|
|
195
|
+
this.memo.set(tool, pending);
|
|
196
|
+
}
|
|
197
|
+
return pending
|
|
198
|
+
.then((resolved) => {
|
|
199
|
+
if (resolved?.source === "cache" || resolved?.source === "downloaded") {
|
|
200
|
+
const root = this.ensureTrustedCacheRoot();
|
|
201
|
+
if (!managedDirectory(root, dirname(resolved.path), false)) {
|
|
202
|
+
throw new CliProvisionError(`Cached CLI disappeared: ${resolved.path}`);
|
|
203
|
+
}
|
|
204
|
+
trustedBinary(resolved.path, lstatSync(resolved.path));
|
|
205
|
+
}
|
|
206
|
+
return resolved;
|
|
207
|
+
})
|
|
208
|
+
.catch((error) => {
|
|
209
|
+
if (this.memo.get(tool) === pending) this.memo.delete(tool);
|
|
210
|
+
throw error;
|
|
211
|
+
});
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
/** Warm the managed cache with the pinned build (the `install` command). */
|
|
@@ -355,48 +369,30 @@ export class CliProvisioner {
|
|
|
355
369
|
* (lstat): a symlink here could redirect the spawn — and the post-install
|
|
356
370
|
* `chmod` — at a path outside the tree we verified.
|
|
357
371
|
*/
|
|
358
|
-
private binaryIn(tool: CliTool, dir: string): string | undefined {
|
|
372
|
+
private binaryIn(tool: CliTool, dir: string, managedRoot?: string): string | undefined {
|
|
359
373
|
const candidates =
|
|
360
374
|
tool === "claude"
|
|
361
375
|
? ["claude", "claude.exe"]
|
|
362
376
|
: [join("bin", "codex"), join("bin", "codex.exe")];
|
|
363
377
|
for (const candidate of candidates) {
|
|
364
378
|
const path = join(dir, candidate);
|
|
379
|
+
if (managedRoot && !managedDirectory(managedRoot, dirname(path), false)) continue;
|
|
380
|
+
let stat: Stats;
|
|
365
381
|
try {
|
|
366
|
-
|
|
382
|
+
stat = lstatSync(path);
|
|
367
383
|
} catch {
|
|
368
|
-
|
|
384
|
+
continue;
|
|
369
385
|
}
|
|
386
|
+
if (managedRoot) trustedBinary(path, stat);
|
|
387
|
+
if (stat.isFile()) return path;
|
|
370
388
|
}
|
|
371
389
|
return undefined;
|
|
372
390
|
}
|
|
373
391
|
|
|
374
|
-
/**
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
* binary we spawn. Create `0o700`, require our own uid, and tighten a
|
|
379
|
-
* looser mode (earlier runs created it with the umask). POSIX-only — on
|
|
380
|
-
* Windows the default cache locations are already per-user.
|
|
381
|
-
*/
|
|
382
|
-
private ensureTrustedCacheRoot(): void {
|
|
383
|
-
if (this.cacheRootTrusted) return;
|
|
384
|
-
mkdirSync(this.cacheDir, { recursive: true, mode: 0o700 });
|
|
385
|
-
if (typeof process.getuid === "function") {
|
|
386
|
-
const stat = lstatSync(this.cacheDir);
|
|
387
|
-
if (!stat.isDirectory()) {
|
|
388
|
-
throw new CliProvisionError(
|
|
389
|
-
`Cache dir ${this.cacheDir} is not a directory (planted symlink?) — refusing to use it`,
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
if (stat.uid !== process.getuid()) {
|
|
393
|
-
throw new CliProvisionError(
|
|
394
|
-
`Cache dir ${this.cacheDir} is owned by uid ${stat.uid}, expected ${process.getuid()} — refusing to use it`,
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
if ((stat.mode & 0o777) !== 0o700) chmodSync(this.cacheDir, 0o700);
|
|
398
|
-
}
|
|
399
|
-
this.cacheRootTrusted = true;
|
|
392
|
+
/** Pin trusted aliases to their physical location and recheck on every use. */
|
|
393
|
+
private ensureTrustedCacheRoot(): string {
|
|
394
|
+
this.cacheDir = trustedCacheDirectory(this.cacheDir, true);
|
|
395
|
+
return this.cacheDir;
|
|
400
396
|
}
|
|
401
397
|
|
|
402
398
|
/** The directory inside an extracted tarball that becomes the install. */
|
|
@@ -418,38 +414,40 @@ export class CliProvisioner {
|
|
|
418
414
|
}
|
|
419
415
|
|
|
420
416
|
private async ensureInstalled(tool: CliTool): Promise<ResolvedCli> {
|
|
421
|
-
this.ensureTrustedCacheRoot();
|
|
417
|
+
const root = this.ensureTrustedCacheRoot();
|
|
422
418
|
const pkg = this.packageFor(tool);
|
|
423
|
-
const dir = join(
|
|
424
|
-
const cached = this.binaryIn(tool, dir);
|
|
419
|
+
const dir = join(root, tool, `${pkg.version}-${pkg.suffix}`);
|
|
420
|
+
const cached = this.binaryIn(tool, dir, root);
|
|
425
421
|
if (cached) return { path: cached, source: "cache", version: pkg.version };
|
|
426
422
|
|
|
427
423
|
this.log(`downloading ${tool} ${pkg.version} (${pkg.suffix}) from ${this.registry}…`);
|
|
428
424
|
const startedAt = Date.now();
|
|
429
425
|
const staging = join(
|
|
430
|
-
|
|
426
|
+
root,
|
|
431
427
|
".tmp",
|
|
432
428
|
`${tool}-${pkg.version}-${process.pid}-${randomBytes(4).toString("hex")}`,
|
|
433
429
|
);
|
|
434
|
-
|
|
430
|
+
managedDirectory(root, dirname(staging), true);
|
|
431
|
+
await mkdir(staging, { mode: 0o700 });
|
|
435
432
|
try {
|
|
436
433
|
const dist = await fetchDist(this.registry, pkg.name, pkg.registryVersion, this.fetch);
|
|
437
434
|
const tgz = join(staging, "package.tgz");
|
|
438
435
|
const bytes = await downloadWithIntegrity(dist.tarball, tgz, dist.integrity, this.fetch);
|
|
439
436
|
const extracted = join(staging, "x");
|
|
440
|
-
await mkdir(extracted);
|
|
437
|
+
await mkdir(extracted, { mode: 0o700 });
|
|
441
438
|
await this.extract(tgz, extracted);
|
|
442
439
|
const payload = await this.payloadDir(tool, extracted);
|
|
443
440
|
|
|
444
|
-
|
|
441
|
+
this.ensureTrustedCacheRoot();
|
|
442
|
+
managedDirectory(root, dirname(dir), true);
|
|
445
443
|
try {
|
|
446
444
|
await rename(payload, dir);
|
|
447
445
|
} catch (error) {
|
|
448
446
|
// Lost an install race with another process. The directory is
|
|
449
447
|
// version-keyed, so the winner's tree is what we would have written.
|
|
450
|
-
if (!this.binaryIn(tool, dir)) throw error;
|
|
448
|
+
if (!this.binaryIn(tool, dir, root)) throw error;
|
|
451
449
|
}
|
|
452
|
-
const path = this.binaryIn(tool, dir);
|
|
450
|
+
const path = this.binaryIn(tool, dir, root);
|
|
453
451
|
if (!path) {
|
|
454
452
|
throw new CliProvisionError(`downloaded ${pkg.name}@${pkg.registryVersion} has no binary`);
|
|
455
453
|
}
|
|
@@ -476,3 +474,90 @@ export class CliProvisioner {
|
|
|
476
474
|
function withVersion(resolved: ResolvedCli): string {
|
|
477
475
|
return resolved.version ? ` ${resolved.version}` : "";
|
|
478
476
|
}
|
|
477
|
+
|
|
478
|
+
function refuseCache(path: string, reason: string): never {
|
|
479
|
+
throw new CliProvisionError(`Untrusted CLI cache path ${path}: ${reason} — refusing to use it`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function trustedOwner(path: string, stat: Stats, ownOnly = false): void {
|
|
483
|
+
if (typeof process.getuid !== "function") return;
|
|
484
|
+
const uid = process.getuid();
|
|
485
|
+
if (stat.uid !== uid && (ownOnly || stat.uid !== 0))
|
|
486
|
+
refuseCache(path, `owned by uid ${stat.uid}, expected ${uid}${ownOnly ? "" : " or root"}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function privateDirectory(path: string, stat: Stats, allowSticky = false): void {
|
|
490
|
+
if (!stat.isDirectory()) refuseCache(path, "not a directory (planted symlink?)");
|
|
491
|
+
trustedOwner(path, stat);
|
|
492
|
+
if (
|
|
493
|
+
typeof process.getuid === "function" &&
|
|
494
|
+
stat.mode & 0o022 &&
|
|
495
|
+
!(allowSticky && stat.mode & 0o1000)
|
|
496
|
+
) {
|
|
497
|
+
refuseCache(path, "writable by other users");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function createDirectory(path: string): void {
|
|
502
|
+
try {
|
|
503
|
+
mkdirSync(path, { mode: 0o700 });
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Check each parent before creating its child; trusted sticky parents permit /tmp. */
|
|
510
|
+
function trustedCacheDirectory(path: string, ownLeaf: boolean): string {
|
|
511
|
+
const root = parse(path).root;
|
|
512
|
+
let current = root;
|
|
513
|
+
privateDirectory(root, lstatSync(root), true);
|
|
514
|
+
const components = relative(root, path).split(sep).filter(Boolean);
|
|
515
|
+
if (ownLeaf && !components.length) refuseCache(path, "filesystem root cannot be a CLI cache");
|
|
516
|
+
for (const [index, component] of components.entries()) {
|
|
517
|
+
const candidate = join(current, component);
|
|
518
|
+
createDirectory(candidate);
|
|
519
|
+
const stat = lstatSync(candidate);
|
|
520
|
+
const leaf = ownLeaf && index === components.length - 1;
|
|
521
|
+
if (stat.isSymbolicLink() && !leaf) {
|
|
522
|
+
trustedOwner(candidate, stat);
|
|
523
|
+
current = trustedCacheDirectory(realpathSync(candidate), false);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (leaf) {
|
|
527
|
+
if (!stat.isDirectory()) refuseCache(candidate, "not a directory (planted symlink?)");
|
|
528
|
+
trustedOwner(candidate, stat, true);
|
|
529
|
+
if (typeof process.getuid === "function" && (stat.mode & 0o777) !== 0o700)
|
|
530
|
+
chmodSync(candidate, 0o700);
|
|
531
|
+
} else privateDirectory(candidate, stat, true);
|
|
532
|
+
current = candidate;
|
|
533
|
+
}
|
|
534
|
+
return current;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Managed descendants cannot be aliases or outsider-writable, even under a repaired root. */
|
|
538
|
+
function managedDirectory(root: string, path: string, create: boolean): boolean {
|
|
539
|
+
const rel = relative(root, path);
|
|
540
|
+
if (isAbsolute(rel) || rel.split(sep).includes(".."))
|
|
541
|
+
refuseCache(path, "outside the managed root");
|
|
542
|
+
let current = root;
|
|
543
|
+
for (const component of rel.split(sep).filter(Boolean)) {
|
|
544
|
+
current = join(current, component);
|
|
545
|
+
if (create) createDirectory(current);
|
|
546
|
+
let stat: Stats;
|
|
547
|
+
try {
|
|
548
|
+
stat = lstatSync(current);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
privateDirectory(current, stat);
|
|
554
|
+
}
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function trustedBinary(path: string, stat: Stats): void {
|
|
559
|
+
if (!stat.isFile()) refuseCache(path, "not a regular executable file");
|
|
560
|
+
trustedOwner(path, stat);
|
|
561
|
+
if (typeof process.getuid === "function" && stat.mode & 0o022)
|
|
562
|
+
refuseCache(path, "executable writable by other users");
|
|
563
|
+
}
|
|
@@ -155,7 +155,12 @@ export class EventProcessor {
|
|
|
155
155
|
// A parented message is a sub-agent's output — nests under its tool call,
|
|
156
156
|
// not a top-level model message (see DESIGN.md D5).
|
|
157
157
|
...(parentToolCallId && { parentToolCallId }),
|
|
158
|
-
|
|
158
|
+
// `model` is assistant/model-message metadata (the turn's configured
|
|
159
|
+
// model from `RunConfig.model`) — never on the user echo, which is
|
|
160
|
+
// user-authored. Gating here keeps it off every user message; every
|
|
161
|
+
// assistant message (top-level AND parented sub-agent output) keeps it,
|
|
162
|
+
// since `config.model` is the only per-turn model signal available.
|
|
163
|
+
...(role === "assistant" && this.meta.model && { model: this.meta.model }),
|
|
159
164
|
timestamp: Date.now(),
|
|
160
165
|
};
|
|
161
166
|
}
|
package/src/protocol/reduce.ts
CHANGED
|
@@ -331,7 +331,11 @@ function openMessage(
|
|
|
331
331
|
outputIndex: init.outputIndex,
|
|
332
332
|
role: init.role,
|
|
333
333
|
...(init.parentToolCallId !== undefined && { parentToolCallId: init.parentToolCallId }),
|
|
334
|
-
|
|
334
|
+
// `model` is assistant/model-message metadata; the user echo is
|
|
335
|
+
// user-authored, so never persist it on a user-role message. Defense in
|
|
336
|
+
// depth alongside the producer gate: a hand-built or replayed
|
|
337
|
+
// `message.started{role:"user", model}` does not leak into the fold.
|
|
338
|
+
...(init.role === "assistant" && init.model !== undefined && { model: init.model }),
|
|
335
339
|
parts: [],
|
|
336
340
|
startedAt: init.timestamp,
|
|
337
341
|
};
|
package/src/protocol/verify.ts
CHANGED
|
@@ -59,10 +59,21 @@ export function verifyStreamContract(
|
|
|
59
59
|
// Via the Law-6 decoder, not the closed union: an unknown event type (or an
|
|
60
60
|
// unknown part inside a known event) is forward-compat, not a violation —
|
|
61
61
|
// only a KNOWN type with a malformed body is.
|
|
62
|
+
//
|
|
63
|
+
// Indices that fail to decode are recorded and skipped by the later passes
|
|
64
|
+
// that re-touch raw event bodies (the bookkeeping loop and the projection-
|
|
65
|
+
// prep loop below): a malformed event's nested fields (message.part's
|
|
66
|
+
// `event.part.id`, `event.part.messageId`, `"raw" in part`) cannot be safely
|
|
67
|
+
// dereferenced a second time, so without the skip the bookkeeping forEach
|
|
68
|
+
// would throw a TypeError and discard the `violations` array — including the
|
|
69
|
+
// schema-valid report just pushed for it. The schema-valid report is
|
|
70
|
+
// authoritative for the index; the raw-body passes leave the index alone.
|
|
71
|
+
const schemaBad = new Set<number>();
|
|
62
72
|
events.forEach((event, i) => {
|
|
63
73
|
try {
|
|
64
74
|
decodeLifecycleEvent(event);
|
|
65
75
|
} catch (err) {
|
|
76
|
+
schemaBad.add(i);
|
|
66
77
|
report(
|
|
67
78
|
"schema-valid",
|
|
68
79
|
i,
|
|
@@ -113,6 +124,13 @@ export function verifyStreamContract(
|
|
|
113
124
|
// Unknown types carry no contract obligations we can check — they are
|
|
114
125
|
// preserved, not interpreted.
|
|
115
126
|
if (isUnknownEvent(event)) return;
|
|
127
|
+
// The validity loop already reported this index as schema-invalid. The
|
|
128
|
+
// bookkeeping below dereferences a malformed event's nested body —
|
|
129
|
+
// `message.part` reads `event.part.id`/`event.part.messageId` and runs
|
|
130
|
+
// `"raw" in part` — which a malformed `part` (null/undefined/non-object)
|
|
131
|
+
// cannot safely provide, so the index is left to the schema-valid report
|
|
132
|
+
// instead of crashing this forEach out from under the violations array.
|
|
133
|
+
if (schemaBad.has(i)) return;
|
|
116
134
|
switch (event.type) {
|
|
117
135
|
case "session.created":
|
|
118
136
|
sessionCreatedInTurn++;
|
|
@@ -168,6 +186,18 @@ export function verifyStreamContract(
|
|
|
168
186
|
turn.lastOutputIndex = Math.max(turn.lastOutputIndex, event.outputIndex);
|
|
169
187
|
turn.openMessages.set(event.messageId, i);
|
|
170
188
|
messageRole.set(event.messageId, event.role);
|
|
189
|
+
// `model` is assistant/model-message metadata; the user echo (and any
|
|
190
|
+
// user-role message) is user-authored, so a `model` field on it
|
|
191
|
+
// misattributes the configured model to the user's prompt. This catches
|
|
192
|
+
// leaks the producer gate prevents but a hand-built or replayed stream
|
|
193
|
+
// could still carry.
|
|
194
|
+
if (event.model !== undefined && event.role !== "assistant") {
|
|
195
|
+
report(
|
|
196
|
+
"model-assistant-only",
|
|
197
|
+
i,
|
|
198
|
+
`message.started ${event.messageId} carries model ${JSON.stringify(event.model)} on a ${event.role} message; model is assistant-only metadata`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
171
201
|
return;
|
|
172
202
|
}
|
|
173
203
|
case "message.part": {
|
|
@@ -306,6 +336,12 @@ export function verifyStreamContract(
|
|
|
306
336
|
const lastTextDelta = new Map<string, number>();
|
|
307
337
|
events.forEach((event, i) => {
|
|
308
338
|
if (isUnknownEvent(event)) return;
|
|
339
|
+
// Reported above as schema-invalid; this pass dereferences the nested
|
|
340
|
+
// `event.part.id` and `event.delta.type`, which a malformed body cannot
|
|
341
|
+
// safely provide. At HEAD the bookkeeping loop throws first, so this
|
|
342
|
+
// deref is not separately reachable — once that loop is guarded, this
|
|
343
|
+
// one becomes the next reachable crash, so both passes check the set.
|
|
344
|
+
if (schemaBad.has(i)) return;
|
|
309
345
|
if (event.type === "message.part") lastSnapshot.set(event.part.id, { event, index: i });
|
|
310
346
|
else if (event.type === "message.part.delta" && event.delta.type !== "tool_input") {
|
|
311
347
|
lastTextDelta.set(event.partId, i);
|
package/src/protocol/wire.ts
CHANGED
|
@@ -267,7 +267,8 @@ export type WireMessage =
|
|
|
267
267
|
| { kind: "notification"; method: string; params?: unknown }
|
|
268
268
|
/** `id` is null on protocol-level errors answering unparseable requests. */
|
|
269
269
|
| { kind: "response"; id: JsonRpcId | null; result?: unknown; error?: JsonRpcError }
|
|
270
|
-
|
|
270
|
+
/** The decoder sets parseError; optional for existing callers constructing invalid messages. */
|
|
271
|
+
| { kind: "invalid"; error: string; parseError?: boolean };
|
|
271
272
|
|
|
272
273
|
/** Decode one NDJSON line into a JSON-RPC message (never throws). */
|
|
273
274
|
export function decodeWireMessage(line: string): WireMessage {
|
|
@@ -275,13 +276,14 @@ export function decodeWireMessage(line: string): WireMessage {
|
|
|
275
276
|
try {
|
|
276
277
|
raw = JSON.parse(line);
|
|
277
278
|
} catch {
|
|
278
|
-
return { kind: "invalid", error: "not valid JSON" };
|
|
279
|
+
return { kind: "invalid", error: "not valid JSON", parseError: true };
|
|
279
280
|
}
|
|
280
281
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
281
|
-
return { kind: "invalid", error: "not a JSON-RPC object" };
|
|
282
|
+
return { kind: "invalid", error: "not a JSON-RPC object", parseError: false };
|
|
282
283
|
}
|
|
283
284
|
const msg = raw as Record<string, unknown>;
|
|
284
|
-
if (msg.jsonrpc !== "2.0")
|
|
285
|
+
if (msg.jsonrpc !== "2.0")
|
|
286
|
+
return { kind: "invalid", error: "missing jsonrpc: '2.0'", parseError: false };
|
|
285
287
|
const id = JsonRpcIdSchema.safeParse(msg.id);
|
|
286
288
|
if (typeof msg.method === "string") {
|
|
287
289
|
return id.success
|
|
@@ -297,7 +299,11 @@ export function decodeWireMessage(line: string): WireMessage {
|
|
|
297
299
|
error: error.success ? error.data : undefined,
|
|
298
300
|
};
|
|
299
301
|
}
|
|
300
|
-
return {
|
|
302
|
+
return {
|
|
303
|
+
kind: "invalid",
|
|
304
|
+
error: "neither request, notification, nor response",
|
|
305
|
+
parseError: false,
|
|
306
|
+
};
|
|
301
307
|
}
|
|
302
308
|
|
|
303
309
|
export function encodeRequest(id: JsonRpcId, method: string, params?: unknown): string {
|
|
@@ -115,20 +115,31 @@ export class AcpUpdateTranslator {
|
|
|
115
115
|
const kind = part.kind
|
|
116
116
|
? ((acpKinds.has(part.kind) ? part.kind : "other") as "other")
|
|
117
117
|
: undefined;
|
|
118
|
+
const updates: SessionUpdate[] = [];
|
|
118
119
|
if (!this.openedTools.has(part.toolCallId)) {
|
|
119
120
|
this.openedTools.add(part.toolCallId);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
121
|
+
// A terminal-first snapshot — a tool already `completed`/`failed` on
|
|
122
|
+
// its first (and maybe only) `message.part` (e.g. a conforming ACP
|
|
123
|
+
// agent that reports a tool solely via a bare terminal
|
|
124
|
+
// `tool_call_update`, which the `acp` harness preserves as a single
|
|
125
|
+
// terminal part) — must still deliver its result body. Emit the
|
|
126
|
+
// spec-canonical pair: announce with a NON-terminal `in_progress`
|
|
127
|
+
// status, then a `tool_call_update` carrying the real terminal
|
|
128
|
+
// status and the output/error `content` (the only update a result
|
|
129
|
+
// can ride on). `cancelled` has no body to drop and stays on the
|
|
130
|
+
// single-event announce path with its mapped `failed` status.
|
|
131
|
+
const terminalFirst = part.state.status === "completed" || part.state.status === "failed";
|
|
132
|
+
updates.push({
|
|
133
|
+
sessionUpdate: "tool_call",
|
|
134
|
+
toolCallId: part.toolCallId,
|
|
135
|
+
title: part.title ?? part.toolName,
|
|
136
|
+
name: part.toolName,
|
|
137
|
+
...(kind && { kind }),
|
|
138
|
+
status: terminalFirst ? "in_progress" : status,
|
|
139
|
+
...(part.locations?.length && { locations: part.locations }),
|
|
140
|
+
...("input" in part.state && { rawInput: part.state.input }),
|
|
141
|
+
});
|
|
142
|
+
if (!terminalFirst) return updates;
|
|
132
143
|
}
|
|
133
144
|
const output =
|
|
134
145
|
part.state.status === "failed"
|
|
@@ -136,16 +147,15 @@ export class AcpUpdateTranslator {
|
|
|
136
147
|
: part.state.status === "completed"
|
|
137
148
|
? part.state.output
|
|
138
149
|
: undefined;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
];
|
|
150
|
+
updates.push({
|
|
151
|
+
sessionUpdate: "tool_call_update",
|
|
152
|
+
toolCallId: part.toolCallId,
|
|
153
|
+
status,
|
|
154
|
+
...(output && {
|
|
155
|
+
content: [{ type: "content", content: { type: "text", text: output } }],
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
return updates;
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
161
|
|
|
@@ -111,16 +111,31 @@ export class AgentServer {
|
|
|
111
111
|
if (this.shuttingDown) return;
|
|
112
112
|
this.shuttingDown = true;
|
|
113
113
|
// Drain first so cancelled turns still broadcast their turn.ended.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this
|
|
114
|
+
// The drain (and each transport's close) is a library extension point
|
|
115
|
+
// that may throw; the finally still closes every transport and runs the
|
|
116
|
+
// host hook even then — every other external call in this file is
|
|
117
|
+
// defended the same way (broadcast/observers/sink/handleLine).
|
|
118
|
+
try {
|
|
119
|
+
await this.runtime.shutdown();
|
|
120
|
+
} finally {
|
|
121
|
+
for (const transport of [...this.transports]) {
|
|
122
|
+
try {
|
|
123
|
+
transport.close();
|
|
124
|
+
} catch {
|
|
125
|
+
// One faulty transport's close must not skip the others or the
|
|
126
|
+
// host's shutdown hook — same isolation broadcast uses for send.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.options.onShutdown?.();
|
|
130
|
+
}
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
private async handleLine(transport: WireTransport, line: string): Promise<void> {
|
|
120
134
|
if (!line.trim()) return;
|
|
121
135
|
const msg = decodeWireMessage(line);
|
|
122
136
|
if (msg.kind === "invalid") {
|
|
123
|
-
|
|
137
|
+
const code = msg.parseError ? WIRE_ERROR_CODES.parseError : WIRE_ERROR_CODES.invalidRequest;
|
|
138
|
+
transport.send(encodeErrorResponse(null, code, msg.error));
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
126
141
|
// Clients only send requests on this wire; stray notifications/responses
|
|
@@ -375,7 +390,10 @@ export class AgentServer {
|
|
|
375
390
|
|
|
376
391
|
case WIRE_METHODS.shutdown: {
|
|
377
392
|
transport.send(encodeResponse(id, {}));
|
|
378
|
-
|
|
393
|
+
// The success ack is already committed on the wire; a throw out of
|
|
394
|
+
// shutdown() must not reach handleLine's catch and emit a second
|
|
395
|
+
// response for this id (a JSON-RPC 2.0 violation).
|
|
396
|
+
await this.shutdown().catch(() => {});
|
|
379
397
|
return;
|
|
380
398
|
}
|
|
381
399
|
|