agent-from-zero 0.39.0 → 0.39.2

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/README.md CHANGED
@@ -97,6 +97,18 @@ auto 模式下,改当前项目、其他 git 仓库或临时目录里的文件
97
97
 
98
98
  也可以在确认弹窗里选 "Yes, and trust …/ for this session",只对本次会话有效。家目录本身和 `/` 不能被信任,`settings.json` 本身永远要你确认。
99
99
 
100
+ ### prod 保护(permissions.ask)
101
+
102
+ `permissions.ask` 里列的文字,只要出现在命令或文件路径里,就一定要你确认(不区分大小写,按子串匹配)。auto 审核和 `--dangerously-skip-permissions` 都不能替你放行:
103
+
104
+ ```json
105
+ {
106
+ "permissions": { "ask": ["--context prod", "cipherowl-aws-prod", "terraform apply"] }
107
+ }
108
+ ```
109
+
110
+ 排查 prod 问题时,往往一连串命令都带着同一个标记(比如每条查询都用 `AWS_PROFILE=cipherowl-aws-prod`)。auto 模式下,确认弹窗里可以选 "Yes, and let auto review handle … commands this session":本次会话里,命中这些规则的命令不再强制找你,改为交给 auto 审核逐条判断。只读的直接执行,删除、部署这类做了就回不去的仍然会问你。这个放行只在 auto 模式下有效,关掉 auto 或用 `--dangerously-skip-permissions` 运行时都会恢复每次确认。
111
+
100
112
  ### 换别的模型
101
113
 
102
114
  和 API key 放在同一个地方,设置这三个变量就行,各家的 base URL 写在 [.env.example](.env.example) 里:
