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 +12 -0
- package/dist/agent.js +8 -5
- package/dist/ink/app.js +49 -22
- package/dist/loop.js +1 -1
- package/dist/mcp.js +90 -15
- package/dist/permissions.js +35 -21
- package/package.json +1 -1
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
283
|
-
const menu = approvalMenu(autoMode.enabled, toolName,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/permissions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
|
496
|
-
if (
|
|
497
|
-
return
|
|
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
|
|
694
|
-
if (
|
|
695
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|