jeo-code 0.12.15 → 0.12.16
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/CHANGELOG.md +13 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/sandbox/authority.ts +105 -42
- package/src/agent/sandbox/bundle.ts +5 -2
- package/src/agent/sandbox/docker.ts +13 -3
- package/src/agent/sandbox/hands-source.text +230 -230
- package/src/agent/sandbox/policy.ts +23 -6
- package/src/commands/launch.ts +17 -3
- package/src/commands/messenger.ts +2 -0
- package/src/messenger/config.ts +27 -1
- package/src/messenger/discord-setup.ts +97 -17
- package/src/messenger/gateway.ts +58 -33
- package/src/messenger/types.ts +2 -0
- package/src/util/retry.ts +12 -0
|
@@ -29,6 +29,17 @@ export function sandboxPathContains(parent: string, child: string): boolean {
|
|
|
29
29
|
return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export function windowsSystemRoot(): string {
|
|
33
|
+
const configured = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? process.env.windir;
|
|
34
|
+
if (configured && path.isAbsolute(configured)) return configured;
|
|
35
|
+
for (const candidate of ["powershell.exe", "cmd.exe"]) {
|
|
36
|
+
const resolved = Bun.which(candidate);
|
|
37
|
+
const match = resolved ? /^(.*)[\\/]System32(?:[\\/]|$)/i.exec(resolved) : null;
|
|
38
|
+
if (match?.[1] && path.isAbsolute(match[1])) return match[1];
|
|
39
|
+
}
|
|
40
|
+
throw new Error("SANDBOX_STATE: Windows system directory is unavailable");
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
let nativeHome: Promise<string> | undefined;
|
|
33
44
|
|
|
34
45
|
/** Bun userInfo() can inherit startup HOME; query the native account database instead. */
|
|
@@ -36,9 +47,9 @@ export function nativeSandboxHome(): Promise<string> {
|
|
|
36
47
|
nativeHome ??= (async () => {
|
|
37
48
|
let home: string | undefined;
|
|
38
49
|
let argv: string[] | undefined;
|
|
50
|
+
let systemRoot = "";
|
|
39
51
|
if (process.platform === "win32") {
|
|
40
|
-
|
|
41
|
-
if (!systemRoot || !path.isAbsolute(systemRoot)) throw new Error("SANDBOX_STATE: native Windows system directory unavailable");
|
|
52
|
+
systemRoot = windowsSystemRoot();
|
|
42
53
|
const script = "$ErrorActionPreference='Stop';[Console]::Out.Write([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile))";
|
|
43
54
|
argv = [path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
|
|
44
55
|
} else {
|
|
@@ -52,7 +63,7 @@ export function nativeSandboxHome(): Promise<string> {
|
|
|
52
63
|
}
|
|
53
64
|
}
|
|
54
65
|
if (argv) {
|
|
55
|
-
const child = Bun.spawn(argv, { env: { SystemRoot:
|
|
66
|
+
const child = Bun.spawn(argv, { env: { SystemRoot: systemRoot }, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
56
67
|
const timer = setTimeout(() => child.kill(), 30_000);
|
|
57
68
|
try {
|
|
58
69
|
const [code, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
@@ -73,7 +84,10 @@ export async function canonicalSandboxPath(value: string, mustExist = true, cwd?
|
|
|
73
84
|
if (process.platform === "win32" && /^(?:[\\/]{2}|[\\/]\?\?[\\/])/.test(value)) throw new Error("SANDBOX_PATH: Windows network and device paths are forbidden");
|
|
74
85
|
const absolute = cwd === undefined ? path.resolve(value) : path.resolve(cwd, value);
|
|
75
86
|
if (process.platform === "win32" && /^[\\/]{2}/.test(absolute)) throw new Error("SANDBOX_PATH: Windows network and device paths are forbidden");
|
|
76
|
-
try {
|
|
87
|
+
try {
|
|
88
|
+
const resolved = await fs.realpath(absolute);
|
|
89
|
+
return process.platform === "win32" && /^[A-Za-z]:$/.test(resolved) ? `${resolved}\\` : resolved;
|
|
90
|
+
}
|
|
77
91
|
catch (error) {
|
|
78
92
|
const code = (error as NodeJS.ErrnoException).code;
|
|
79
93
|
// macOS realpath can reject launchd sockets even though their parent is resolvable.
|
|
@@ -81,14 +95,17 @@ export async function canonicalSandboxPath(value: string, mustExist = true, cwd?
|
|
|
81
95
|
if (!socket && (mustExist || code !== "ENOENT")) throw error;
|
|
82
96
|
const parent = path.dirname(absolute);
|
|
83
97
|
if (parent === absolute) throw error;
|
|
84
|
-
|
|
98
|
+
const resolvedParent = await canonicalSandboxPath(parent, false);
|
|
99
|
+
const result = path.join(resolvedParent, path.basename(absolute));
|
|
100
|
+
return process.platform === "win32" && /^[A-Za-z]:$/.test(result) ? `${result}\\` : result;
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
/** Deny endpoint ancestors even when their socket has not been created yet. */
|
|
89
105
|
export async function validateSandboxMountSource(host: string, privatePaths: readonly string[], controlPaths: readonly string[] = []): Promise<void> {
|
|
90
106
|
const home = await canonicalSandboxPath(os.homedir());
|
|
91
|
-
|
|
107
|
+
const isRoot = host === path.parse(host).root || (process.platform === "win32" && /^[A-Za-z]:[\\/]?$/.test(host));
|
|
108
|
+
if (isRoot || host === home) throw new Error("SANDBOX_POLICY: host root and whole-home mounts are forbidden");
|
|
92
109
|
if (privatePaths.some(privatePath => sandboxPathContains(host, privatePath) || sandboxPathContains(privatePath, host))) {
|
|
93
110
|
throw new Error("SANDBOX_POLICY: mount overlaps private control state");
|
|
94
111
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -60,7 +60,7 @@ import { AgyCliError, type AgyTurnResult } from "../agent/antigravity-cli";
|
|
|
60
60
|
import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooks } from "../agent/hooks";
|
|
61
61
|
import { createSandboxSupervisor } from "../agent/sandbox/supervisor";
|
|
62
62
|
import { createSandboxPolicy } from "../agent/sandbox/policy";
|
|
63
|
-
import { createGrantAuthority } from "../agent/sandbox/authority";
|
|
63
|
+
import { createGrantAuthority, ensurePrivateSandboxDirectory } from "../agent/sandbox/authority";
|
|
64
64
|
import { handleSandboxControl } from "../agent/sandbox/control";
|
|
65
65
|
import type { SandboxConfig, SandboxSupervisor } from "../agent/sandbox/types";
|
|
66
66
|
import { runAgySessionTurn } from "./launch/agy-turn";
|
|
@@ -75,6 +75,7 @@ import { RouteHistory } from "../agent/route-history";
|
|
|
75
75
|
|
|
76
76
|
import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
|
|
77
77
|
import { messengerUsageFallback } from "../messenger/model-policy";
|
|
78
|
+
import { messengerProfilePrivatePaths } from "../messenger/config";
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
import { listAliases } from "../ai/model-registry";
|
|
@@ -400,6 +401,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
400
401
|
let supervisor: SandboxSupervisor | undefined;
|
|
401
402
|
let stateDirectory: string | undefined;
|
|
402
403
|
let stopEndpoint: (() => Promise<void>) | undefined;
|
|
404
|
+
const setup = Promise.withResolvers<void>();
|
|
403
405
|
let endpointStartup: Promise<void> | undefined;
|
|
404
406
|
let closing: Promise<void> | undefined;
|
|
405
407
|
const acknowledge = async (gone: boolean): Promise<void> => {
|
|
@@ -411,6 +413,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
411
413
|
const close = (): Promise<void> => closing ??= (async () => {
|
|
412
414
|
for (;;) {
|
|
413
415
|
try {
|
|
416
|
+
await setup.promise;
|
|
414
417
|
await endpointStartup;
|
|
415
418
|
await stopEndpoint?.();
|
|
416
419
|
// No supervisor means startup never had authority to create an environment.
|
|
@@ -445,6 +448,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
445
448
|
const runSession = (): Promise<void> => {
|
|
446
449
|
const ready = Promise.withResolvers<void>();
|
|
447
450
|
endpointStartup = ready.promise;
|
|
451
|
+
setup.resolve();
|
|
448
452
|
return runLaunchSession(args, cleanup => { stopEndpoint = cleanup; ready.resolve(); }).finally(ready.resolve);
|
|
449
453
|
};
|
|
450
454
|
try {
|
|
@@ -460,9 +464,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
460
464
|
for (const legacy of [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")]) {
|
|
461
465
|
if (fs.existsSync(legacy)) throw new Error("SANDBOX_PRIVATE_STATE: legacy transcripts are inside the workspace; relocate them before granting workspace access");
|
|
462
466
|
}
|
|
463
|
-
|
|
467
|
+
// The messenger profile (config, token file, run script) is private control state wherever it lives, not only under ~/.jeo.
|
|
468
|
+
const profileDirectory = process.env.JEO_MESSENGER_PROFILE_DIR;
|
|
469
|
+
if (profileDirectory !== undefined && !path.isAbsolute(profileDirectory)) throw new Error("SANDBOX_PRIVATE_STATE: invalid messenger profile directory");
|
|
470
|
+
const profilePaths = profileDirectory === undefined ? [] : await messengerProfilePrivatePaths(profileDirectory, cwd);
|
|
471
|
+
if (closing) throw new Error("Messenger worker stopping");
|
|
472
|
+
// Publish the candidate before creating it so close() removes it even if owner-only creation or ACL verification fails midway.
|
|
473
|
+
const candidateStateDirectory = path.join(tmpdir(), `jeo-sandbox-authority-${crypto.randomUUID()}`);
|
|
474
|
+
stateDirectory = candidateStateDirectory;
|
|
475
|
+
stateDirectory = await ensurePrivateSandboxDirectory(candidateStateDirectory);
|
|
476
|
+
if (closing) throw new Error("Messenger worker stopping");
|
|
464
477
|
const sessionDirectory = previousSessionDirectory || path.join(jeoHomeDir(), "sandbox-sessions", createHash("sha256").update(path.resolve(cwd)).digest("hex"));
|
|
465
|
-
const privateRoots = [stateDirectory, sessionDirectory, jeoHomeDir(), notifySessionsDir()];
|
|
478
|
+
const privateRoots = [stateDirectory, sessionDirectory, jeoHomeDir(), notifySessionsDir(), ...profilePaths];
|
|
466
479
|
const policy = await createSandboxPolicy({ sessionKey, revision: 0, cwd, config: sandboxConfig, privatePaths: privateRoots });
|
|
467
480
|
if (closing) throw new Error("Messenger worker stopping");
|
|
468
481
|
const sessionSupervisor = createSandboxSupervisor({ policy });
|
|
@@ -481,6 +494,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
481
494
|
if (closing) throw new Error("Messenger worker stopping");
|
|
482
495
|
await withSandboxExecution({ supervisor: sessionSupervisor, authority, requestAccess: (host, mode) => authority.requestAccess(host, mode) }, runSession);
|
|
483
496
|
} finally {
|
|
497
|
+
setup.resolve();
|
|
484
498
|
await close();
|
|
485
499
|
process.removeListener("SIGTERM", handleTermination);
|
|
486
500
|
process.removeListener("message", handleControlMessage);
|
|
@@ -159,6 +159,8 @@ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof B
|
|
|
159
159
|
delete env.JEO_MESSENGER_SANDBOX;
|
|
160
160
|
delete env.JEO_MESSENGER_ALLOWED_USER_IDS;
|
|
161
161
|
delete env.JEO_MESSENGER_SESSION_KEY;
|
|
162
|
+
delete env.JEO_MESSENGER_PROFILE_DIR;
|
|
163
|
+
if (config.profileDir !== undefined) env.JEO_MESSENGER_PROFILE_DIR = config.profileDir;
|
|
162
164
|
if (sandbox) {
|
|
163
165
|
env.JEO_MESSENGER_SANDBOX = JSON.stringify(sandbox);
|
|
164
166
|
env.JEO_MESSENGER_ALLOWED_USER_IDS = JSON.stringify(platformConfig?.allowedUserIds ?? []);
|
package/src/messenger/config.ts
CHANGED
|
@@ -147,6 +147,12 @@ export async function validateMessengerStorage(config: MessengerConfig): Promise
|
|
|
147
147
|
if (sandboxPathContains(cwd, sessionDir) || sandboxPathContains(sessionDir, cwd)) {
|
|
148
148
|
throw new Error("sessionDir must be outside and disjoint from the sandbox workspace");
|
|
149
149
|
}
|
|
150
|
+
if (config.profileDir !== undefined) {
|
|
151
|
+
const profile = await canonicalSandboxPath(config.profileDir);
|
|
152
|
+
if (sandboxPathContains(cwd, profile) || sandboxPathContains(profile, cwd)) {
|
|
153
|
+
throw new Error("The sandbox workspace must be disjoint from the messenger profile directory (configuration and credentials); set cwd to a directory outside it");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
150
156
|
const pending = [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")];
|
|
151
157
|
while (pending.length) {
|
|
152
158
|
const directory = pending.pop()!;
|
|
@@ -162,6 +168,25 @@ export async function validateMessengerStorage(config: MessengerConfig): Promise
|
|
|
162
168
|
}
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Host paths a sandbox mount or grant must never touch: the whole profile directory (messenger.json, token file,
|
|
173
|
+
* run script, anything created later) and every direct entry that is a symlink leaving it. Entries are canonicalized,
|
|
174
|
+
* so a symlink cannot move a secret outside the list. The workspace must be disjoint from the profile in both
|
|
175
|
+
* directions; any failure to inspect the profile throws instead of returning a shorter list.
|
|
176
|
+
*/
|
|
177
|
+
export async function messengerProfilePrivatePaths(profileDir: string, cwd: string): Promise<string[]> {
|
|
178
|
+
const [profile, workspace] = await Promise.all([canonicalSandboxPath(profileDir), canonicalSandboxPath(cwd)]);
|
|
179
|
+
if (sandboxPathContains(workspace, profile) || sandboxPathContains(profile, workspace)) {
|
|
180
|
+
throw new Error("SANDBOX_PRIVATE_STATE: the sandbox workspace must be disjoint from the messenger profile directory (configuration and credentials)");
|
|
181
|
+
}
|
|
182
|
+
const privatePaths = [profile];
|
|
183
|
+
for (const name of await fs.readdir(profile)) {
|
|
184
|
+
const entry = await canonicalSandboxPath(path.join(profile, name), false);
|
|
185
|
+
if (!sandboxPathContains(profile, entry)) privatePaths.push(entry);
|
|
186
|
+
}
|
|
187
|
+
return privatePaths;
|
|
188
|
+
}
|
|
189
|
+
|
|
165
190
|
export async function loadMessengerConfig(file: string): Promise<MessengerConfig> {
|
|
166
191
|
let value: unknown;
|
|
167
192
|
try {
|
|
@@ -169,7 +194,8 @@ export async function loadMessengerConfig(file: string): Promise<MessengerConfig
|
|
|
169
194
|
} catch {
|
|
170
195
|
throw new Error("Cannot read messenger configuration as JSON");
|
|
171
196
|
}
|
|
172
|
-
const
|
|
197
|
+
const profileDir = path.dirname(path.resolve(file));
|
|
198
|
+
const config: MessengerConfig = { ...parseMessengerConfig(value, profileDir), profileDir };
|
|
173
199
|
await validateMessengerStorage(config);
|
|
174
200
|
const artifactDir = config.platforms.discord?.artifactDir;
|
|
175
201
|
if (artifactDir) {
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { loadMessengerConfig, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
4
|
+
import { DEFAULT_MESSENGER_TOOLS, loadMessengerConfig, messengerProfilePrivatePaths, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
4
5
|
import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
|
|
5
6
|
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
6
7
|
import { installMessengerService, messengerServicePlan } from "./service";
|
|
7
8
|
import { isEnoent, isRecord } from "./types";
|
|
8
|
-
import { notifyDir } from "../agent/notify/paths";
|
|
9
|
+
import { jeoHomeDir, notifyDir, notifySessionsDir } from "../agent/notify/paths";
|
|
10
|
+
import { createSandboxPolicy } from "../agent/sandbox/policy";
|
|
11
|
+
import type { SandboxConfig } from "../agent/sandbox/types";
|
|
9
12
|
|
|
10
13
|
export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
|
|
11
14
|
const TOKEN_FILE = "discord-token";
|
|
15
|
+
/** Fresh-setup workspace: a jeo-owned directory in the user's home, outside every sandbox-private root. */
|
|
16
|
+
const WORKSPACE_DIR = "jeo-discord-workspace";
|
|
17
|
+
const WORKSPACE_MARKER = ".jeo-workspace";
|
|
12
18
|
const RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
13
19
|
const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
14
20
|
const MESSAGE_CONTENT = 1 << 18;
|
|
@@ -495,11 +501,18 @@ export function discordRunCommand(command: string[], configName: string, tokenEn
|
|
|
495
501
|
return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
|
|
496
502
|
}
|
|
497
503
|
|
|
498
|
-
|
|
504
|
+
/** Stage `content` beside `file`, run `validate` on the staged copy, and only then replace `file`; failure leaves `file` untouched. */
|
|
505
|
+
async function writePrivate(file: string, content: string, mode: number, validate?: (staged: string) => Promise<void>): Promise<void> {
|
|
499
506
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
try {
|
|
508
|
+
await Bun.write(temporary, content);
|
|
509
|
+
await fs.chmod(temporary, mode);
|
|
510
|
+
await validate?.(temporary);
|
|
511
|
+
await fs.rename(temporary, file);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
await fs.unlink(temporary).catch(() => undefined);
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
503
516
|
}
|
|
504
517
|
|
|
505
518
|
async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
|
|
@@ -519,6 +532,61 @@ async function readExistingConfig(configPath: string): Promise<Record<string, un
|
|
|
519
532
|
throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
|
|
520
533
|
}
|
|
521
534
|
|
|
535
|
+
/**
|
|
536
|
+
* The policy admission `messenger run` performs before starting the sandbox worker (commands/launch.ts), with the same
|
|
537
|
+
* private roots: the authority state directory, the session directory, the jeo home, the notify sessions directory and
|
|
538
|
+
* the profile paths from messengerProfilePrivatePaths, which also refuses a workspace that overlaps the profile. The authority state
|
|
539
|
+
* directory is a fresh random temp directory at launch, so a representative path under os.tmpdir() stands in for it.
|
|
540
|
+
* Throws on any refusal, so setup cannot succeed for a workspace that launch would reject.
|
|
541
|
+
*/
|
|
542
|
+
async function admitSandboxWorkspace(sandbox: SandboxConfig, profileDir: string, sessionDir: string, cwd: string): Promise<void> {
|
|
543
|
+
const profilePaths = await messengerProfilePrivatePaths(profileDir, cwd);
|
|
544
|
+
await createSandboxPolicy({
|
|
545
|
+
sessionKey: "setup",
|
|
546
|
+
revision: 0,
|
|
547
|
+
cwd,
|
|
548
|
+
config: sandbox,
|
|
549
|
+
privatePaths: [path.join(os.tmpdir(), "jeo-sandbox-authority-setup"), sessionDir, jeoHomeDir(), notifySessionsDir(), ...profilePaths],
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Create the default workspace (or reuse one that carries the marker an earlier setup run left), then prove `admit`
|
|
555
|
+
* accepts it. The marker is a re-run convention, not proof of ownership; reuse is still gated by `admit`, and an
|
|
556
|
+
* unmarked existing directory is never adopted. Failure cleanup is non-recursive and best-effort: it can only remove
|
|
557
|
+
* the marker this call wrote and the then-empty directory, so concurrently added data survives.
|
|
558
|
+
*/
|
|
559
|
+
async function createDefaultWorkspace(admit: (workspace: string) => Promise<void>): Promise<string> {
|
|
560
|
+
const workspace = path.join(os.homedir(), WORKSPACE_DIR);
|
|
561
|
+
const marker = path.join(workspace, WORKSPACE_MARKER);
|
|
562
|
+
let created = false;
|
|
563
|
+
let wroteMarker = false;
|
|
564
|
+
try {
|
|
565
|
+
await fs.mkdir(workspace, { mode: 0o700 });
|
|
566
|
+
created = true;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
if (created) {
|
|
572
|
+
await fs.writeFile(marker, "", { flag: "wx", mode: 0o600 });
|
|
573
|
+
wroteMarker = true;
|
|
574
|
+
} else {
|
|
575
|
+
const [stat, tag] = await Promise.all([fs.lstat(workspace), fs.lstat(marker).catch(() => undefined)]);
|
|
576
|
+
if (!stat.isDirectory() || !tag?.isFile()) {
|
|
577
|
+
throw new Error(`${workspace} already exists without the jeo setup marker`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
await admit(workspace);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (wroteMarker) await fs.unlink(marker).catch(() => undefined);
|
|
583
|
+
// rmdir refuses a non-empty directory, so anything added meanwhile is preserved.
|
|
584
|
+
if (created) await fs.rmdir(workspace).catch(() => undefined);
|
|
585
|
+
throw new Error(`Default workspace ${workspace} is unusable (${error instanceof Error ? error.message : String(error)}); set "cwd" in the config to a directory outside private jeo and config roots`, { cause: error });
|
|
586
|
+
}
|
|
587
|
+
return workspace;
|
|
588
|
+
}
|
|
589
|
+
|
|
522
590
|
export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
|
|
523
591
|
return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
|
|
524
592
|
}
|
|
@@ -606,13 +674,22 @@ export async function setupDiscordMessenger(
|
|
|
606
674
|
|
|
607
675
|
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
608
676
|
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
609
|
-
const
|
|
677
|
+
const sessionDir = str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions");
|
|
678
|
+
let cwd: unknown;
|
|
679
|
+
if (existing && Object.hasOwn(existing, "cwd")) {
|
|
680
|
+
// An explicit value is never replaced; an unusable one fails the loader below before the config is touched.
|
|
681
|
+
cwd = existing.cwd;
|
|
682
|
+
} else {
|
|
683
|
+
cwd = await createDefaultWorkspace(workspace => admitSandboxWorkspace(sandbox, directory, path.resolve(directory, sessionDir), workspace));
|
|
684
|
+
if (existing) io.say(`The existing config had no "cwd"; its sandbox workspace is now ${cwd}. No existing files were moved or deleted.`);
|
|
685
|
+
}
|
|
686
|
+
throwIfAborted(deps.signal);
|
|
610
687
|
const config = {
|
|
611
688
|
...existing,
|
|
612
|
-
cwd
|
|
613
|
-
sessionDir
|
|
689
|
+
cwd,
|
|
690
|
+
sessionDir,
|
|
614
691
|
sandbox,
|
|
615
|
-
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames :
|
|
692
|
+
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : [...DEFAULT_MESSENGER_TOOLS]),
|
|
616
693
|
platforms: { ...platforms, discord },
|
|
617
694
|
};
|
|
618
695
|
for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
|
|
@@ -620,7 +697,16 @@ export async function setupDiscordMessenger(
|
|
|
620
697
|
await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
|
|
621
698
|
throwIfAborted(deps.signal);
|
|
622
699
|
}
|
|
623
|
-
|
|
700
|
+
// The loader `messenger run` uses, then the policy admission it performs, must accept the staged file before it replaces a working config.
|
|
701
|
+
await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600, async staged => {
|
|
702
|
+
const loaded = await loadMessengerConfig(staged);
|
|
703
|
+
validateMessengerEnvironment({ ...loaded, platforms: { discord: loaded.platforms.discord } }, { [tokenEnv]: token });
|
|
704
|
+
try {
|
|
705
|
+
await admitSandboxWorkspace(sandbox, loaded.profileDir ?? directory, loaded.sessionDir, loaded.cwd);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
throw new Error(`Sandbox workspace ${loaded.cwd} would be refused at launch (${error instanceof Error ? error.message : String(error)}); set "cwd" in the config to a directory outside private jeo and config roots`, { cause: error });
|
|
708
|
+
}
|
|
709
|
+
});
|
|
624
710
|
throwIfAborted(deps.signal);
|
|
625
711
|
await writePrivate(
|
|
626
712
|
runScript,
|
|
@@ -630,12 +716,6 @@ export async function setupDiscordMessenger(
|
|
|
630
716
|
0o700,
|
|
631
717
|
);
|
|
632
718
|
throwIfAborted(deps.signal);
|
|
633
|
-
// The same loader `messenger run` uses must accept what was written.
|
|
634
|
-
const written = await loadMessengerConfig(configPath);
|
|
635
|
-
validateMessengerEnvironment(
|
|
636
|
-
{ ...written, platforms: { discord: written.platforms.discord } },
|
|
637
|
-
{ [tokenEnv]: token },
|
|
638
|
-
);
|
|
639
719
|
|
|
640
720
|
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
|
|
641
721
|
throwIfAborted(deps.signal);
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -216,7 +216,12 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
|
|
|
216
216
|
return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Admission lane. A top-level message has no thread yet, so only its raw channel can order it against its
|
|
221
|
+
* neighbours; anything already resolved is ordered by its own conversation. A lane is held only until the request
|
|
222
|
+
* is queued, never while it runs, so it cannot serialize requests that end up in different threads.
|
|
223
|
+
*/
|
|
224
|
+
function admissionKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
220
225
|
if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
|
|
221
226
|
return JSON.stringify([message.platform, message.accountId, message.guildId, message.channelId, message.platform === "discord" ? null : message.senderId]);
|
|
222
227
|
}
|
|
@@ -243,12 +248,15 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
|
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
interface PendingMessage {
|
|
251
|
+
/** Prepared destination: replies, deliveries and activity target it. */
|
|
246
252
|
message: MessengerMessage;
|
|
247
253
|
adapter: MessengerAdapter;
|
|
248
254
|
signal: AbortSignal;
|
|
249
255
|
resolve: () => void;
|
|
250
|
-
/**
|
|
256
|
+
/** Destination conversation key; requests sharing it never run at the same time. */
|
|
251
257
|
key: string;
|
|
258
|
+
/** Status reactions on the user's original message. */
|
|
259
|
+
status: (state: "processing" | "success" | "failure") => void;
|
|
252
260
|
}
|
|
253
261
|
|
|
254
262
|
export interface MessengerGateway {
|
|
@@ -260,8 +268,9 @@ export interface MessengerGatewayOptions {
|
|
|
260
268
|
/** Minimum spacing between in-place activity edits for one request. */
|
|
261
269
|
activityIntervalMs?: number;
|
|
262
270
|
/**
|
|
263
|
-
* Requests processed at once (default 1). Requests of one
|
|
264
|
-
*
|
|
271
|
+
* Requests processed at once (default 1). Requests of one conversation answer and deliver in arrival order and
|
|
272
|
+
* wait in the queue, not in a slot, behind their predecessor; above 1 the reply implementation must accept
|
|
273
|
+
* concurrent turns for different conversations.
|
|
265
274
|
*/
|
|
266
275
|
concurrency?: number;
|
|
267
276
|
/** Authenticated human controls bypass model queues, including busy sessions. */
|
|
@@ -286,23 +295,22 @@ export function createMessengerGateway(
|
|
|
286
295
|
const queue: PendingMessage[] = [];
|
|
287
296
|
const activeKeys = new Set<string>();
|
|
288
297
|
const turns = new Set<Promise<void>>();
|
|
289
|
-
/**
|
|
290
|
-
const conversationTails = new Map<string, Promise<void>>();
|
|
298
|
+
/** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
|
|
291
299
|
const admissionTails = new Map<string, Promise<void>>();
|
|
300
|
+
/** Requests holding a queue slot while the adapter prepares their destination; counted against the queue cap. */
|
|
301
|
+
let preparing = 0;
|
|
292
302
|
const lifetime = new AbortController();
|
|
293
303
|
let running = false;
|
|
294
304
|
let fatalError: MessengerFatalError | undefined;
|
|
295
305
|
let statusInFlight = 0;
|
|
296
306
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
let tail: Promise<void> | undefined;
|
|
303
|
-
let conversation = "";
|
|
307
|
+
function createStatusReporter(
|
|
308
|
+
adapter: MessengerAdapter,
|
|
309
|
+
message: MessengerMessage,
|
|
310
|
+
signal: AbortSignal,
|
|
311
|
+
): (state: "processing" | "success" | "failure") => void {
|
|
304
312
|
let statusChain = Promise.resolve();
|
|
305
|
-
|
|
313
|
+
return state => {
|
|
306
314
|
if (!adapter.status) return;
|
|
307
315
|
statusChain = statusChain.then(async () => {
|
|
308
316
|
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
@@ -332,22 +340,20 @@ export function createMessengerGateway(
|
|
|
332
340
|
}
|
|
333
341
|
});
|
|
334
342
|
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function processRequest(pending: PendingMessage): Promise<void> {
|
|
346
|
+
const { message, adapter, signal, resolve, status } = pending;
|
|
347
|
+
let failed = false;
|
|
348
|
+
let activity = NO_ACTIVITY;
|
|
335
349
|
try {
|
|
336
350
|
if (signal.aborted) return;
|
|
337
351
|
status("processing");
|
|
338
|
-
|
|
339
|
-
conversation = messengerConversationKey(destination);
|
|
340
|
-
const previous = conversationTails.get(conversation);
|
|
341
|
-
const done = Promise.withResolvers<void>();
|
|
342
|
-
release = done.resolve;
|
|
343
|
-
tail = done.promise;
|
|
344
|
-
conversationTails.set(conversation, tail);
|
|
345
|
-
await previous;
|
|
346
|
-
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
352
|
+
activity = createActivityReporter(adapter, message, signal, activityIntervalMs);
|
|
347
353
|
activity.report("Starting request");
|
|
348
354
|
let text: string;
|
|
349
355
|
try {
|
|
350
|
-
text = await reply(
|
|
356
|
+
text = await reply(message, signal, activity.report);
|
|
351
357
|
} catch (error) {
|
|
352
358
|
failed = true;
|
|
353
359
|
if (error instanceof MessengerFatalError) {
|
|
@@ -363,7 +369,7 @@ export function createMessengerGateway(
|
|
|
363
369
|
}
|
|
364
370
|
if (!signal.aborted && text.trim()) {
|
|
365
371
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
366
|
-
await adapter.send(
|
|
372
|
+
await adapter.send(message, text, signal);
|
|
367
373
|
}
|
|
368
374
|
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
369
375
|
} catch {
|
|
@@ -371,8 +377,6 @@ export function createMessengerGateway(
|
|
|
371
377
|
if (!signal.aborted) status("failure");
|
|
372
378
|
} finally {
|
|
373
379
|
await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
|
|
374
|
-
release();
|
|
375
|
-
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
376
380
|
resolve();
|
|
377
381
|
}
|
|
378
382
|
}
|
|
@@ -407,12 +411,12 @@ export function createMessengerGateway(
|
|
|
407
411
|
const oldest = seen.values().next().value;
|
|
408
412
|
if (oldest !== undefined) seen.delete(oldest);
|
|
409
413
|
}
|
|
410
|
-
const
|
|
411
|
-
const previous = admissionTails.get(
|
|
414
|
+
const lane = admissionKey(message, adapter);
|
|
415
|
+
const previous = admissionTails.get(lane) ?? Promise.resolve();
|
|
412
416
|
const ready = Promise.withResolvers<void>();
|
|
413
417
|
const tail = previous.then(() => ready.promise);
|
|
414
|
-
admissionTails.set(
|
|
415
|
-
void tail.then(() => { if (admissionTails.get(
|
|
418
|
+
admissionTails.set(lane, tail);
|
|
419
|
+
void tail.then(() => { if (admissionTails.get(lane) === tail) admissionTails.delete(lane); });
|
|
416
420
|
try {
|
|
417
421
|
if (options.sandboxControl) {
|
|
418
422
|
const control = await options.sandboxControl(message, effectiveSignal);
|
|
@@ -422,9 +426,30 @@ export function createMessengerGateway(
|
|
|
422
426
|
}
|
|
423
427
|
}
|
|
424
428
|
await previous;
|
|
425
|
-
if (effectiveSignal.aborted
|
|
429
|
+
if (effectiveSignal.aborted) return;
|
|
430
|
+
const status = createStatusReporter(adapter, message, effectiveSignal);
|
|
431
|
+
// Capacity is claimed before preparing: a request that cannot be queued must never create a thread.
|
|
432
|
+
if (queue.length + preparing >= MAX_QUEUED_MESSAGES) {
|
|
433
|
+
// Never executed, so forget the id: a redelivery is accepted once capacity returns, replaying nothing.
|
|
434
|
+
seen.delete(id);
|
|
435
|
+
status("failure");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let destination: MessengerMessage;
|
|
439
|
+
let key: string;
|
|
440
|
+
preparing++;
|
|
441
|
+
try {
|
|
442
|
+
destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
|
|
443
|
+
key = messengerConversationKey(destination);
|
|
444
|
+
} catch {
|
|
445
|
+
if (!effectiveSignal.aborted) status("failure");
|
|
446
|
+
return;
|
|
447
|
+
} finally {
|
|
448
|
+
preparing--;
|
|
449
|
+
}
|
|
450
|
+
if (effectiveSignal.aborted) return;
|
|
426
451
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
427
|
-
queue.push({ message, adapter, signal: effectiveSignal, resolve, key });
|
|
452
|
+
queue.push({ message: destination, adapter, signal: effectiveSignal, resolve, key, status });
|
|
428
453
|
schedule();
|
|
429
454
|
return promise;
|
|
430
455
|
} finally { ready.resolve(); }
|
package/src/messenger/types.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface MessengerPlatformConfig {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export interface MessengerConfig {
|
|
53
|
+
/** Directory holding the loaded messenger.json, token file and run script; set by `loadMessengerConfig`. */
|
|
54
|
+
profileDir?: string;
|
|
53
55
|
cwd: string;
|
|
54
56
|
sessionDir: string;
|
|
55
57
|
toolNames: string[];
|
package/src/util/retry.ts
CHANGED
|
@@ -64,6 +64,18 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
64
64
|
if (isRefusalError(err)) {
|
|
65
65
|
return false;
|
|
66
66
|
}
|
|
67
|
+
// An Antigravity/Gemini 3.x `finishReason=MALFORMED_FUNCTION_CALL` on an otherwise-200
|
|
68
|
+
// response that already carried partial text/functionCall parts is rejected by
|
|
69
|
+
// antigravity.ts as "…response incomplete (finishReason=MALFORMED_FUNCTION_CALL)."
|
|
70
|
+
// (call AND stream). It is an intermittent model fault, not a deterministic classifier
|
|
71
|
+
// verdict: the adapter throws BEFORE serializing the buffered functionCalls, so a resend
|
|
72
|
+
// executes no unvalidated call. Retry it within the normal attempt budget. Scoped to this
|
|
73
|
+
// exact shape — other `response incomplete (…)` reasons (MAX_TOKENS, missing finishReason)
|
|
74
|
+
// stay non-retryable; the empty-candidate variant ("returned no content (…)") is already
|
|
75
|
+
// retried below. A stream that has yielded a chunk is never re-run by retryableStream.
|
|
76
|
+
if (/Antigravity Cloud Code Assist response incomplete \(finishReason=MALFORMED_FUNCTION_CALL\)/i.test(errorMessageOf(err))) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
67
79
|
// A pre-response connection failure (refused / unresolved host) — retryable: a
|
|
68
80
|
// local provider that is mid-restart, or a transient DNS blip, can clear within
|
|
69
81
|
// the backoff budget. A provider that is simply never running exhausts the
|