package/dist/agent.js CHANGED
@@ -442,7 +442,7 @@ async function main() {
442
442
  // Fail closed: in a non-interactive session nobody can say yes, so the
443
443
  // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
444
444
  // denies were already enforced in permissions.ts before we get here.
445
- const confirm = async (question, toolName, trustDir) => {
445
+ const confirm = async (question, toolName, offer) => {
446
446
  console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
447
447
  if (!autoMode.enabled && process.env.MINI_AGENT_AUTO_APPROVE === "1") {
448
448
  console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
@@ -454,8 +454,8 @@ async function main() {
454
454
  }
455
455
  // Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
456
456
  // option lets the user stop being asked about this tool (or, in auto mode,
457
- // this outside directory) for the session.
458
- const menu = approvalMenu(autoMode.enabled, toolName, trustDir);
457
+ // this outside directory or these prod-guard rules) for the session.
458
+ const menu = approvalMenu(autoMode.enabled, toolName, offer);
459
459
  const { approved, granted } = menu.choose(await select(menu.options));
460
460
  if (granted)
461
461
  console.log(chalk.dim(` ${granted}`));
@@ -532,6 +532,9 @@ async function main() {
532
532
  }
533
533
  apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
534
534
  };
535
+ // The login link for /mcp auth. This REPL waits on the login, so a browser on
536
+ // another machine (SSH) gets its redirect here through curl, run on this one.
537
+ const showAuthUrl = (url) => console.log(chalk.dim(`authenticate in your browser: ${url}\n browser on another machine (SSH)? after authorizing, run here in another terminal: curl '<the address it lands on>'`));
535
538
  // /mcp — list configured MCP servers + status, then manage one (like Claude Code).
536
539
  const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : status === "pending" ? chalk.dim("connecting…") : chalk.dim("disabled");
537
540
  const handleMcpCommand = async (arg) => {
@@ -546,7 +549,7 @@ async function main() {
546
549
  const name = parts.slice(1).join(" ");
547
550
  const action = MCP_SUBCOMMANDS[parts[0]];
548
551
  try {
549
- console.log(chalk.dim(await runMcpAction(name, action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
552
+ console.log(chalk.dim(await runMcpAction(name, action, showAuthUrl)));
550
553
  }
551
554
  catch (err) {
552
555
  console.log(chalk.yellow(err.message));
@@ -578,7 +581,7 @@ async function main() {
578
581
  return;
579
582
  }
580
583
  try {
581
- console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
584
+ console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action, showAuthUrl)));
582
585
  }
583
586
  catch (err) {
584
587
  console.log(chalk.yellow(err.message));
package/dist/ink/app.js CHANGED
@@ -44,7 +44,7 @@ import { detailPage, summarizeActivity } from "./activity.js";
44
44
  import { useTerminalSize } from "./viewport.js";
45
45
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
46
46
  import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
47
- import { listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpProblems, mcpServerDetails, reloadMcpServers, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
47
+ import { listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpProblems, mcpServerDetails, reloadMcpServers, runMcpAction, oauthLoginPending, cancelOAuthLogins, deliverOAuthRedirect } from "../mcp.js"; // /mcp: list + per-server actions, and the login that waits for its browser
48
48
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
49
49
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
50
50
  // No ✳ (U+2733): it has an emoji presentation, and terminals that honour it draw
@@ -279,8 +279,8 @@ export function App({ session, runTurn, clipboard }) {
279
279
  const hooks = {
280
280
  images: attachments,
281
281
  output: sink,
282
- confirm: (question, toolName, trustDir) => new Promise((resolve) => {
283
- const menu = approvalMenu(autoMode.enabled, toolName, trustDir); // same choices and session grants as the readline REPL
282
+ confirm: (question, toolName, offer) => new Promise((resolve) => {
283
+ const menu = approvalMenu(autoMode.enabled, toolName, offer); // same choices and session grants as the readline REPL
284
284
  openSelect(`⚠ approval needed — ${question}`, menu.options, (i) => {
285
285
  const { approved, granted } = menu.choose(i);
286
286
  note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}${granted ? chalk.dim(` (${granted})`) : ""}`);
@@ -508,6 +508,16 @@ export function App({ session, runTurn, clipboard }) {
508
508
  const submitLine = async (draft, attachments = [], immediate = false) => {
509
509
  peerTurns.current = 0; // a human is here: peers may start turns again
510
510
  peerHoldNoted.current = false;
511
+ // An MCP login is waiting for its browser redirect: what you submit is the
512
+ // address the browser landed on, or nothing for the model yet.
513
+ if (oauthLoginPending()) {
514
+ const got = deliverOAuthRedirect(pastedTexts.expand(draft));
515
+ if (got === "delivered")
516
+ return note(chalk.dim("(got the authorization redirect — finishing the login…)"));
517
+ return note(chalk.yellow(got === "stale"
518
+ ? "That address belongs to another login attempt — paste the one from this login, or Esc to cancel."
519
+ : "An MCP login is waiting: paste the address the browser landed on (http://localhost:…/callback?code=…), or Esc to cancel."));
520
+ }
511
521
  // Talk mode: plain text goes to the chosen session, even while this agent works.
512
522
  if (talkTo && !pending && !draft.startsWith("/")) {
513
523
  const text = pastedTexts.expand(draft);
@@ -617,6 +627,25 @@ export function App({ session, runTurn, clipboard }) {
617
627
  current.controller.abort();
618
628
  note(chalk.dim("(interrupting the current step to process your follow-up)"));
619
629
  };
630
+ // Run one /mcp action. A login waits for its browser redirect (up to 5
631
+ // minutes): say so on the spinner line, let Esc cancel it, and accept the
632
+ // redirected address pasted into the input box — the way in when the browser
633
+ // runs on another machine (SSH), where localhost is not this one.
634
+ const runMcpUi = async (name, action) => {
635
+ setBusy(true);
636
+ if (action === "authenticate")
637
+ setStatus(`Waiting for ${name} to authorize in the browser · paste the address it lands on here, or Esc to cancel`);
638
+ try {
639
+ note(chalk.dim(await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}\n browser on another machine (SSH)? after authorizing, paste the address it lands on (http://localhost:…/callback?code=…) here and press Enter`)))));
640
+ }
641
+ catch (err) {
642
+ note(chalk.yellow(err.message));
643
+ }
644
+ finally {
645
+ setBusy(false);
646
+ setStatus(null);
647
+ }
648
+ };
620
649
  // Handle a /slash command. Returns true if the line was a command (handled).
621
650
  // /mcp — list configured servers + status, then pick one to manage it.
622
651
  const handleMcpCommand = async (arg) => {
@@ -635,19 +664,7 @@ export function App({ session, runTurn, clipboard }) {
635
664
  }
636
665
  // Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
637
666
  if (parts.length >= 2 && MCP_SUBCOMMANDS[parts[0]]) {
638
- const name = parts.slice(1).join(" ");
639
- setBusy(true);
640
- try {
641
- const action = MCP_SUBCOMMANDS[parts[0]];
642
- const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
643
- note(chalk.dim(msg));
644
- }
645
- catch (err) {
646
- note(chalk.yellow(err.message));
647
- }
648
- finally {
649
- setBusy(false);
650
- }
667
+ await runMcpUi(parts.slice(1).join(" "), MCP_SUBCOMMANDS[parts[0]]);
651
668
  return;
652
669
  }
653
670
  const servers = listMcpServers();
@@ -664,11 +681,7 @@ export function App({ session, runTurn, clipboard }) {
664
681
  openSelect(`${server.name} MCP Server`, actions.map((a) => a.label), (j) => {
665
682
  if (j < 0)
666
683
  return note(chalk.dim("(cancelled)"));
667
- setBusy(true);
668
- runMcpAction(server.name, actions[j].action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)))
669
- .then((msg) => note(chalk.dim(msg)))
670
- .catch((err) => note(chalk.yellow(err.message)))
671
- .finally(() => setBusy(false));
684
+ void runMcpUi(server.name, actions[j].action);
672
685
  }, mcpServerDetails(server)); // Claude Code's server screen: status, auth, protocol, URL, config, capabilities, tools
673
686
  });
674
687
  };
@@ -992,6 +1005,10 @@ export function App({ session, runTurn, clipboard }) {
992
1005
  statusRequest.current.abort();
993
1006
  return;
994
1007
  }
1008
+ if (key.escape && oauthLoginPending()) {
1009
+ cancelOAuthLogins();
1010
+ return;
1011
+ } // a /mcp login waiting for its browser
995
1012
  if (key.escape && talkTo && !pending && !input) {
996
1013
  setTalkTo(null);
997
1014
  note(chalk.dim(`(back to this agent — no longer talking to "${talkTo.name}")`));
@@ -1136,6 +1153,16 @@ export function App({ session, runTurn, clipboard }) {
1136
1153
  }
1137
1154
  // Ink 5 strips the leading ESC from CSI-u / modifyOtherKeys sequences.
1138
1155
  if (key.return || modifiedReturn) {
1156
+ // A /mcp login waits for its browser redirect: the UI is busy, but what
1157
+ // you submit is the address the browser landed on (submitLine takes it).
1158
+ if (oauthLoginPending()) {
1159
+ const text = input.trim();
1160
+ setInput("");
1161
+ setCursor(0);
1162
+ if (text)
1163
+ void submitLine(text);
1164
+ return;
1165
+ }
1139
1166
  if (submitting.current || (busy && !turn.current))
1140
1167
  return;
1141
1168
  const immediate = key.ctrl || modifiedReturn;
@@ -1348,5 +1375,5 @@ export function App({ session, runTurn, clipboard }) {
1348
1375
  }
1349
1376
  return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(line, lineStarts[i] ?? 0, "l") })] }, i));
1350
1377
  });
