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.
@@ -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
- const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
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: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "" }, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
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 { return await fs.realpath(absolute); }
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
- return path.join(await canonicalSandboxPath(parent, false), path.basename(absolute));
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
- if (host === path.parse(host).root || host === home) throw new Error("SANDBOX_POLICY: host root and whole-home mounts are forbidden");
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
- if (!path.isAbsolute(value)) return value;
167
- const mount = [...policy.mounts].sort((a, b) => b.host.length - a.host.length).find(item => sandboxPathContains(item.host, value));
168
- if (!mount) return value;
169
- const relative = path.relative(mount.host, value).split(path.sep).join("/");
170
- return relative ? `${mount.guest}/${relative}` : mount.guest;
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
- for (const key of ["filePath", "path", "dirPath", "dir", "targetPath", "cwd", "subdir"]) {
176
- if (typeof mapped[key] === "string") mapped[key] = guestPath(mapped[key]);
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, ...(callOptions?.cwd ? { cwd: guestPath(callOptions.cwd) } : {}) }, callOptions);
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
- return enqueue({ id: crypto.randomUUID(), type: "exec", argv: [...argv], ...(execOptions.cwd ? { cwd: guestPath(execOptions.cwd) } : {}), ...(execOptions.env ? { env: execOptions.env } : {}), ...(execOptions.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }, { signal: execOptions.signal });
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");
@@ -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);
@@ -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
- // Bounded delegation: a subagent's step contract stays exact — the parent
478
- // owns any retry/extension decision, so the gjc retry flow is disabled here.
479
- budget: { maxExtensions: 0 },
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
  },
@@ -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(raw);
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
- return /^[/$]/.test(t) ? "command" : "steer";
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
@@ -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
- stateDirectory = fs.mkdtempSync(path.join(tmpdir(), "jeo-sandbox-authority-"));
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 = () => { queuedPromptInput.partial = ""; };
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 ?? []);
@@ -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: 0 },
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
  },
@@ -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 config = parseMessengerConfig(value, path.dirname(path.resolve(file)));
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
- async function writePrivate(file: string, content: string, mode: number): Promise<void> {
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
- await Bun.write(temporary, content);
501
- await fs.chmod(temporary, mode);
502
- await fs.rename(temporary, file);
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 defaultTools = ["read", "find", "search", "write", "edit", "bash", "computer"];
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: str(existing?.cwd) ?? "./workspace",
613
- sessionDir: str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions"),
689
+ cwd,
690
+ sessionDir,
614
691
  sandbox,
615
- toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : defaultTools),
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
- await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
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);