indusagi-coding-agent 0.2.13 → 0.2.14

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.
Files changed (56) hide show
  1. package/README.md +19 -0
  2. package/dist/entry.js +1762 -958
  3. package/dist/guardrails.js +145 -35
  4. package/dist/index.js +1810 -1006
  5. package/dist/review-agent.js +344 -60
  6. package/dist/review-server.js +346 -62
  7. package/package.json +12 -3
  8. package/src/boot/auth-vault.ts +4 -5
  9. package/src/boot/runners/addon-wiring.ts +111 -3
  10. package/src/boot/runners/benchmark-integrity.ts +107 -0
  11. package/src/boot/runners/repl-runner.ts +2 -1
  12. package/src/boot/runners/session.ts +18 -8
  13. package/src/boot/server-token.ts +20 -11
  14. package/src/briefing/context-docs.ts +144 -12
  15. package/src/channels/oneshot.ts +6 -0
  16. package/src/conductor/conductor.ts +67 -12
  17. package/src/conductor/contract.ts +4 -3
  18. package/src/conductor/transcript-store/store.ts +4 -8
  19. package/src/console/auth-status.ts +9 -4
  20. package/src/console/components/AgentsView.ts +2 -2
  21. package/src/console/components/BackgroundAgents.ts +15 -2
  22. package/src/console/components/Banner.ts +20 -9
  23. package/src/console/components/Composer.ts +57 -82
  24. package/src/console/components/ScrollViewport.ts +46 -0
  25. package/src/console/components/TerminalConsole.ts +165 -162
  26. package/src/console/components/WorkingIndicator.ts +6 -5
  27. package/src/console/components/banner-sweep.ts +5 -1
  28. package/src/console/components/welcome.ts +3 -3
  29. package/src/console/contract.ts +10 -5
  30. package/src/console/index.ts +7 -7
  31. package/src/console/input/complete.ts +2 -2
  32. package/src/console/input/dir-reader.ts +3 -1
  33. package/src/console/input/graphemes.ts +22 -0
  34. package/src/console/input/keymap.ts +6 -4
  35. package/src/console/mount.ts +45 -3
  36. package/src/console/overlays/auth.ts +28 -8
  37. package/src/console/overlays/boards.ts +13 -40
  38. package/src/console/overlays/host.ts +10 -0
  39. package/src/console/overlays/pickers.ts +35 -30
  40. package/src/console/reducer.ts +12 -5
  41. package/src/console/slash/builtins.ts +4 -3
  42. package/src/console/slash/commands/integrations.ts +13 -14
  43. package/src/console/slash/commands/transcript.ts +3 -8
  44. package/src/console/slash/commands/workbench.ts +2 -2
  45. package/src/console/test-support.ts +48 -0
  46. package/src/integrations/sarvam/config.ts +15 -13
  47. package/src/kit/external-editor.ts +17 -1
  48. package/src/kit/run-shell.ts +31 -0
  49. package/src/launch/invocation/flags.ts +12 -0
  50. package/src/launch/login.ts +23 -10
  51. package/src/launch/oauth.ts +3 -0
  52. package/src/models.ts +78 -2
  53. package/src/security/private-state.ts +122 -0
  54. package/src/window-budget/summarize/condense.ts +4 -1
  55. package/src/workspace/brand.ts +1 -1
  56. package/src/workspace/locator.ts +2 -2
@@ -35,8 +35,82 @@ import {
35
35
  type InterceptorChain,
36
36
  createAddonHost,
37
37
  } from "../../addons/index.js";
38
+ import { createHash } from "node:crypto";
39
+ import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
40
+ import { join, relative, resolve } from "node:path";
41
+ import { atomicWritePrivateFileSync, readPrivateFileSync } from "../../security/private-state.js";
38
42
  import type { BootContext } from "../contract.js";
39
43
 
