jeo-code 0.12.15 → 0.12.17
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 +26 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +11 -11
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +63 -8
- package/src/agent/hooks.ts +2 -1
- package/src/agent/loop-guards.ts +18 -7
- 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 +540 -540
- package/src/agent/sandbox/policy.ts +23 -6
- package/src/agent/sandbox/supervisor.ts +21 -9
- package/src/agent/shell-lease.ts +2 -1
- package/src/agent/task-tool.ts +17 -3
- package/src/agent/tools.ts +2 -1
- package/src/commands/launch/input.ts +25 -3
- package/src/commands/launch.ts +26 -5
- package/src/commands/messenger.ts +2 -0
- package/src/commands/ralplan.ts +14 -1
- 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/prompts/AGENTS.md +1 -1
- package/src/util/posix-tools.ts +66 -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
|
}
|
|
@@ -162,20 +162,29 @@ export function createSandboxSupervisor(options: { policy: SandboxPolicy; backen
|
|
|
162
162
|
return effect.result.promise;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** A Windows host path that matches no mount is not "relative" to the Linux guest; it must never be forwarded. */
|
|
165
166
|
function guestPath(value: string): string {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
const mount = path.isAbsolute(value) ? [...policy.mounts].sort((a, b) => b.host.length - a.host.length).find(item => sandboxPathContains(item.host, value)) : undefined;
|
|
168
|
+
if (mount) {
|
|
169
|
+
const relative = path.relative(mount.host, value).split(path.sep).join("/");
|
|
170
|
+
return relative ? `${mount.guest}/${relative}` : mount.guest;
|
|
171
|
+
}
|
|
172
|
+
if (process.platform === "win32" && /^(?:[A-Za-z]:|[\\/]{2}|\\)/.test(value)) throw new Error("SANDBOX_POLICY: host path is outside approved mounts");
|
|
173
|
+
return value;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
function call(tool: string, args: Record<string, unknown>, callOptions?: SandboxCallOptions): Promise<ToolResult> {
|
|
174
177
|
const mapped = { ...args };
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
let cwd: string | undefined;
|
|
179
|
+
try {
|
|
180
|
+
for (const key of ["filePath", "path", "dirPath", "dir", "targetPath", "cwd", "subdir"]) {
|
|
181
|
+
if (typeof mapped[key] === "string") mapped[key] = guestPath(mapped[key]);
|
|
182
|
+
}
|
|
183
|
+
if (callOptions?.cwd) cwd = guestPath(callOptions.cwd);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return Promise.resolve({ success: false, output: "", error: error instanceof Error ? error.message : String(error) });
|
|
177
186
|
}
|
|
178
|
-
return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(
|
|
187
|
+
return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(cwd ? { cwd } : {}) }, callOptions);
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
return {
|
|
@@ -184,7 +193,10 @@ export function createSandboxSupervisor(options: { policy: SandboxPolicy; backen
|
|
|
184
193
|
return Object.fromEntries(names.map(name => [name, (args: Record<string, unknown>, cwd: string, onProgress?: (text: string) => void) => call(name, args, { cwd, onProgress })]));
|
|
185
194
|
},
|
|
186
195
|
exec(argv: readonly string[], execOptions: SandboxExecOptions = {}): Promise<SandboxExecResult> {
|
|
187
|
-
|
|
196
|
+
let cwd: string | undefined;
|
|
197
|
+
try { if (execOptions.cwd) cwd = guestPath(execOptions.cwd); }
|
|
198
|
+
catch (error) { return Promise.resolve({ success: false, output: "", error: error instanceof Error ? error.message : String(error) }); }
|
|
199
|
+
return enqueue({ id: crypto.randomUUID(), type: "exec", argv: [...argv], ...(cwd ? { cwd } : {}), ...(execOptions.env ? { env: execOptions.env } : {}), ...(execOptions.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }, { signal: execOptions.signal });
|
|
188
200
|
},
|
|
189
201
|
async replacePolicy(next): Promise<void> {
|
|
190
202
|
if (state === "closed" || state === "changing" || next.sessionKey !== policy.sessionKey || next.revision <= policy.revision) throw new Error("SANDBOX_POLICY: closed, changing or invalid policy revision");
|
package/src/agent/shell-lease.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import * as cp from "node:child_process";
|
|
35
35
|
import * as fs from "node:fs";
|
|
36
|
+
import { resolvePosixTool } from "../util/posix-tools";
|
|
36
37
|
import * as os from "node:os";
|
|
37
38
|
import * as path from "node:path";
|
|
38
39
|
import { Readable } from "node:stream";
|
|
@@ -96,7 +97,7 @@ export function spawnLeasedShell(command: string, cwd: string): LeasedShell {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
function spawnDirect(command: string, cwd: string): LeasedShell {
|
|
99
|
-
const proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
100
|
+
const proc = Bun.spawn([resolvePosixTool("bash"), "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
100
101
|
const stdout = proc.stdout.getReader();
|
|
101
102
|
const stderr = proc.stderr.getReader();
|
|
102
103
|
const reaped = proc.exited.then(() => undefined);
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -467,6 +467,7 @@ export async function runSubagentOnce(
|
|
|
467
467
|
const failoverEnabled = !!opts.config.routing?.enabled;
|
|
468
468
|
let activeModel = initialModel;
|
|
469
469
|
emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
|
|
470
|
+
let contractCorrections = 0;
|
|
470
471
|
const runOnce = () => {
|
|
471
472
|
return runAgentLoop(history, {
|
|
472
473
|
cwd,
|
|
@@ -474,14 +475,27 @@ export async function runSubagentOnce(
|
|
|
474
475
|
maxSteps,
|
|
475
476
|
maxTokens: resolveMaxOutputTokens(activeModel, thinking),
|
|
476
477
|
sessionKey,
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
budget: { maxExtensions:
|
|
478
|
+
// Allow bounded step-budget extensions when a subagent is actively making
|
|
479
|
+
// progress so multi-file edits + test verification do not get cut off mid-run.
|
|
480
|
+
budget: { maxExtensions: role.readOnly ? 1 : 2 },
|
|
480
481
|
signal,
|
|
481
482
|
steer,
|
|
482
483
|
tools: subagentToolset(role),
|
|
483
484
|
...(failoverEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
|
|
484
485
|
events: {
|
|
486
|
+
onBeforeDone: reason => {
|
|
487
|
+
const hasToolActivity = fileMutations > 0 || bashRuns > 0 || readOnlyEvidenceCalls > 0;
|
|
488
|
+
if (!hasToolActivity || contractCorrections >= 1) return null;
|
|
489
|
+
const check = validateSubagentDoneReason(role, reason);
|
|
490
|
+
if (!check.ok) {
|
|
491
|
+
contractCorrections++;
|
|
492
|
+
return (
|
|
493
|
+
`Your done.reason is missing required section(s) for ${role.title}: ${check.missing?.join(", ")}. ` +
|
|
494
|
+
`Call done again with non-empty content under every required section header.`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
},
|
|
485
499
|
onEvidence: evidence => {
|
|
486
500
|
if (evidence.sawMutation) mutationMayHaveOccurred = true;
|
|
487
501
|
},
|
package/src/agent/tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyBashFixups } from "./bash-fixups";
|
|
2
|
+
import { resolvePosixTool } from "../util/posix-tools";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { readWorkflowStateStrict, type WorkflowState } from "./state";
|
|
@@ -727,7 +728,7 @@ export async function bashTool(
|
|
|
727
728
|
const reapBackground = isBackgroundReapEnabled();
|
|
728
729
|
const grouped = reapBackground && process.platform !== "win32";
|
|
729
730
|
if (grouped) ensureBackgroundSweeper();
|
|
730
|
-
const proc = Bun.spawn(["bash", "-c", command], {
|
|
731
|
+
const proc = Bun.spawn([resolvePosixTool("bash"), "-c", command], {
|
|
731
732
|
cwd: runCwd,
|
|
732
733
|
// Detach stdin (no inherited TTY): a command that reads stdin (`cat`, `read`,
|
|
733
734
|
// a REPL, an interactive prompt) then gets immediate EOF and exits instead of
|
|
@@ -439,7 +439,7 @@ export function captureLivePromptInputChunk(
|
|
|
439
439
|
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
440
440
|
if (start !== -1) state.inPaste = true;
|
|
441
441
|
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
442
|
-
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
442
|
+
if (feedLivePromptSegment(state, decodeExtendedKeys(stripMouseReports(plain)))) accepted = true;
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
445
|
return accepted;
|
|
@@ -506,10 +506,17 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
506
506
|
opts.onBufferedInput?.(raw);
|
|
507
507
|
return;
|
|
508
508
|
}
|
|
509
|
+
// Strip mouse reports and terminal capability replies (OSC/CSI device attributes)
|
|
510
|
+
// first so terminal responses arriving mid-turn never leak into the live draft.
|
|
511
|
+
const stripped = stripMouseReports(raw);
|
|
512
|
+
if (!stripped && raw.length > 0) {
|
|
513
|
+
opts.onNoise?.();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
509
516
|
// Kitty/modifyOtherKeys re-encodings → legacy bytes FIRST, so Esc (`CSI 27u`) and
|
|
510
517
|
// Ctrl+C (`CSI 99;5u` / `CSI 27;5;99~`) cancel a live turn on every terminal —
|
|
511
518
|
// previously they fell into the "noise" branch and did NOTHING mid-turn.
|
|
512
|
-
const text = decodeExtendedKeys(
|
|
519
|
+
const text = decodeExtendedKeys(stripped);
|
|
513
520
|
if (text === "\u000f") {
|
|
514
521
|
opts.onDetailKey?.();
|
|
515
522
|
return;
|
|
@@ -579,7 +586,22 @@ export function classifyMidTurnLine(line: string): "command" | "steer" | "empty"
|
|
|
579
586
|
// A lone sigil ("/" or "$") has no command name — ignore it instead of aborting the
|
|
580
587
|
// running turn to dispatch an empty command (a stray slash should not interrupt work).
|
|
581
588
|
if (t === "/" || t === "$") return "empty";
|
|
582
|
-
|
|
589
|
+
const firstToken = t.split(/\s+/, 1)[0] ?? "";
|
|
590
|
+
if (firstToken.startsWith("/")) {
|
|
591
|
+
// A slash command is a single leading token (/model, /help, /?); a token containing
|
|
592
|
+
// additional slashes (/Users/foo/bar, /tmp/a.ts, //comment) is a path or comment,
|
|
593
|
+
// never a REPL command that should abort the running turn.
|
|
594
|
+
const name = firstToken.slice(1);
|
|
595
|
+
if (!name || name.includes("/")) return "steer";
|
|
596
|
+
return /^[a-zA-Z?][a-zA-Z0-9_-]*$/.test(name) ? "command" : "steer";
|
|
597
|
+
}
|
|
598
|
+
if (firstToken.startsWith("$")) {
|
|
599
|
+
// A skill invocation starts with a skill identifier ($team, $ralplan), not a
|
|
600
|
+
// numeric dollar amount ($100) or path ($/foo).
|
|
601
|
+
const name = firstToken.slice(1);
|
|
602
|
+
return /^[a-zA-Z][a-zA-Z0-9_:-]*$/.test(name) ? "command" : "steer";
|
|
603
|
+
}
|
|
604
|
+
return "steer";
|
|
583
605
|
}
|
|
584
606
|
/** Default window (ms) over which the several delivery paths of ONE physical Ctrl+C
|
|
585
607
|
* press — readline 'keypress', the rl/process 'SIGINT' event, and the raw \u0003 stdin
|
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);
|
|
@@ -1953,7 +1967,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1953
1967
|
if ((pendingMutationVerification || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
|
|
1954
1968
|
return (
|
|
1955
1969
|
`Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
|
|
1956
|
-
`Run a relevant test/build/typecheck/lint after the last change, then call done again.`
|
|
1970
|
+
`Run a relevant test/build/typecheck/lint after the last change (e.g. 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done again.`
|
|
1957
1971
|
);
|
|
1958
1972
|
}
|
|
1959
1973
|
|
|
@@ -3163,7 +3177,14 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
3163
3177
|
queueBusySnapshot = () => ({
|
|
3164
3178
|
text: queuedPromptInput.partial,
|
|
3165
3179
|
});
|
|
3166
|
-
queueBusyClear = () => {
|
|
3180
|
+
queueBusyClear = () => {
|
|
3181
|
+
queuedPromptInput.partial = "";
|
|
3182
|
+
const r = rl as unknown as { line?: string; cursor?: number };
|
|
3183
|
+
if (typeof r.line === "string") {
|
|
3184
|
+
r.line = "";
|
|
3185
|
+
r.cursor = 0;
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3167
3188
|
queueBusyCommand = (line: string) => {
|
|
3168
3189
|
// NO_MIDTURN_DISPATCH=1 keeps the legacy prefill path (tee up, press Enter); the
|
|
3169
3190
|
// default routes to the immediate-dispatch queue served at the top of the loop.
|
|
@@ -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/commands/ralplan.ts
CHANGED
|
@@ -80,15 +80,28 @@ export async function runConsensusCriticGate(args: {
|
|
|
80
80
|
// is gated the SAME way: zero observed read/search/find/ast_grep/lsp calls means
|
|
81
81
|
// the [OKAY]/[ITERATE]/[REJECT] text is unevidenced regardless of what it claims.
|
|
82
82
|
let evidenceCalls = 0;
|
|
83
|
+
let contractCorrections = 0;
|
|
83
84
|
const runOnce = () => runAgentLoop(history, {
|
|
84
85
|
cwd: args.cwd,
|
|
85
86
|
model: activeModel,
|
|
86
87
|
maxSteps,
|
|
87
|
-
budget: { maxExtensions:
|
|
88
|
+
budget: { maxExtensions: 1 },
|
|
88
89
|
signal: args.signal,
|
|
89
90
|
tools: subagentToolset(role),
|
|
90
91
|
rateLimitFallbackAvailable,
|
|
91
92
|
events: {
|
|
93
|
+
onBeforeDone: reason => {
|
|
94
|
+
if (evidenceCalls === 0 || contractCorrections >= 1) return null;
|
|
95
|
+
const check = validateSubagentDoneReason(role, reason);
|
|
96
|
+
if (!check.ok) {
|
|
97
|
+
contractCorrections++;
|
|
98
|
+
return (
|
|
99
|
+
`Your critic report is missing required section(s): ${check.missing?.join(", ")}. ` +
|
|
100
|
+
`Call done again starting with [OKAY], [ITERATE], or [REJECT] and include a non-empty 'Justification:' section.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
92
105
|
onToolResult: (tool, success) => {
|
|
93
106
|
if (success && (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp")) evidenceCalls++;
|
|
94
107
|
},
|
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);
|