1351
- })() })), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
1378
+ })() })), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && !oauthLoginPending() && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
1352
1379
  }
package/dist/loop.js CHANGED
@@ -772,7 +772,7 @@ async function authorizeCall(call, opts) {
772
772
  if (preview)
773
773
  sink(opts).note(preview.replace(/^/gm, agentIndent(opts)));
774
774
  }
775
- const ok = autoAllowed || (!(auto && opts.canPrompt === false) && await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}${reviewReason ? `\n ${reviewReason}` : ""}`, call.name, v.trustDir));
775
+ const ok = autoAllowed || (!(auto && opts.canPrompt === false) && await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}${reviewReason ? `\n ${reviewReason}` : ""}`, call.name, { trustDir: v.trustDir, guardRules: v.guardRules }));
776
776
  // Every human prompt in auto mode is an interruption worth counting, with
777
777
  // where it came from: the reviewer asked ("review", joinable by reviewId),
778
778
  // a doubt outlived the agent's retries ("escalated"), or a rule required a
package/dist/mcp.js CHANGED
@@ -37,6 +37,10 @@ import { emit } from "./telemetry.js"; // observability
37
37
  const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
38
38
  const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
39
39
  const OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60_000; // how long the local callback waits for the browser
40
+ // A startup connect that dies on the network (a dropped TLS handshake through a
41
+ // proxy, a timeout, a blip) is retried after these pauses before the server is
42
+ // marked failed; it shows as "connecting…" meanwhile. Mutable for the tests.
43
+ export const MCP_CONNECT_RETRY = { delaysMs: [2_000, 5_000, 10_000] };
40
44
  const PROTOCOL_VERSION = "2024-11-05"; // the MCP revision we speak in the handshake