44
+ interface AddonTrustRecord {
45
+ readonly root: string;
46
+ readonly digest: string;
47
+ readonly approvedAt: string;
48
+ }
49
+
50
+ interface AddonTrustStore {
51
+ readonly version: 1;
52
+ readonly projects: readonly AddonTrustRecord[];
53
+ }
54
+
55
+ const TRUST_FILE = "addon-trust.json";
56
+
57
+ function hashAddonTree(addonsDir: string): string | null {
58
+ let root: string;
59
+ try {
60
+ const rootStat = lstatSync(addonsDir);
61
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) return null;
62
+ root = realpathSync(addonsDir);
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
65
+ throw error;
66
+ }
67
+ const files: string[] = [];
68
+ const visit = (dir: string): void => {
69
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
70
+ const path = join(dir, entry.name);
71
+ const info = lstatSync(path);
72
+ if (info.isSymbolicLink()) throw new Error(`Refusing symlink in project addon tree: ${path}`);
73
+ if (info.isDirectory()) visit(path);
74
+ else if (info.isFile()) files.push(path);
75
+ else throw new Error(`Refusing special file in project addon tree: ${path}`);
76
+ }
77
+ };
78
+ visit(root);
79
+ const hash = createHash("sha256");
80
+ for (const file of files) {
81
+ hash.update(relative(root, file));
82
+ hash.update("\0");
83
+ hash.update(readFileSync(file));
84
+ hash.update("\0");
85
+ }
86
+ return hash.digest("hex");
87
+ }
88
+
89
+ function readTrustStore(path: string): AddonTrustStore {
90
+ const raw = readPrivateFileSync(path);
91
+ if (raw === null) return { version: 1, projects: [] };
92
+ try {
93
+ const parsed = JSON.parse(raw) as Partial<AddonTrustStore>;
94
+ if (parsed.version !== 1 || !Array.isArray(parsed.projects)) return { version: 1, projects: [] };
95
+ return {
96
+ version: 1,
97
+ projects: parsed.projects.filter((record): record is AddonTrustRecord =>
98
+ typeof record?.root === "string" &&
99
+ typeof record?.digest === "string" &&
100
+ typeof record?.approvedAt === "string"),
101
+ };
102
+ } catch {
103
+ return { version: 1, projects: [] };
104
+ }
105
+ }
106
+
107
+ function persistTrust(path: string, root: string, digest: string): void {
108
+ const store = readTrustStore(path);
109
+ const projects = store.projects.filter((record) => record.root !== root);
110
+ projects.push({ root, digest, approvedAt: new Date().toISOString() });
111
+ atomicWritePrivateFileSync(path, `${JSON.stringify({ version: 1, projects }, null, 2)}\n`);
112
+ }
113
+
40
114
  /**
41
115
  * Build and populate the addon host for a run, returning its wired
42
116
  * {@link AddonSurfaceBundle}.
@@ -54,10 +128,44 @@ import type { BootContext } from "../contract.js";
54
128
  */
55
129
  export async function buildAddonHost(ctx: BootContext): Promise<AddonSurfaceBundle> {
56
130
  const host = createAddonHost({ handles: {} });
57
- host.onFault(() => {
58
- // swallow addon load faults so a broken addon cannot sink the session
131
+ host.onFault((fault) => {
132
+ process.stderr.write(`Addon ${fault.kind} fault: ${fault.message}\n`);
59
133
  });
60
- return host.loadAll({ workspace: ctx.invocation.cwd ?? process.cwd() });
134
+ const workspace = resolve(ctx.invocation.cwd ?? process.cwd());
135
+ const addonsDir = join(workspace, ".indus", "addons");
136
+ let canonicalRoot: string;
137
+ let digest: string | null;
138
+ try {
139
+ canonicalRoot = realpathSync(workspace);
140
+ digest = hashAddonTree(addonsDir);
141
+ } catch (error) {
142
+ process.stderr.write(`Project addons blocked: ${error instanceof Error ? error.message : String(error)}\n`);
143
+ return host.loadAll({ explicitPaths: [] });
144
+ }
145
+ if (digest === null) return host.loadAll({ explicitPaths: [] });
146
+
147
+ const trustPath = join(ctx.workspace.profileDir, TRUST_FILE);
148
+ const approvedNow = ctx.invocation.flags["trust-project-addons"] === true;
149
+ let trusted = false;
150
+ try {
151
+ const store = readTrustStore(trustPath);
152
+ trusted = store.projects.some((record) => record.root === canonicalRoot && record.digest === digest);
153
+ if (!trusted && approvedNow) {
154
+ persistTrust(trustPath, canonicalRoot, digest);
155
+ trusted = true;
156
+ }
157
+ } catch (error) {
158
+ process.stderr.write(`Project addons blocked: ${error instanceof Error ? error.message : String(error)}\n`);
159
+ return host.loadAll({ explicitPaths: [] });
160
+ }
161
+
162
+ if (!trusted) {
163
+ process.stderr.write(
164
+ `Project addons are disabled until explicitly trusted for this exact content. Re-run with --trust-project-addons to approve ${canonicalRoot}.\n`,
165
+ );
166
+ return host.loadAll({ explicitPaths: [] });
167
+ }
168
+ return host.loadAll({ workspace: canonicalRoot });
61
169
  }
62
170
 
63
171
  /**
@@ -0,0 +1,107 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { AgentTool } from "../../addons/index.js";
4
+
5
+ type BenchmarkEnv = Readonly<Record<string, string | undefined>>;
6
+
7
+ const GUARDED_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls", "bash"]);
8
+ const PATH_KEYS = new Set(["path", "file", "file_path", "directory", "dir", "cwd", "search_path"]);
9
+ const FORBIDDEN_ABSOLUTE = [
10
+ "/tests",
11
+ "/solution",
12
+ "/solutions",
13
+ "/reference",
14
+ "/reference_solution",
15
+ "/reference-solutions",
16
+ "/reward.txt",
17
+ "/rewards",
18
+ "/grader",
19
+ "/verifier",
20
+ ];
21
+ const FORBIDDEN_BASENAMES = new Set([
22
+ "solution.sh",
23
+ "solve.sh",
24
+ "reward.txt",
25
+ "expected_answer.txt",
26
+ "expected_output.txt",
27
+ ]);
28
+
29
+ function normalizeCandidate(value: string, cwd: string): string {
30
+ return path.resolve(cwd, value);
31
+ }
32
+
33
+ function forbiddenPath(value: string, cwd: string): boolean {
34
+ const resolved = normalizeCandidate(value, cwd);
35
+ const base = path.basename(resolved).toLowerCase();
36
+ if (FORBIDDEN_BASENAMES.has(base)) return true;
37
+ return FORBIDDEN_ABSOLUTE.some((prefix) => resolved === prefix || resolved.startsWith(`${prefix}/`));
38
+ }
39
+
40
+ function pathValues(args: unknown): string[] {
41
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return [];
42
+ const values: string[] = [];
43
+ for (const [key, value] of Object.entries(args as Record<string, unknown>)) {
44
+ if (PATH_KEYS.has(key) && typeof value === "string") values.push(value);
45
+ }
46
+ return values;
47
+ }
48
+
49
+ function forbiddenShell(command: string, cwd: string): boolean {
50
+ const lower = command.toLowerCase();
51
+ if ([...FORBIDDEN_BASENAMES].some((name) => lower.includes(name))) return true;
52
+ if (FORBIDDEN_ABSOLUTE.some((prefix) => new RegExp(`(^|[\\s'\"=])${prefix.replaceAll("/", "\\/")}(?:[/\\s'\";|&]|$)`, "i").test(command))) return true;
53
+ if (/(^|[\s'"=])\.\.\/(?:tests|solutions?|reference(?:[-_]solutions?)?|grader|verifier)(?:[/\s'";|&]|$)/i.test(command)) return true;
54
+ if (/\b(?:find|ls|grep|rg)\s+(?:-[^\s]+\s+)*\/(?:\s|$)/i.test(command)) return true;
55
+ const retrievesOnline = /\b(?:curl|wget|git\s+clone|gh\s+repo\s+clone)\b/i.test(command);
56
+ const benchmarkMaterial = /terminal[-_ ]bench|reference[-_ ]solution|solution\.sh|solve\.sh|benchmark[-_ ]solution/i.test(command);
57
+ if (retrievesOnline && benchmarkMaterial) return true;
58
+ return false;
59
+ }
60
+
61
+ function denialReason(tool: string, args: unknown, cwd: string): string | undefined {
62
+ if (tool === "bash") {
63
+ const command = typeof (args as { command?: unknown } | null)?.command === "string"
64
+ ? (args as { command: string }).command
65
+ : "";
66
+ if (forbiddenShell(command, cwd)) return "Terminal-Bench integrity guard blocked benchmark-owned or solution material.";
67
+ return undefined;
68
+ }
69
+ if (pathValues(args).some((candidate) => forbiddenPath(candidate, cwd))) {
70
+ return "Terminal-Bench integrity guard blocked benchmark-owned or solution material.";
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function recordDenial(tool: string, callId: string, reason: string, env: BenchmarkEnv): void {
76
+ const auditPath = env.INDUSAGI_BENCHMARK_AUDIT_LOG ?? "/logs/agent/integrity-denials.jsonl";
77
+ try {
78
+ appendFileSync(auditPath, `${JSON.stringify({ type: "integrity_denial", tool, call_id: callId, reason })}\n`, { encoding: "utf8" });
79
+ } catch {
80
+ // Blocking must remain fail-closed even if the optional audit sink is unavailable.
81
+ }
82
+ }
83
+
84
+ function wrapOne(tool: AgentTool, cwd: string, env: BenchmarkEnv): AgentTool {
85
+ const execute = tool.execute.bind(tool);
86
+ return {
87
+ ...tool,
88
+ async execute(callId: string, args: unknown, signal?: AbortSignal, onUpdate?: unknown) {
89
+ const reason = denialReason(tool.name, args, cwd);
90
+ if (reason !== undefined) {
91
+ recordDenial(tool.name, callId, reason, env);
92
+ return {
93
+ content: [{ type: "text" as const, text: reason }],
94
+ details: undefined,
95
+ isError: true,
96
+ };
97
+ }
98
+ return execute(callId, args as never, signal as never, onUpdate as never);
99
+ },
100
+ };
101
+ }
102
+
103
+ /** Apply fail-closed Terminal-Bench integrity checks at every enabled tool boundary. */
104
+ export function wrapToolsForTerminalBench(tools: AgentTool[], cwd: string, env: BenchmarkEnv = process.env): AgentTool[] {
105
+ if (env.INDUSAGI_BENCHMARK_PROFILE !== "terminal-bench") return tools;
106
+ return tools.map((tool) => GUARDED_TOOLS.has(tool.name) ? wrapOne(tool, cwd, env) : tool);
107
+ }
@@ -14,6 +14,7 @@ import { SessionLibrary } from "../../sessions/library.js";
14
14
  import { PreferenceStore } from "../../settings/manager.js";
15
15
  import { sessionScopeDir } from "./session.js";
16
16
  import { createAuthVault } from "../auth-vault.js";
17
+ import type { SessionConductor } from "../../conductor/contract.js";
17
18
  import type { BootContext, Runner } from "../contract.js";
18
19
  import { buildSessionConductor } from "./session.js";
19
20
  import { resolveServerGatewayUrls } from "./server-mode.js";
@@ -28,7 +29,7 @@ const EXIT_OK = 0;
28
29
  * wired here from the boot-layer pieces so the conductor and the UI share one
29
30
  * credential store and one gateway-base-url mapping.
30
31
  */
31
- export function buildOverlayServices(ctx: BootContext, conductor: { updateGatewayBaseUrls(map: Record<string, string>): void }) {
32
+ export function buildOverlayServices(ctx: BootContext, conductor: SessionConductor) {
32
33
  const cwd = ctx.invocation.cwd ?? process.cwd();
33
34
  const vault = createAuthVault(ctx.workspace.authPath);
34
35
  return {
@@ -9,7 +9,7 @@
9
9
  * is constructed until the first turn runs.
10
10
  */
11
11
  import { existsSync, readFileSync } from "node:fs";
12
- import { join } from "node:path";
12
+ import { isAbsolute, join, resolve } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { createMCPAgentToolFactory, loadMCPConfig, MCPClientPool } from "indusagi/mcp";
15
15
  import {
@@ -61,7 +61,9 @@ import { DiskMemoryStore, memoryDirFor, loadMemoryDoc } from "./memdir.js";
61
61
  import { isReasoningEffortSupported } from "../../models.js";
62
62
  import type { CodingAgentThinkingLevel } from "../../conductor/contract.js";
63
63
  import { buildAddonHost, wrapToolsWithAddons, concatAddonTools } from "./addon-wiring.js";
64
+ import { wrapToolsForTerminalBench } from "./benchmark-integrity.js";
64
65
  import type { BootContext, Invocation } from "../contract.js";
66
+ import { readPrivateFileSync } from "../../security/private-state.js";
65
67
 
66
68
  interface AuthFileData {
67
69
  [provider: string]: unknown;
@@ -70,8 +72,9 @@ interface AuthFileData {
70
72
  function authenticatedProviders(ctx: BootContext): string[] {
71
73
  try {
72
74
  const path = ctx.workspace.authPath;
73
- if (!existsSync(path)) return [];
74
- const data = JSON.parse(readFileSync(path, "utf8")) as AuthFileData;
75
+ const raw = readPrivateFileSync(path);
76
+ if (raw === null) return [];
77
+ const data = JSON.parse(raw) as AuthFileData;
75
78
  return Object.keys(data).filter((id) => {
76
79
  const accounts = data[id];
77
80
  return id.length > 0 && accounts !== null && typeof accounts === "object" && Object.keys(accounts as object).length > 0;
@@ -303,8 +306,11 @@ export function composeSystem(
303
306
  skills: readonly SkillCard[] = [],
304
307
  memoryDoc?: ContextDoc,
305
308
  ): string {
309
+ const approvedExternalPaths = Array.isArray(inv.flags["allow-context-import"])
310
+ ? inv.flags["allow-context-import"].filter((path): path is string => typeof path === "string").map((path) => isAbsolute(path) ? path : resolve(cwd, path))
311
+ : [];
306
312
  const contextDocs = [
307
- ...gatherContextDocs(cwd, homedir()),
313
+ ...gatherContextDocs(cwd, homedir(), { approvedExternalPaths }),
308
314
  ...memoryDoc ? [memoryDoc] : [],
309
315
  ];
310
316
  let base = inv.system !== undefined ? resolveText(inv.system) : composeBriefing({ tools, contextDocs, skills });
@@ -431,13 +437,14 @@ function lastUserTurnStart(messages: Array<{ role: string }>): number {
431
437
 
432
438
  const condenseTranscript = async (
433
439
  messages: Array<{ role: string }>,
434
- opts?: { model?: unknown; apiKey?: string; signal?: AbortSignal; force?: boolean },
440
+ opts?: { model?: unknown; apiKey?: string; signal?: AbortSignal; force?: boolean; instructions?: string },
435
441
  ): Promise<Array<{ role: string }>> => {
436
442
  const model = opts?.model;
437
443
  const deps = model ? {
438
444
  model,
439
445
  ...opts?.apiKey !== undefined ? { apiKey: opts.apiKey } : {},
440
446
  ...opts?.signal !== undefined ? { signal: opts.signal } : {},
447
+ ...opts?.instructions !== undefined ? { instructions: opts.instructions } : {},
441
448
  } : {};
442
449
  const cleared = clearStaleToolResults(messages as never) as Array<{ role: string }>;
443
450
  if (opts?.force) {
@@ -475,12 +482,12 @@ function gatherPermissionRules(store: PreferenceStore): PermissionRule[] {
475
482
  return rules;
476
483
  }
477
484
 
478
- function resolveDefaultMode(store: PreferenceStore): PermissionMode {
485
+ export function resolveDefaultMode(store: PreferenceStore): PermissionMode {
479
486
  for (const tier of [store.projectOverrides(), store.globalDefaults()]) {
480
487
  const mode = tier.permissions?.defaultMode;
481
488
  if (mode !== undefined && isPermissionMode(mode)) return mode;
482
489
  }
483
- return "default";
490
+ return "acceptEdits";
484
491
  }
485
492
 
486
493
  function readOnlyNamesFromDeck(tools: AgentTool[]): Set<string> {
@@ -598,7 +605,10 @@ export async function buildSessionConductor(ctx: BootContext) {
598
605
  basePath: typeof inv.sarvamBasePath === "string" ? inv.sarvamBasePath : typeof inv.flags?.["sarvam-base-path"] === "string" ? inv.flags["sarvam-base-path"] : undefined,
599
606
  })),
600
607
  ];
601
- const tools = wrapToolsWithAddons(concatAddonTools(deck, addons), addons);
608
+ const tools = wrapToolsForTerminalBench(
609
+ wrapToolsWithAddons(concatAddonTools(deck, addons), addons),
610
+ cwd,
611
+ );
602
612
  const skills = gatherModelSkills(cwd);
603
613
  const system = composeSystem(tools, inv, cwd, skills, memoryDoc);
604
614
  const thinking = resolveThinkingLevel(inv.thinking, modelId);
@@ -18,9 +18,10 @@
18
18
  * ISO timestamp, on disk) conversion, which lets {@link isServerTokenExpired}
19
19
  * decide freshness without re-deriving the clock offset.
20
20
  */
21
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
21
+ import { unlink } from "node:fs/promises";
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
+ import { atomicWritePrivateFileSync, readPrivateFileSync } from "../security/private-state.js";
24
25
 
25
26
  /** Root of the indus-server. Used to build the gateway base URL and the device-login endpoints. Any trailing slash(es) are stripped so callers can append paths with a single separator. */
26
27
  export const INDUS_SERVER_URL: string = process.env.INDUS_SERVER_URL?.replace(/\/+$/, "") ?? "https://indus-server.vercel.app";
@@ -29,6 +30,16 @@ export const CONFIG_DIR: string = path.join(os.homedir(), ".indusagi", "agent");
29
30
  /** Absolute path of the JSON file the session token is persisted to. */
30
31
  export const TOKEN_FILE: string = path.join(CONFIG_DIR, "server-token.json");
31
32
 
33
+ export interface ServerTokenPaths {
34
+ readonly configDir?: string;
35
+ readonly tokenFile?: string;
36
+ }
37
+
38
+ function resolveTokenPaths(paths: ServerTokenPaths = {}): { configDir: string; tokenFile: string } {
39
+ const configDir = paths.configDir ?? CONFIG_DIR;
40
+ return { configDir, tokenFile: paths.tokenFile ?? path.join(configDir, "server-token.json") };
41
+ }
42
+
32
43
  /** On-disk token shape (what {@link storeServerToken} writes / {@link getServerToken} returns). */
33
44
  export interface TokenData {
34
45
  /** The better-auth session token, used as the gateway api key in server mode. */
@@ -65,9 +76,10 @@ export interface ServerTokenInput {
65
76
  *
66
77
  * @returns the parsed {@link TokenData}, or `null` when nothing is stored.
67
78
  */
68
- export async function getServerToken(): Promise<TokenData | null> {
79
+ export async function getServerToken(paths: ServerTokenPaths = {}): Promise<TokenData | null> {
69
80
  try {
70
- const data = await readFile(TOKEN_FILE, "utf8");
81
+ const data = readPrivateFileSync(resolveTokenPaths(paths).tokenFile);
82
+ if (data === null) return null;
71
83
  return JSON.parse(data);
72
84
  } catch {
73
85
  return null;
@@ -83,9 +95,9 @@ export async function getServerToken(): Promise<TokenData | null> {
83
95
  * @param token the device-flow token payload.
84
96
  * @returns `true` on success, `false` if the write failed.
85
97
  */
86
- export async function storeServerToken(token: ServerTokenInput): Promise<boolean> {
98
+ export async function storeServerToken(token: ServerTokenInput, paths: ServerTokenPaths = {}): Promise<boolean> {
87
99
  try {
88
- await mkdir(CONFIG_DIR, { recursive: true });
100
+ const { tokenFile } = resolveTokenPaths(paths);
89
101
  const tokenData = {
90
102
  access_token: token.access_token,
91
103
  refresh_token: token.refresh_token,
@@ -94,10 +106,7 @@ export async function storeServerToken(token: ServerTokenInput): Promise<boolean
94
106
  expires_at: token.expires_in !== undefined ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null,
95
107
  created_at: new Date().toISOString(),
96
108
  };
97
- await writeFile(TOKEN_FILE, `${JSON.stringify(tokenData, null, 2)}\n`, {
98
- encoding: "utf8",
99
- mode: 384,
100
- });
109
+ atomicWritePrivateFileSync(tokenFile, `${JSON.stringify(tokenData, null, 2)}\n`);
101
110
  return true;
102
111
  } catch (error) {
103
112
  console.error("Failed to store server token:", (error as Error).message);
@@ -111,9 +120,9 @@ export async function storeServerToken(token: ServerTokenInput): Promise<boolean
111
120
  *
112
121
  * @returns `true` when a file was deleted, `false` when none existed / removal failed.
113
122
  */
114
- export async function clearServerToken(): Promise<boolean> {
123
+ export async function clearServerToken(paths: ServerTokenPaths = {}): Promise<boolean> {
115
124
  try {
116
- await unlink(TOKEN_FILE);
125
+ await unlink(resolveTokenPaths(paths).tokenFile);
117
126
  return true;
118
127
  } catch {
119
128
  return false;
@@ -1,19 +1,151 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
- import { dirname, extname, isAbsolute, join, parse, resolve } from "node:path";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, extname, isAbsolute, join, parse, resolve, sep } from "node:path";
3
3
  import type { ContextDoc } from "./contract.js";
4
4
 
5
- export interface GatherContextDocsOptions { readonly maxImportDepth?: number; readonly maxBytesPerDoc?: number; }
5
+ export interface GatherContextDocsOptions {
6
+ readonly maxImportDepth?: number;
7
+ readonly maxBytesPerDoc?: number;
8
+ /** Exact external files the user explicitly approved for this run. */
9
+ readonly approvedExternalPaths?: readonly string[];
10
+ }
11
+
6
12
  export const DEFAULT_MAX_IMPORT_DEPTH = 5;
7
13
  export const DEFAULT_MAX_BYTES_PER_DOC = 40_000;
8
14
  export const CANDIDATE_FILES = ["AGENTS.md", "CLAUDE.md", "INDUSAGI.md", "CLAUDE.local.md", "AGENTS.local.md", join(".indusagi", "CLAUDE.md"), join(".claude", "CLAUDE.md")];
9
- export const TEXT_FILE_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".text", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".py", ".rb", ".go", ".rs", ".sh", ".env"]);
15
+ export const TEXT_FILE_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".text", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".py", ".rb", ".go", ".rs", ".sh"]);
10
16
  export const IMPORT_REGEX = /(?:^|\s)@((?:[^\s\\]|\\ )+)/g;
11
17
 
12
- export function shortenPath(path: string, cwd: string, home: string): string { if (cwd && path.startsWith(cwd)) { const rest = path.slice(cwd.length).replace(/^[/\\]+/, ""); return rest ? `./${rest}` : "."; } return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path; }
13
- export function dirChain(cwd: string): string[] { const chain: string[] = []; let current = cwd; for (let i = 0; i < 256; i++) { chain.push(current); const parent = dirname(current); if (parent === current || current === parse(current).root) break; current = parent; } return chain.reverse(); }
14
- export function safeRealpath(path: string): string { try { return realpathSync(path); } catch { return path; } }
15
- export function isTextFile(path: string): boolean { const extension = extname(path).toLowerCase(); return !extension || TEXT_FILE_EXTENSIONS.has(extension); }
16
- export function readFileBody(path: string): string | null { if (!existsSync(path)) return null; try { const body = readFileSync(path, "utf8").trim(); return body || null; } catch { return null; } }
17
- export function extractImports(body: string, baseDir: string, home: string): string[] { const out: string[] = []; IMPORT_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = IMPORT_REGEX.exec(body))) { let spec = match[1]; if (!spec) continue; const hash = spec.indexOf("#"); if (hash >= 0) spec = spec.slice(0, hash); spec = spec.replace(/\\ /g, " ").trim(); if (!spec) continue; const absolute = spec.startsWith("~/") ? join(home, spec.slice(2)) : isAbsolute(spec) ? spec : resolve(baseDir, spec); if (isTextFile(absolute)) out.push(absolute); } return out; }
18
- export function loadDocTree(path: string, cwd: string, home: string, seen: Set<string>, depth: number, maxDepth: number, maxBytes: number, out: ContextDoc[]): void { const real = safeRealpath(path); if (seen.has(real)) return; seen.add(real); const body = readFileBody(path); if (body === null) return; out.push({ path: shortenPath(path, cwd, home), body: body.length > maxBytes ? body.slice(0, maxBytes) : body }); if (depth >= maxDepth) return; for (const child of extractImports(body, dirname(path), home)) loadDocTree(child, cwd, home, seen, depth + 1, maxDepth, maxBytes, out); }
19
- export function gatherContextDocs(cwd: string, home: string, opts?: GatherContextDocsOptions): readonly ContextDoc[] { const out: ContextDoc[] = []; try { const seen = new Set<string>(); const roots = dirChain(cwd); if (home) roots.push(home); for (const dir of roots) for (const name of CANDIDATE_FILES) loadDocTree(join(dir, name), cwd, home, seen, 0, opts?.maxImportDepth ?? DEFAULT_MAX_IMPORT_DEPTH, opts?.maxBytesPerDoc ?? DEFAULT_MAX_BYTES_PER_DOC, out); } catch { return out; } return out; }
18
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
19
+ const SECRET_BASENAMES = new Set([
20
+ ".env", "credentials", "credentials.json", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
21
+ ]);
22
+ const SECRET_EXTENSIONS = new Set([".key", ".pem", ".p12", ".pfx"]);
23
+
24
+ export function shortenPath(path: string, cwd: string, home: string): string {
25
+ if (cwd && (path === cwd || path.startsWith(`${cwd}${sep}`))) {
26
+ const rest = path.slice(cwd.length).replace(/^[/\\]+/, "");
27
+ return rest ? `./${rest}` : ".";
28
+ }
29
+ return home && (path === home || path.startsWith(`${home}${sep}`)) ? `~${path.slice(home.length)}` : path;
30
+ }
31
+
32
+ /** Retained for API compatibility; context discovery no longer walks this chain. */
33
+ export function dirChain(cwd: string): string[] {
34
+ const chain: string[] = [];
35
+ let current = cwd;
36
+ for (let i = 0; i < 256; i++) {
37
+ chain.push(current);
38
+ const parent = dirname(current);
39
+ if (parent === current || current === parse(current).root) break;
40
+ current = parent;
41
+ }
42
+ return chain.reverse();
43
+ }
44
+
45
+ export function safeRealpath(path: string): string {
46
+ try { return realpathSync(path); } catch { return path; }
47
+ }
48
+
49
+ export function isTextFile(path: string): boolean {
50
+ const extension = extname(path).toLowerCase();
51
+ return !extension || TEXT_FILE_EXTENSIONS.has(extension);
52
+ }
53
+
54
+ export function isLikelySecretFile(path: string): boolean {
55
+ const name = basename(path).toLowerCase();
56
+ return SECRET_BASENAMES.has(name) || name.startsWith(".env.") || SECRET_EXTENSIONS.has(extname(name));
57
+ }
58
+
59
+ export function readFileBody(path: string): string | null {
60
+ let fd: number | undefined;
61
+ try {
62
+ const before = lstatSync(path);
63
+ if (before.isSymbolicLink() || !before.isFile()) return null;
64
+ fd = openSync(path, constants.O_RDONLY | NO_FOLLOW);
65
+ if (!fstatSync(fd).isFile()) return null;
66
+ const body = readFileSync(fd, "utf8").trim();
67
+ return body || null;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (fd !== undefined) closeSync(fd);
72
+ }
73
+ }
74
+
75
+ export function extractImports(body: string, baseDir: string, home: string): string[] {
76
+ const out: string[] = [];
77
+ IMPORT_REGEX.lastIndex = 0;
78
+ let match: RegExpExecArray | null;
79
+ while ((match = IMPORT_REGEX.exec(body))) {
80
+ let spec = match[1];
81
+ if (!spec) continue;
82
+ const hash = spec.indexOf("#");
83
+ if (hash >= 0) spec = spec.slice(0, hash);
84
+ spec = spec.replace(/\\ /g, " ").trim();
85
+ if (!spec) continue;
86
+ const absolute = spec.startsWith("~/") ? join(home, spec.slice(2)) : isAbsolute(spec) ? spec : resolve(baseDir, spec);
87
+ if (isTextFile(absolute)) out.push(absolute);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function canonicalExistingFile(path: string): string | null {
93
+ try {
94
+ const before = lstatSync(path);
95
+ if (before.isSymbolicLink() || !before.isFile()) return null;
96
+ return realpathSync(path);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function isWithin(root: string, candidate: string): boolean {
103
+ return candidate === root || candidate.startsWith(`${root}${sep}`);
104
+ }
105
+
106
+ interface LoadPolicy {
107
+ readonly root: string;
108
+ readonly approvedExternal: ReadonlySet<string>;
109
+ }
110
+
111
+ function loadTrustedDocTree(path: string, cwd: string, home: string, seen: Set<string>, depth: number, maxDepth: number, maxBytes: number, out: ContextDoc[], policy: LoadPolicy): void {
112
+ if (isLikelySecretFile(path)) return;
113
+ const real = canonicalExistingFile(path);
114
+ if (real === null || seen.has(real) || isLikelySecretFile(real)) return;
115
+ if (!isWithin(policy.root, real) && !policy.approvedExternal.has(real)) return;
116
+ seen.add(real);
117
+ const body = readFileBody(real);
118
+ if (body === null) return;
119
+ out.push({ path: shortenPath(real, cwd, home), body: body.length > maxBytes ? body.slice(0, maxBytes) : body });
120
+ if (depth >= maxDepth) return;
121
+ for (const child of extractImports(body, dirname(real), home)) {
122
+ loadTrustedDocTree(child, cwd, home, seen, depth + 1, maxDepth, maxBytes, out, policy);
123
+ }
124
+ }
125
+
126
+ /** Compatibility wrapper with secure default confinement to cwd. */
127
+ export function loadDocTree(path: string, cwd: string, home: string, seen: Set<string>, depth: number, maxDepth: number, maxBytes: number, out: ContextDoc[]): void {
128
+ let root: string;
129
+ try { root = realpathSync(cwd); } catch { return; }
130
+ loadTrustedDocTree(path, root, home, seen, depth, maxDepth, maxBytes, out, { root, approvedExternal: new Set() });
131
+ }
132
+
133
+ export function gatherContextDocs(cwd: string, home: string, opts: GatherContextDocsOptions = {}): readonly ContextDoc[] {
134
+ const out: ContextDoc[] = [];
135
+ try {
136
+ const root = realpathSync(cwd);
137
+ const approvedExternal = new Set<string>();
138
+ for (const path of opts.approvedExternalPaths ?? []) {
139
+ const real = canonicalExistingFile(path);
140
+ if (real !== null && !isLikelySecretFile(real)) approvedExternal.add(real);
141
+ }
142
+ const policy = { root, approvedExternal };
143
+ const seen = new Set<string>();
144
+ for (const name of CANDIDATE_FILES) {
145
+ loadTrustedDocTree(join(root, name), root, home, seen, 0, opts.maxImportDepth ?? DEFAULT_MAX_IMPORT_DEPTH, opts.maxBytesPerDoc ?? DEFAULT_MAX_BYTES_PER_DOC, out, policy);
146
+ }
147
+ } catch {
148
+ return out;
149
+ }
150
+ return out;
151
+ }
@@ -49,6 +49,12 @@ function ndjsonStrategy(): OneshotStrategy {
49
49
  },
50
50
  onSignal(signal: unknown, ctx: ChannelContext): void {
51
51
  const s = signal as { kind: string };
52
+ // Running shell commands can emit thousands of rolling progress snapshots,
53
+ // each containing the same bounded output tail. Serializing every snapshot
54
+ // ignores stdout backpressure and can retain hundreds of megabytes. The
55
+ // terminal tool result is emitted in full by tool_end, which is the durable
56
+ // event required by one-shot consumers and benchmark trajectory conversion.
57
+ if (s.kind === "tool_update") return;
52
58
  ctx.out.write(ctx.framer.encodeLine({ type: "signal", name: s.kind, body: s }));
53
59
  },
54
60
  finish(state: ConductorState, ctx: ChannelContext): number {