41
45
  const CLIENT_INFO = { name: "mini-agent", version: "0" }; // who we say we are in the handshake
42
46
  // Thrown when an HTTP MCP server answers 401 — the caller marks the server as
@@ -59,6 +63,15 @@ export class NeedsAuthError extends Error {
59
63
  }
60
64
  }
61
65
  }
66
+ // The request never reached the server or its answer never came back: no
67
+ // connection, a dropped TLS handshake, a timeout. Unlike an error the server
68
+ // returns, trying again later can succeed.
69
+ export class McpNetworkError extends Error {
70
+ constructor(message) {
71
+ super(message);
72
+ this.name = "McpNetworkError";
73
+ }
74
+ }
62
75
  // ---- stdio transport: one JSON object per line, to a subprocess --------------
63
76
  class StdioTransport {
64
77
  child;
@@ -268,7 +281,11 @@ class HttpTransport {
268
281
  catch (err) {
269
282
  if (err instanceof NeedsAuthError)
270
283
  throw err; // propagate so the registry can mark needs-auth
271
- return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
284
+ // fetch says only "fetch failed"; the reason (UND_ERR_CONNECT_TIMEOUT,
285
+ // ECONNRESET, ENOTFOUND…) is on its cause, and it is what you need to know.
286
+ const cause = err.cause;
287
+ const why = cause?.code ?? cause?.message;
288
+ return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}${why ? ` (${why})` : ""}`, network: true } };
272
289
  }
273
290
  }
274
291
  notify(method, params) {
@@ -312,7 +329,7 @@ class McpClient {
312
329
  async start() {
313
330
  const init = await this.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, INIT_TIMEOUT_MS);
314
331
  if (init.error)
315
- throw new Error(init.error.message); // server refused to initialize
332
+ throw init.error.network ? new McpNetworkError(init.error.message) : new Error(init.error.message); // unreachable, or the server refused to initialize
316
333
  this.protocolVersion = init.result?.protocolVersion;
317
334
  this.capabilities = Object.keys(init.result?.capabilities ?? {});
318
335
  this.transport.notify("notifications/initialized", {}); // tell the server we're ready
@@ -325,7 +342,7 @@ class McpClient {
325
342
  async listTools() {
326
343
  const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS);
327
344
  if (list.error)
328
- throw new Error(list.error.message);
345
+ throw list.error.network ? new McpNetworkError(list.error.message) : new Error(list.error.message);
329
346
  return list.result?.tools ?? [];
330
347
  }
331
348
  // Invoke one tool and flatten its content into plain text for the model.
@@ -355,9 +372,17 @@ const registry = new Map();
355
372
  // scratch path so a fake server's tokens never land in the real store).
356
373
  const OAUTH_STORE_PATH = process.env.MINI_AGENT_OAUTH_STORE || path.join(os.homedir(), ".config", "mini-agent", "mcp-oauth.json");
357
374
  const expiryFrom = (t) => (typeof t.expires_in === "number" ? Date.now() + t.expires_in * 1000 : undefined);
375
+ // The file holds bearer tokens, so only its owner may read it (0600): set on
376
+ // every write, and tightened on read for a store created before this rule or
377
+ // copied over from another machine.
358
378
  function readOAuthStore() {
359
379
  try {
360
- return JSON.parse(fs.readFileSync(OAUTH_STORE_PATH, "utf8"));
380
+ const store = JSON.parse(fs.readFileSync(OAUTH_STORE_PATH, "utf8"));
381
+ try {
382
+ fs.chmodSync(OAUTH_STORE_PATH, 0o600);
383
+ }
384
+ catch { /* not ours to fix (read-only mount) — still usable */ }
385
+ return store;
361
386
  }
362
387
  catch {
363
388
  return {};
@@ -365,7 +390,8 @@ function readOAuthStore() {
365
390
  }
366
391
  function writeOAuthStore(store) {
367
392
  fs.mkdirSync(path.dirname(OAUTH_STORE_PATH), { recursive: true });
368
- fs.writeFileSync(OAUTH_STORE_PATH, JSON.stringify(store, null, 2) + "\n");
393
+ fs.writeFileSync(OAUTH_STORE_PATH, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
394
+ fs.chmodSync(OAUTH_STORE_PATH, 0o600); // mode applies only when the file is created
369
395
  }
370
396
  const oauthStore = readOAuthStore();
371
397
  function base64url(input) {
@@ -475,8 +501,48 @@ export async function discoverOAuth(serverUrl, challenge = {}) {
475
501
  // DCR case, where we register whatever we got); a fixed port is what a
476
502
  // pre-registered client demands. The code promise is armed on creation, BEFORE
477
503
  // the browser opens, so a fast callback can't race us.
504
+ // Logins waiting for their browser redirect, by OAuth state. The loopback
505
+ // listener is one way in; a pasted redirect address is the other, for a browser
506
+ // on another machine (SSH, a remote box) whose "localhost" is not this one.
507
+ const waitingLogins = new Map();
508
+ export const oauthLoginPending = () => waitingLogins.size > 0;
509
+ // Esc while a login waits: give up on it (the flow rejects, its listener closes).
510
+ export function cancelOAuthLogins() {
511
+ for (const login of [...waitingLogins.values()])
512
+ login.cancel();
513
+ }
514
+ // The user pasted the address the browser landed on — on its own, or inside a
515
+ // sentence ("curl this: http://localhost:37831/callback?code=…"). Hand it to the
516
+ // login waiting for its state, exactly as if the browser had reached us.
517
+ // "stale": a callback address no waiting login owns (an older attempt, a typo);
518
+ // "none": no callback address in the text at all.
519
+ export function deliverOAuthRedirect(text) {
520
+ const found = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\/callback\?[^\s"'<>]+/.exec(text);
521
+ if (!found)
522
+ return "none";
523
+ let url;
524
+ try {
525
+ url = new URL(found[0].replace(/[)\]}。,、;,;!!??]+$/, "").replace(/^(?!https?:\/\/)/, "http://")); // trailing sentence punctuation is not part of it
526
+ }
527
+ catch {
528
+ return "none";
529
+ }
530
+ const login = waitingLogins.get(url.searchParams.get("state") ?? "");
531
+ if (!login)
532
+ return "stale";
533
+ login.accept(url);
534
+ return "delivered";
535
+ }
478
536
  async function listenForCallback(port, state) {
479
537
  let settle;
538
+ // The redirect's verdict, however it arrives (the browser, or a pasted address).
539
+ const accept = (url) => {
540
+ const code = url.searchParams.get("code");
541
+ if (code)
542
+ settle?.resolve(code);
543
+ else
544
+ settle?.reject(new Error(`OAuth callback did not include a code: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "unknown error"}`));
545
+ };
480
546
  const handler = (req, res) => {
481
547
  const url = new URL(req.url ?? "/", "http://localhost");
482
548
  res.setHeader("connection", "close"); // one-shot listener: a kept-alive socket would deliver a LATER flow's callback (same fixed port) to this dead handler
@@ -486,13 +552,7 @@ async function listenForCallback(port, state) {
486
552
  res.end("<h1>Connected</h1><p>You can close this window and return to the terminal.</p>");
487
553
  // Settle only once the page has been flushed: settling tears the servers
488
554
  // down, and tearing down before the write lands would blank the browser tab.
489
- res.once("finish", () => {
490
- const code = url.searchParams.get("code");
491
- if (code)
492
- settle?.resolve(code);
493
- else
494
- settle?.reject(new Error(`OAuth callback did not include a code: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "unknown error"}`));
495
- });
555
+ res.once("finish", () => accept(url));
496
556
  };
497
557
  const v4 = createServer(handler);
498
558
  const bound = await new Promise((resolve, reject) => {
@@ -508,6 +568,7 @@ async function listenForCallback(port, state) {
508
568
  v6.listen(bound, "::1", () => resolve());
509
569
  });
510
570
  const close = () => {
571
+ waitingLogins.delete(state);
511
572
  // Release the port for real: close() alone leaves open sockets alive, and a
512
573
  // pre-registered client's next flow needs the SAME port back immediately.
513
574
  for (const s of [v4, v6]) {
@@ -525,6 +586,7 @@ async function listenForCallback(port, state) {
525
586
  reject: (e) => (clearTimeout(timer), close(), reject(e)),
526
587
  };
527
588
  });
589
+ waitingLogins.set(state, { accept, cancel: () => settle?.reject(new Error("Authorization cancelled")) });
528
590
  return { port: bound, close, code };
529
591
  }
530
592
  // Run the OAuth authorization-code flow for an HTTP server: PKCE always;
@@ -688,8 +750,10 @@ function markNeedsAuth(name, challenge) {
688
750
  }
689
751
  // Connect a single configured server and register its tools. Updates the
690
752
  // registry with the live status. Used by connectMcpServers() at startup and by
691
- // /mcp reconnect / enable / auth.
692
- async function connectOne(name, retried = false, quiet = false) {
753
+ // /mcp reconnect / enable / auth. `retryDelays`: pauses before retrying a
754
+ // network failure (background startup only — an explicit /mcp action answers
755
+ // at once, and its user can simply run it again).
756
+ async function connectOne(name, retried = false, quiet = false, retryDelays = []) {
693
757
  const entry = entryFor(name);
694
758
  const attempt = {}; // this connect's identity (see the guard after the handshake)
695
759
  entry.attempt = attempt;
@@ -735,6 +799,17 @@ async function connectOne(name, retried = false, quiet = false) {
735
799
  return connectOne(name, true, quiet);
736
800
  markNeedsAuth(name, err);
737
801
  }
802
+ else if (err instanceof McpNetworkError && retryDelays.length) {
803
+ // Unreachable this time: stay "connecting" and try again shortly, unless
804
+ // the user disabled or reconnected it meanwhile (a newer attempt).
805
+ entry.status = "pending";
806
+ entry.error = err.message;
807
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[0]).unref());
808
+ const now = registry.get(name); // re-read: /mcp may have changed it while we waited
809
+ if (now !== entry || now.attempt !== attempt || now.status === "disabled")
810
+ return;
811
+ return connectOne(name, retried, quiet, retryDelays.slice(1));
812
+ }
738
813
  else {
739
814
  entry.status = "failed";
740
815
  entry.error = err.message;
@@ -800,7 +875,7 @@ export async function connectMcpServers(options = {}) {
800
875
  for (const name of entries)
801
876
  entryFor(name).status = "pending";
802
877
  for (const name of entries)
803
- void connectOne(name, false, true); // fire and forget; connectOne never throws
878
+ void connectOne(name, false, true, MCP_CONNECT_RETRY.delaysMs); // fire and forget; connectOne never throws
804
879
  }
805
880
  else {
806
881
  for (const name of entries)
@@ -468,14 +468,27 @@ export function recoverableShell(command) {
468
468
  // Text that always needs a human: a prod context, a deployment verb, a prod
469
469
  // path. Checked BEFORE auto review and BEFORE the bypass rewrite, so neither
470
470
  // can approve it. Case-insensitive substring, on the whole command or the path.
471
- function guardHit(text) {
471
+ //
472
+ // Investigating prod means many commands carrying the same marker (every query
473
+ // under AWS_PROFILE=cipherowl-aws-prod), so in auto mode the human may release
474
+ // the rules a command matched, for the rest of the session. A released rule no
475
+ // longer forces a human: its commands go to auto review one by one, which
476
+ // still asks for anything irreversible. Only auto mode honours a release: the
477
+ // default mode has no reviewer behind it, and bypass has nothing at all.
478
+ const releasedGuards = new Set();
479
+ export function releaseGuards(rules) {
480
+ for (const rule of rules)
481
+ releasedGuards.add(rule.toLowerCase());
482
+ emit("agent_guard_released", { rules: rules.join(",") });
483
+ }
484
+ // Every rule the text matches that still needs a human (all of them, so one
485
+ // approval can release them together rather than one prompt per rule).
486
+ function guardHits(text, auto) {
472
487
  const haystack = text.toLowerCase();
473
- for (const rule of CONFIG.permissions.ask ?? []) {
474
- if (rule && haystack.includes(rule.toLowerCase()))
475
- return rule;
476
- }
477
- return null;
488
+ const honourReleases = auto && !CONFIG.bypassPermissions;
489
+ return (CONFIG.permissions.ask ?? []).filter((rule) => rule && haystack.includes(rule.toLowerCase()) && !(honourReleases && releasedGuards.has(rule.toLowerCase())));
478
490
  }
491
+ const guardVerdict = (rules, summary) => ({ decision: "ask", requiresHuman: true, reason: `prod guard: ${rules.map((r) => `"${r}"`).join(", ")}`, summary, guardRules: rules });
479
492
  // Decide what a bash command deserves: deny, ask, or allow.
480
493
  function checkBash(command, auto = false) {
481
494
  const summary = command.trim(); // what the user will see in the prompt
@@ -492,9 +505,9 @@ function checkBash(command, auto = false) {
492
505
  for (const d of user.deny)
493
506
  if (command.toLowerCase().includes(d.toLowerCase()))
494
507
  return { decision: "deny", reason: `denied by your settings ("${d}")`, summary };
495
- const guard = guardHit(command);
496
- if (guard)
497
- return { decision: "ask", requiresHuman: true, reason: `prod guard: "${guard}"`, summary };
508
+ const guards = guardHits(command, auto);
509
+ if (guards.length)
510
+ return guardVerdict(guards, summary);
498
511
  // node/npm/npx, git aliases and tool-wide grants can run arbitrary code.
499
512
  // Auto mode must inspect the actual command, including background commands.
500
513
  if (auto) {
@@ -690,9 +703,9 @@ function basePermission(toolName, argsJson, auto) {
690
703
  const hit = noFlyHit(p); // is it in the no-fly zone?
691
704
  if (hit)
692
705
  return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
693
- const guard = guardHit(p); // the user's prod guard, applied to the path
694
- if (guard)
695
- return { decision: "ask", requiresHuman: true, reason: `prod guard: "${guard}"`, summary: p };
706
+ const guards = guardHits(p, auto); // the user's prod guard, applied to the path
707
+ if (guards.length)
708
+ return guardVerdict(guards, p);
696
709
  // The user may pre-approve write tools ("tool:edit_file") to skip the
697
710
  // prompt — note this runs AFTER the no-fly check, so it widens "ask",
698
711
  // never "deny".
@@ -717,7 +730,8 @@ function basePermission(toolName, argsJson, auto) {
717
730
  // upgrades an ASK to ALLOW — but a hard DENY (rm -rf /, .git, .env…) always
718
731
  // stands. Convenience never overrides the no-fly rules. A grant for either
719
732
  // shell tool covers the other: the command is what was vouched for.
720
- if (!auto && verdict.decision === "ask" && (CONFIG.permissions.allow.includes(`tool:${toolName}`) || CONFIG.permissions.allow.includes("tool:run_bash"))) {
733
+ // Nor a prod-guard ask: that one needs a human every time, grant or not.
734
+ if (!auto && verdict.decision === "ask" && !verdict.requiresHuman && (CONFIG.permissions.allow.includes(`tool:${toolName}`) || CONFIG.permissions.allow.includes("tool:run_bash"))) {
721
735
  return { decision: "allow", reason: "bash pre-approved for this session", summary: verdict.summary };
722
736
  }
723
737
  return verdict;
@@ -772,15 +786,11 @@ function resolveFileTarget(target) {
772
786
  }
773
787
  return path.join(fs.realpathSync(current), ...missing);
774
788
  }
775
- // ---- The approval menu ----------------------------------------------------------
776
- // Both frontends (readline and Ink) offer the same choices and apply the same
777
- // session grant, so the menu lives next to the rules it widens. Outside auto
778
- // mode the grant is "don't ask again for this tool"; in auto mode it is "trust
779
- // this directory", offered only for an outside write. The last option is always
780
- // "No", and so is -1 (Esc). `granted` is the note to show after a grant.
781
- export function approvalMenu(auto, toolName, trustDir) {
789
+ export function approvalMenu(auto, toolName, offer = {}) {
790
+ const { trustDir, guardRules } = offer;
791
+ const rules = guardRules?.map((r) => `"${r}"`).join(", ");
782
792
  const grant = auto
783
- ? (trustDir ? `Yes, and trust ${trustDir}/ for this session` : null)
793
+ ? (trustDir ? `Yes, and trust ${trustDir}/ for this session` : rules ? `Yes, and let auto review handle ${rules} commands this session` : null)
784
794
  : (toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session");
785
795
  const options = [auto ? "Yes, once" : "Yes", ...(grant ? [grant] : []), "No — let me tell the agent what to do instead"];
786
796
  const choose = (i) => {
@@ -792,6 +802,10 @@ export function approvalMenu(auto, toolName, trustDir) {
792
802
  CONFIG.autoMode.trustedDirs.push(trustDir);
793
803
  return { approved: true, granted: `trusting ${trustDir}/ for this session` };
794
804
  }
805
+ if (auto && guardRules?.length) {
806
+ releaseGuards(guardRules);
807
+ return { approved: true, granted: `${rules} commands go to auto review for the rest of this session` };
808
+ }
795
809
  if (!toolName)
796
810
  return { approved: true };
797
811
  CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"