openmausbot 0.1.72 → 0.1.74
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/dist/assets/index-BRLHsQ5g.js +298 -0
- package/dist/assets/index-ByLQ7a3F.css +1 -0
- package/dist/assets/{index-Bpai86ua.js → index-CwoQdM0z.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +9 -0
- package/dist-server/drivers/agents-proxy.js +2 -2
- package/dist-server/index.js +721 -484
- package/dist-server/mcp-gate.js +58 -16
- package/dist-server/openmausbot.js +236 -80
- package/dist-server/pair-cli.js +236 -80
- package/dist-server/server/auto-approve.js +30 -206
- package/dist-server/server/channel-queue.js +20 -2
- package/dist-server/server/config.js +36 -1
- package/dist-server/server/drivers/acp/core.js +56 -3
- package/dist-server/server/drivers/acp/grok.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +2 -2
- package/dist-server/server/drivers/antigravity-acp.js +5 -4
- package/dist-server/server/drivers/antigravity.js +9 -4
- package/dist-server/server/drivers/claude.js +54 -11
- package/dist-server/server/drivers/codex.js +86 -27
- package/dist-server/server/engine-install.js +9 -5
- package/dist-server/server/index.js +210 -231
- package/dist-server/server/message-db.js +70 -3
- package/dist-server/server/procs.js +82 -23
- package/dist-server/server/routines.js +67 -8
- package/dist-server/server/steer-queue.js +27 -7
- package/dist-server/server/team-manifest.js +1 -1
- package/dist-server/server/vps-computer.js +81 -12
- package/dist-server/server/webhooks.js +4 -1
- package/dist-server/server/workspace-backup.js +24 -9
- package/dist-server/shared/approval-mode.js +11 -10
- package/dist-server/vps-container-mcp.js +9 -0
- package/package.json +1 -1
- package/dist/assets/index-BFPYw6P-.js +0 -296
- package/dist/assets/index-D1odUdTi.css +0 -1
- package/dist-server/server/auto-review.js +0 -79
|
@@ -1,237 +1,61 @@
|
|
|
1
|
-
//
|
|
1
|
+
// What the harness does with a provider's permission request.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// it is a "you probably didn't mean to hand THIS one over unattended"
|
|
11
|
-
// backstop for the obvious catastrophes. Real containment is the
|
|
12
|
-
// sandbox and the bot's own computer, not a regex.
|
|
13
|
-
import { approvalModeFor } from "../shared/approval-mode.js";
|
|
3
|
+
// Nothing here decides whether an action is safe. Each approval level is a
|
|
4
|
+
// provider's own permission mode passed straight through (Claude `auto`,
|
|
5
|
+
// Grok `--permission-mode`, Codex `approvalsReviewer`, …), and a request that
|
|
6
|
+
// reaches this process is one the provider left for a person. The only
|
|
7
|
+
// verdict the app synthesizes is Full access, because that level is the
|
|
8
|
+
// person's explicit, separately confirmed grant to answer every prompt.
|
|
9
|
+
// Questions never come through here: a bot's question always reaches a human.
|
|
14
10
|
/** Full access is the person's explicit grant to this receiving bot, including
|
|
15
11
|
* delegated work. It never inherits the sender's mode or elevates another bot.
|
|
16
12
|
* Custom is a provider-config choice rather than an app Full-access grant, so
|
|
17
|
-
* peer-started Custom turns
|
|
18
|
-
*
|
|
13
|
+
* peer-started Custom turns use Auto. Provider support and grant confirmation
|
|
14
|
+
* are checked by the caller. */
|
|
19
15
|
export function approvalModeForOrigin(mode, origin) {
|
|
20
16
|
if (mode === "custom" && origin.peerInitiated)
|
|
21
17
|
return "auto";
|
|
22
18
|
return mode;
|
|
23
19
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/\bsudo\s+rm\b|\bchmod\s+-R\s+777\s+\//i,
|
|
32
|
-
];
|
|
33
|
-
// Not destructive, but exactly what you don't hand over unattended: a
|
|
34
|
-
// bot reading your keys is quiet, permanent, and unrecoverable.
|
|
35
|
-
const SENSITIVE = [
|
|
36
|
-
/(^|[\s/"'])\.env(\.|$|["'\s])/i,
|
|
37
|
-
/\.ssh\/|id_rsa|id_ed25519|authorized_keys/i,
|
|
38
|
-
/\.aws\/credentials|\.netrc|\.npmrc|\.pypirc|\.docker\/config\.json/i,
|
|
39
|
-
/security\s+find-(generic|internet)-password|\bkeychain\b/i,
|
|
40
|
-
/\bcredentials?\.json\b|\bserviceaccount\b/i,
|
|
41
|
-
];
|
|
42
|
-
/** First matching pattern's source, so a verdict can NAME the rule that
|
|
43
|
-
* made it — the decision log's whole value is "which rule", and deriving
|
|
44
|
-
* the match a second time at the call site is how the log and the verdict
|
|
45
|
-
* drift apart. */
|
|
46
|
-
function matchFirst(rules, text) {
|
|
47
|
-
for (const re of rules)
|
|
48
|
-
if (re.test(text))
|
|
49
|
-
return re.source;
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
export function looksSensitive(text) {
|
|
53
|
-
return matchFirst(SENSITIVE, text) !== null;
|
|
54
|
-
}
|
|
55
|
-
export function looksDestructive(text) {
|
|
56
|
-
return matchFirst(DESTRUCTIVE, text) !== null;
|
|
57
|
-
}
|
|
58
|
-
/** The key an "Always allow" remembers.
|
|
59
|
-
*
|
|
60
|
-
* A bare tool name is far too coarse for a command runner: remembering
|
|
61
|
-
* "Bash" would hand the bot a permanent unattended shell, which is the
|
|
62
|
-
* opposite of what someone pressing "always allow" on `git status`
|
|
63
|
-
* intends. Command tools are therefore keyed by their program —
|
|
64
|
-
* `Bash:git`, `Bash:npm` — so the grant is as narrow as the thing you
|
|
65
|
-
* actually looked at. Computed once, server-side, and echoed back by the
|
|
66
|
-
* client so the two sides can never disagree about what was granted. */
|
|
67
|
-
const COMMAND_TOOLS = new Set(["bash", "shell", "execute", "run_command", "computer_exec", "terminal"]);
|
|
68
|
-
export function approvalKey(tool, summary, scope) {
|
|
69
|
-
const bare = tool.replace(/^mcp__[^_]+__/, "").toLowerCase();
|
|
70
|
-
if (!COMMAND_TOOLS.has(bare))
|
|
71
|
-
return scope ? `${scope}:${tool}` : tool;
|
|
72
|
-
// first bare word of the command, skipping env assignments and sudo
|
|
73
|
-
const words = summary.trim().split(/\s+/);
|
|
74
|
-
let i = 0;
|
|
75
|
-
while (i < words.length && (/^[A-Z_][A-Z0-9_]*=/.test(words[i]) || words[i] === "sudo"))
|
|
76
|
-
i += 1;
|
|
77
|
-
const program = (words[i] ?? "").split("/").pop()?.replace(/[^\w.-]/g, "") ?? "";
|
|
78
|
-
const key = program ? `${tool}:${program}` : tool;
|
|
79
|
-
return scope ? `${scope}:${key}` : key;
|
|
80
|
-
}
|
|
81
|
-
/** A durable "Always allow" choice is offered only when that exact grant
|
|
82
|
-
* would be honored on the next identical request. Custom delegates approval
|
|
83
|
-
* semantics to config.toml, while guards and provider-sandbox changes are
|
|
84
|
-
* intentionally never bypassed by remembered app grants. */
|
|
85
|
-
export function rememberableApprovalKey(bot, tool, summary, context) {
|
|
86
|
-
if (!bot ||
|
|
87
|
-
approvalModeFor(bot) === "custom" ||
|
|
88
|
-
context.source !== "no-grant" ||
|
|
89
|
-
context.scope ||
|
|
90
|
-
context.requiresExplicitApproval) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
return approvalKey(tool, summary, context.scope);
|
|
94
|
-
}
|
|
95
|
-
/** The verdict AND its provenance. The decision itself is unchanged from
|
|
96
|
-
* autoDecision below — this exists so the decision log can record which
|
|
97
|
-
* rule decided without the call site re-deriving (and eventually
|
|
98
|
-
* mis-deriving) the match. */
|
|
99
|
-
export function autoVerdict(bot, tool, summary, context) {
|
|
100
|
-
const mode = approvalModeFor(bot);
|
|
101
|
-
if (context?.nativeApproval)
|
|
102
|
-
return { approve: null, source: "native-approval" };
|
|
103
|
-
// This branch intentionally precedes every guard. Entering Full access is
|
|
104
|
-
// separately consent-gated by the bot PATCH endpoint, and its promise is
|
|
105
|
-
// literal: even destructive, sensitive, unattended, and host-computer
|
|
106
|
-
// permission requests are approved. The request.opened caller invokes this
|
|
107
|
-
// function for permissions only, never for provider questions.
|
|
108
|
-
if (mode === "full") {
|
|
109
|
-
return {
|
|
110
|
-
approve: `approved ${tool} (full access)`,
|
|
111
|
-
source: "full-access",
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
if (context?.requiresExplicitApproval) {
|
|
20
|
+
export function autoVerdict(mode, tool, context) {
|
|
21
|
+
// Full's promise is literal: even a sandbox widening is approved. Entering
|
|
22
|
+
// Full is separately consent-gated by the bot PATCH endpoint, and the
|
|
23
|
+
// request.opened caller invokes this for permissions only, never questions.
|
|
24
|
+
if (mode === "full")
|
|
25
|
+
return { approve: `approved ${tool} (full access)`, source: "full-access" };
|
|
26
|
+
if (context?.requiresExplicitApproval)
|
|
115
27
|
return { approve: null, source: "explicit-approval-block" };
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// into them
|
|
119
|
-
const destructive = matchFirst(DESTRUCTIVE, summary) ?? matchFirst(DESTRUCTIVE, tool);
|
|
120
|
-
const sensitive = destructive ? null : matchFirst(SENSITIVE, summary);
|
|
121
|
-
// The grant is computed even when a hard block will refuse it: the row
|
|
122
|
-
// worth auditing is "this WOULD have auto-approved, and only the block
|
|
123
|
-
// stood in the way", which cannot be told apart from an ordinary
|
|
124
|
-
// "nobody granted this" card without knowing both halves.
|
|
125
|
-
const key = approvalKey(tool, summary, context?.scope);
|
|
126
|
-
const grant = destructive || sensitive
|
|
127
|
-
? null
|
|
128
|
-
: mode !== "custom" && bot.alwaysAllow?.includes(key)
|
|
129
|
-
? { approve: `auto-approved ${key} (always allowed)`, source: "always-allow", rule: key }
|
|
130
|
-
: mode === "auto"
|
|
131
|
-
? { approve: `auto-approved ${tool}`, source: "auto-mode", rule: undefined }
|
|
132
|
-
: null;
|
|
133
|
-
if (context?.unattended) {
|
|
134
|
-
// Auto mode is something a person switched on for turns they are present
|
|
135
|
-
// for. A webhook turn begins with nobody watching, on a payload someone
|
|
136
|
-
// else wrote, so it does not inherit that decision — the guard above is a
|
|
137
|
-
// pattern list its own comment calls "not a security boundary", and it
|
|
138
|
-
// must not stand in for a human at 3am. A guard that would have carded
|
|
139
|
-
// anyway keeps its own name; the block is only the story when it is the
|
|
140
|
-
// thing that changed the outcome.
|
|
141
|
-
if (grant)
|
|
142
|
-
return { approve: null, source: "unattended-block", rule: grant.rule };
|
|
143
|
-
if (destructive)
|
|
144
|
-
return { approve: null, source: "destructive-guard", rule: destructive };
|
|
145
|
-
if (sensitive)
|
|
146
|
-
return { approve: null, source: "sensitive-guard", rule: sensitive };
|
|
147
|
-
return { approve: null, source: "no-grant" };
|
|
148
|
-
}
|
|
149
|
-
if (context?.scope === "local-computer" && mode !== "auto") {
|
|
150
|
-
// Host control is not covered by a remembered always-allow grant.
|
|
151
|
-
// After the Auto-on-this-computer warning, unclassified GUI actions
|
|
152
|
-
// (click/type) may auto-approve; destructive/sensitive still card.
|
|
153
|
-
if (grant)
|
|
154
|
-
return { approve: null, source: "local-computer-block", rule: grant.rule };
|
|
155
|
-
if (destructive)
|
|
156
|
-
return { approve: null, source: "destructive-guard", rule: destructive };
|
|
157
|
-
if (sensitive)
|
|
158
|
-
return { approve: null, source: "sensitive-guard", rule: sensitive };
|
|
159
|
-
return { approve: null, source: "no-grant" };
|
|
160
|
-
}
|
|
161
|
-
if (destructive)
|
|
162
|
-
return { approve: null, source: "destructive-guard", rule: destructive };
|
|
163
|
-
if (sensitive)
|
|
164
|
-
return { approve: null, source: "sensitive-guard", rule: sensitive };
|
|
165
|
-
if (grant)
|
|
166
|
-
return { approve: grant.approve, source: grant.source, rule: grant.rule };
|
|
28
|
+
if (mode === "auto" || mode === "custom")
|
|
29
|
+
return { approve: null, source: "native-approval" };
|
|
167
30
|
return { approve: null, source: "no-grant" };
|
|
168
31
|
}
|
|
169
|
-
/** Why this request may be answered without the human, or null to ask. */
|
|
170
|
-
export function autoDecision(bot, tool, summary, context) {
|
|
171
|
-
return autoVerdict(bot, tool, summary, context).approve;
|
|
172
|
-
}
|
|
173
|
-
/** The note a card shows above its buttons, explaining why the bot stopped
|
|
174
|
-
* rather than answering for itself.
|
|
175
|
-
*
|
|
176
|
-
* The unattended case is the one users misread. A turn a webhook or another
|
|
177
|
-
* bot started never runs Auto at all — approvalModeForTurn downgrades it to
|
|
178
|
-
* Ask before the provider spawns — so "this action needs you" would name the
|
|
179
|
-
* wrong cause and imply the next action might pass. It will not: with a fleet
|
|
180
|
-
* delegating between bots, every card looks like this until someone answers.
|
|
181
|
-
* Say that plainly, and name the mode that keeps running. */
|
|
182
|
-
export function approvalHeldReason(context) {
|
|
183
|
-
const key = approvalHeldNote(context);
|
|
184
|
-
return key && HELD_NOTE[key];
|
|
185
|
-
}
|
|
186
32
|
/** Every fixed note a held card can show, by catalog key.
|
|
187
33
|
*
|
|
188
34
|
* The card is the last thing between a bot and someone's filesystem, so the
|
|
189
35
|
* one line explaining why it stopped should not be the one line still in
|
|
190
36
|
* English. The client translates by key and falls back to this text, which
|
|
191
37
|
* the server keeps sending: cards saved before the key existed still render,
|
|
192
|
-
* and so do the free-text apply errors that have no key at all.
|
|
193
|
-
*
|
|
194
|
-
* The unattended hint is a whole second sentence rather than a suffix. A
|
|
195
|
-
* translator needs the sentence, not two halves to reassemble. */
|
|
38
|
+
* and so do the free-text apply errors that have no key at all. */
|
|
196
39
|
export const HELD_NOTE = {
|
|
197
40
|
"approval.held.native": "The provider requires your approval for this action.",
|
|
198
41
|
"approval.held.sandbox": "This changes the provider sandbox, so only Full access can approve it automatically.",
|
|
199
|
-
"approval.held.localComputer": "Controlling your computer is never covered by Always allow, so this needs you.",
|
|
200
|
-
"approval.held.unattended": "A webhook or another bot started this turn, so Approve for me is paused and every action asks.",
|
|
201
|
-
"approval.held.unattendedFullAccess": "A webhook or another bot started this turn, so Approve for me is paused and every action asks. Full access keeps working unattended.",
|
|
202
|
-
"approval.held.destructive": "This looks destructive, so Approve for me stopped to ask.",
|
|
203
|
-
"approval.held.sensitive": "This touches credentials, so Approve for me stopped to ask.",
|
|
204
|
-
"approval.held.needsYou": "This action needs you, so Approve for me stopped to ask.",
|
|
205
42
|
"approval.held.undeliveredFull": "Full access couldn't deliver this approval.",
|
|
206
43
|
"approval.held.undelivered": "Approve for me couldn't answer this one.",
|
|
207
44
|
};
|
|
208
45
|
/** Which note, as a key. approvalHeldReason is this plus the English text, so
|
|
209
46
|
* the branching that decides the note lives in exactly one place. */
|
|
210
47
|
export function approvalHeldNote(context) {
|
|
211
|
-
if (context.source === "native-approval")
|
|
212
|
-
return "approval.held.native";
|
|
213
48
|
if (!context.permission)
|
|
214
49
|
return undefined;
|
|
215
|
-
if (context.
|
|
50
|
+
if (context.source === "explicit-approval-block")
|
|
216
51
|
return "approval.held.sandbox";
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
? "approval.held.unattendedFullAccess"
|
|
227
|
-
: "approval.held.unattended";
|
|
228
|
-
}
|
|
229
|
-
// A guard names itself. Both stop the same mode, but one is about damage
|
|
230
|
-
// and the other about secrets, and a read-only .env card that says
|
|
231
|
-
// "destructive" teaches people to stop reading these.
|
|
232
|
-
if (context.source === "destructive-guard")
|
|
233
|
-
return "approval.held.destructive";
|
|
234
|
-
if (context.source === "sensitive-guard")
|
|
235
|
-
return "approval.held.sensitive";
|
|
236
|
-
return "approval.held.needsYou";
|
|
52
|
+
if (context.source === "native-approval")
|
|
53
|
+
return "approval.held.native";
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** The note a card shows above its buttons, explaining why the bot stopped
|
|
57
|
+
* rather than answering for itself. */
|
|
58
|
+
export function approvalHeldReason(context) {
|
|
59
|
+
const key = approvalHeldNote(context);
|
|
60
|
+
return key && HELD_NOTE[key];
|
|
237
61
|
}
|
|
@@ -6,7 +6,20 @@
|
|
|
6
6
|
// operation settles so the current responder cannot appear to answer words it
|
|
7
7
|
// never saw.
|
|
8
8
|
import { newId } from "./contracts.js";
|
|
9
|
+
import { chatFollowups, saveChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
9
10
|
const queues = new Map(); // threadId -> waiting sends
|
|
11
|
+
export function restoreChannelMessages() {
|
|
12
|
+
queues.clear();
|
|
13
|
+
for (const row of chatFollowups("channel")) {
|
|
14
|
+
if (row.status !== "pending")
|
|
15
|
+
continue;
|
|
16
|
+
const entry = queues.get(row.threadId) ?? { groupId: row.ownerId, items: [] };
|
|
17
|
+
if (entry.groupId !== row.ownerId)
|
|
18
|
+
throw new Error("queued task belongs to another channel");
|
|
19
|
+
entry.items.push({ ...row.payload, id: row.id, mode: row.payload.mode ?? "chat" });
|
|
20
|
+
queues.set(row.threadId, entry);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
10
23
|
export function queueChannelMessage(groupId, threadId, text, options = {}) {
|
|
11
24
|
const entry = queues.get(threadId) ?? { groupId, items: [] };
|
|
12
25
|
if (entry.groupId !== groupId)
|
|
@@ -19,6 +32,7 @@ export function queueChannelMessage(groupId, threadId, text, options = {}) {
|
|
|
19
32
|
mode: options.mode ?? "chat",
|
|
20
33
|
via: options.via,
|
|
21
34
|
};
|
|
35
|
+
saveChatFollowup({ id: item.id, kind: "channel", ownerId: groupId, threadId, payload: item });
|
|
22
36
|
entry.items.push(item);
|
|
23
37
|
queues.set(threadId, entry);
|
|
24
38
|
return { id: item.id };
|
|
@@ -38,6 +52,7 @@ export function cancelChannelMessage(groupId, queueId) {
|
|
|
38
52
|
const items = entry.items.filter((item) => item.id !== queueId);
|
|
39
53
|
if (items.length === entry.items.length)
|
|
40
54
|
continue;
|
|
55
|
+
settleChatFollowups([queueId], "cancelled");
|
|
41
56
|
if (items.length === 0)
|
|
42
57
|
queues.delete(threadId);
|
|
43
58
|
else
|
|
@@ -55,14 +70,17 @@ export function drainChannelMessages(isWorking, run) {
|
|
|
55
70
|
for (const [threadId, entry] of queues) {
|
|
56
71
|
if (isWorking(entry.groupId))
|
|
57
72
|
continue;
|
|
58
|
-
const item = entry.items
|
|
73
|
+
const item = entry.items[0];
|
|
59
74
|
if (!item) {
|
|
60
75
|
queues.delete(threadId);
|
|
61
76
|
continue;
|
|
62
77
|
}
|
|
78
|
+
settleChatFollowups([item.id], "dispatching");
|
|
79
|
+
entry.items.shift();
|
|
63
80
|
if (entry.items.length === 0)
|
|
64
81
|
queues.delete(threadId);
|
|
65
|
-
run({ ...item, groupId: entry.groupId, threadId });
|
|
82
|
+
const running = run({ ...item, groupId: entry.groupId, threadId });
|
|
83
|
+
void Promise.resolve(running).then(() => settleChatFollowups([item.id], null), () => settleChatFollowups([item.id], "interrupted")).catch((error) => console.warn("channel-queue: could not settle durable follow-up", error));
|
|
66
84
|
}
|
|
67
85
|
}
|
|
68
86
|
/** Test helper. */
|
|
@@ -209,6 +209,18 @@ const featureConfigSchema = z.object({
|
|
|
209
209
|
* also has its own switch. */
|
|
210
210
|
browser: z.boolean().optional(),
|
|
211
211
|
});
|
|
212
|
+
/** First-run progress. Kept in the workspace config rather than a browser so
|
|
213
|
+
* it survives cleared site data and is shared by every paired client. Hint
|
|
214
|
+
* ids are short renderer-chosen slugs; the list is capped so a buggy client
|
|
215
|
+
* cannot grow the file without bound. */
|
|
216
|
+
const onboardingConfigSchema = z.object({
|
|
217
|
+
/** ISO timestamp of finishing (or skipping to the end of) the welcome flow. */
|
|
218
|
+
completedAt: z.string().trim().max(40).optional(),
|
|
219
|
+
/** Which welcome flow was completed; a newer flow may re-show itself. */
|
|
220
|
+
version: z.number().int().min(0).max(1000).optional(),
|
|
221
|
+
reelSeen: z.boolean().optional(),
|
|
222
|
+
hintsSeen: z.array(z.string().trim().min(1).max(60)).max(100).optional(),
|
|
223
|
+
}).strict();
|
|
212
224
|
const instanceConfigSchema = z.object({
|
|
213
225
|
driver: z.string().min(1),
|
|
214
226
|
displayName: optionalText,
|
|
@@ -306,6 +318,7 @@ const appConfigSchema = z.object({
|
|
|
306
318
|
threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
|
|
307
319
|
localVm: localVmConfigSchema.optional(),
|
|
308
320
|
features: featureConfigSchema.optional(),
|
|
321
|
+
onboarding: onboardingConfigSchema.optional(),
|
|
309
322
|
browserProfiles: browserProfilesSchema.optional(),
|
|
310
323
|
instances: instanceConfigMapSchema.optional(),
|
|
311
324
|
/** User-configured MCP servers, mounted into every capable engine. Kept
|
|
@@ -422,6 +435,28 @@ export function showToolCallsEnabled(cfg) {
|
|
|
422
435
|
export function builtInBrowserEnabled(cfg) {
|
|
423
436
|
return cfg.features?.browser === true;
|
|
424
437
|
}
|
|
438
|
+
/** Config sections no provider driver reads. A write that touches only
|
|
439
|
+
* these must not rebuild the fleet: rebuilding disposes every engine child
|
|
440
|
+
* and reloads it, seconds of work that would also interrupt in-flight
|
|
441
|
+
* turns. The guided tour writes `onboarding` on every step, so it in
|
|
442
|
+
* particular has to stay cheap. */
|
|
443
|
+
export const FLEET_NEUTRAL_KEYS = new Set([
|
|
444
|
+
"profile",
|
|
445
|
+
"language",
|
|
446
|
+
"tts",
|
|
447
|
+
"imageGen",
|
|
448
|
+
"vps",
|
|
449
|
+
"rooms",
|
|
450
|
+
"threads",
|
|
451
|
+
"localVm",
|
|
452
|
+
"features",
|
|
453
|
+
"browserProfiles",
|
|
454
|
+
"onboarding",
|
|
455
|
+
]);
|
|
456
|
+
/** The keys of a config patch that require the provider fleet to reload. */
|
|
457
|
+
export function providerReloadKeys(patch) {
|
|
458
|
+
return Object.keys(patch).filter((key) => !FLEET_NEUTRAL_KEYS.has(key));
|
|
459
|
+
}
|
|
425
460
|
// OMB_DATA_DIR isolates test/soak rigs from the user's real fleet.
|
|
426
461
|
export const DATA_DIR = process.env.OMB_DATA_DIR ?? join(homedir(), ".openmausbot");
|
|
427
462
|
const LEGACY_DATA_DIR = join(homedir(), ".opengrokbot");
|
|
@@ -648,7 +683,7 @@ export function saveConfig(patch, options = {}) {
|
|
|
648
683
|
const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
|
|
649
684
|
if (storedProfiles.success)
|
|
650
685
|
disk.browserProfiles = storedProfiles.data;
|
|
651
|
-
for (const key of ["xai", "anthropic", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features", "budgets", "billing"]) {
|
|
686
|
+
for (const key of ["xai", "anthropic", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features", "budgets", "billing", "onboarding"]) {
|
|
652
687
|
const section = checkedPatch[key];
|
|
653
688
|
if (!section)
|
|
654
689
|
continue;
|
|
@@ -101,6 +101,28 @@ function decodeAcpConfig(defaultCli) {
|
|
|
101
101
|
* ACP JSON-RPC-over-stdio driver. Harness differences (argv, auth, catalog)
|
|
102
102
|
* live in `support`; this is the shared handshake and turn runtime.
|
|
103
103
|
*/
|
|
104
|
+
/** What "the same operation" means for a remembered session allow: the
|
|
105
|
+
* tool call's shape with keys sorted, so two identical requests key alike
|
|
106
|
+
* however the agent ordered its JSON. null when nothing identifies it. */
|
|
107
|
+
function sessionOperationKey(toolCall) {
|
|
108
|
+
const rawInput = toolCall?.rawInput;
|
|
109
|
+
const command = typeof rawInput?.command === "string" ? rawInput.command : undefined;
|
|
110
|
+
const hasInput = rawInput && typeof rawInput === "object" && Object.keys(rawInput).length > 0;
|
|
111
|
+
if (!command && !hasInput)
|
|
112
|
+
return null;
|
|
113
|
+
const stable = (value) => Array.isArray(value)
|
|
114
|
+
? value.map(stable)
|
|
115
|
+
: value && typeof value === "object"
|
|
116
|
+
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]))
|
|
117
|
+
: value;
|
|
118
|
+
return JSON.stringify(stable({
|
|
119
|
+
kind: toolCall?.kind,
|
|
120
|
+
title: toolCall?.title,
|
|
121
|
+
command,
|
|
122
|
+
input: rawInput,
|
|
123
|
+
locations: toolCall?.locations,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
104
126
|
export function createAcpDriver(support) {
|
|
105
127
|
const DRIVER_KIND = support.driverKind;
|
|
106
128
|
const SOURCE = support.nativeSource;
|
|
@@ -155,6 +177,13 @@ export function createAcpDriver(support) {
|
|
|
155
177
|
await refreshModels();
|
|
156
178
|
const listeners = new Set();
|
|
157
179
|
const active = new Map();
|
|
180
|
+
// "Always allow this session", remembered by the driver when the agent
|
|
181
|
+
// offered no `allow_always` of its own: the exact operations (kind,
|
|
182
|
+
// title, command, input, locations) a person allowed for the session,
|
|
183
|
+
// per thread. A repeat is answered the way they did, once, for as long
|
|
184
|
+
// as the native session lasts. A generic title with no input identifies
|
|
185
|
+
// nothing and is never remembered.
|
|
186
|
+
const sessionAllows = new Map();
|
|
158
187
|
const emit = (event) => {
|
|
159
188
|
for (const listener of listeners)
|
|
160
189
|
listener(event);
|
|
@@ -455,6 +484,7 @@ export function createAcpDriver(support) {
|
|
|
455
484
|
const optionFor = (want) => options.find((o) => o.kind === `${want}_once` && typeof o.optionId === "string")?.optionId
|
|
456
485
|
?? options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId
|
|
457
486
|
?? null;
|
|
487
|
+
const optionAlways = options.find((o) => o.kind === "allow_always" && typeof o.optionId === "string")?.optionId ?? null;
|
|
458
488
|
const cancelled = { outcome: { outcome: "cancelled" } };
|
|
459
489
|
const missing = (want) => emit({
|
|
460
490
|
...base(threadId, turnId),
|
|
@@ -474,14 +504,23 @@ export function createAcpDriver(support) {
|
|
|
474
504
|
});
|
|
475
505
|
}
|
|
476
506
|
const kind = String(toolCall.kind ?? "");
|
|
507
|
+
// an earlier "Always allow this session" on this exact operation
|
|
508
|
+
const operationKey = isQuestion || controlsHost ? null : sessionOperationKey(toolCall);
|
|
509
|
+
if (operationKey && sessionAllows.get(threadId)?.has(operationKey)) {
|
|
510
|
+
const allow = optionFor("allow");
|
|
511
|
+
if (allow) {
|
|
512
|
+
return send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "selected", optionId: allow } } });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
477
515
|
const tool = kind === "execute" ? "shell" : kind === "edit" ? "edit" : kind || "tool";
|
|
478
516
|
const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
|
|
479
517
|
const requestId = newId();
|
|
480
|
-
const finish = (behavior, source = "user", message) => {
|
|
518
|
+
const finish = (behavior, source = "user", message, always) => {
|
|
481
519
|
if (!asks.delete(requestId))
|
|
482
520
|
return;
|
|
483
521
|
clearTimeout(timer);
|
|
484
522
|
const want = behavior === "allow" ? "allow" : "reject";
|
|
523
|
+
const forSession = want === "allow" && always === true && !isQuestion && !controlsHost;
|
|
485
524
|
const named = isQuestion && behavior === "answer"
|
|
486
525
|
? options.filter((option) => option.optionId === message || option.name?.trim() === message)
|
|
487
526
|
: [];
|
|
@@ -489,7 +528,16 @@ export function createAcpDriver(support) {
|
|
|
489
528
|
? null
|
|
490
529
|
: isQuestion
|
|
491
530
|
? named.length === 1 && typeof named[0].optionId === "string" ? named[0].optionId : null
|
|
492
|
-
:
|
|
531
|
+
: forSession
|
|
532
|
+
? optionAlways ?? optionFor("allow")
|
|
533
|
+
: optionFor(want);
|
|
534
|
+
// the agent keeps its own allow_always; when it offered none, the
|
|
535
|
+
// driver keeps the operation for the session instead
|
|
536
|
+
if (forSession && !optionAlways && optionId && operationKey) {
|
|
537
|
+
const remembered = sessionAllows.get(threadId) ?? new Set();
|
|
538
|
+
remembered.add(operationKey);
|
|
539
|
+
sessionAllows.set(threadId, remembered);
|
|
540
|
+
}
|
|
493
541
|
if (behavior !== "cancel" && !optionId)
|
|
494
542
|
missing(isQuestion ? "matching answer" : want);
|
|
495
543
|
send({
|
|
@@ -523,6 +571,8 @@ export function createAcpDriver(support) {
|
|
|
523
571
|
? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
|
|
524
572
|
: undefined,
|
|
525
573
|
approvalScope: controlsHost ? "local-computer" : undefined,
|
|
574
|
+
// the driver can honor a session-wide allow either way
|
|
575
|
+
allowSession: !isQuestion && !controlsHost ? true : undefined,
|
|
526
576
|
});
|
|
527
577
|
};
|
|
528
578
|
const handleNotification = (msg) => {
|
|
@@ -700,6 +750,9 @@ export function createAcpDriver(support) {
|
|
|
700
750
|
throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
|
|
701
751
|
}
|
|
702
752
|
const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
|
|
753
|
+
// a fresh native session forgets what the previous one allowed
|
|
754
|
+
if (!cursor)
|
|
755
|
+
sessionAllows.delete(threadId);
|
|
703
756
|
let sessionResult = null;
|
|
704
757
|
if (cursor) {
|
|
705
758
|
try {
|
|
@@ -878,7 +931,7 @@ export function createAcpDriver(support) {
|
|
|
878
931
|
const finish = turn?.asks.get(requestId);
|
|
879
932
|
if (!finish)
|
|
880
933
|
return "unavailable"; // settled, timed out, or turn gone
|
|
881
|
-
finish(decision.behavior, "user", decision.message);
|
|
934
|
+
finish(decision.behavior, "user", decision.message, decision.always === true);
|
|
882
935
|
return decision.behavior === "allow"
|
|
883
936
|
? "allowed-once"
|
|
884
937
|
: decision.behavior === "answer"
|
|
@@ -214,7 +214,9 @@ const support = {
|
|
|
214
214
|
// crates/codegen/xai-grok-pager-bin/src/main.rs:1259-1273.
|
|
215
215
|
spawnArgs: (config, turn) => [
|
|
216
216
|
"--permission-mode",
|
|
217
|
-
config.fullAuto
|
|
217
|
+
config.fullAuto
|
|
218
|
+
? "bypassPermissions"
|
|
219
|
+
: turn.approvalMode === "auto" ? "auto" : turn.approvalMode === "edits" ? "acceptEdits" : "default",
|
|
218
220
|
"agent",
|
|
219
221
|
...(turn.model ? ["-m", turn.model] : []),
|
|
220
222
|
// long form on purpose: `--effort` is documented as an alias, and an
|
|
@@ -531,7 +531,7 @@ const TOOLS = [
|
|
|
531
531
|
additionalProperties: false,
|
|
532
532
|
properties: {
|
|
533
533
|
action: { type: "string", enum: ["append", "replace", "remove", "supersede"] },
|
|
534
|
-
text: { type: "string", minLength: 1,
|
|
534
|
+
text: { type: "string", minLength: 1, description: "Non-blank new text for append, replace, or supersede: the fact itself, without a date or bullet. Omit for remove; use remove to delete a passage." },
|
|
535
535
|
old_text: { type: "string", minLength: 1, description: "Exact unique existing passage for replace, supersede, or remove. Omit for append." },
|
|
536
536
|
},
|
|
537
537
|
required: ["action"],
|
|
@@ -544,7 +544,7 @@ const TOOLS = [
|
|
|
544
544
|
type: "object",
|
|
545
545
|
additionalProperties: false,
|
|
546
546
|
properties: {
|
|
547
|
-
text: { type: "string", minLength: 1,
|
|
547
|
+
text: { type: "string", minLength: 1, description: "One line about what happened, in plain words." },
|
|
548
548
|
},
|
|
549
549
|
required: ["text"],
|
|
550
550
|
},
|
|
@@ -148,6 +148,7 @@ export class AntigravityAcpClient {
|
|
|
148
148
|
startupDiagnosticBytes = 0;
|
|
149
149
|
nativeStartupHint;
|
|
150
150
|
closed = false;
|
|
151
|
+
stopping;
|
|
151
152
|
onAuthorizationUrl;
|
|
152
153
|
/** Settles once the runtime process is gone. On Windows a running
|
|
153
154
|
* executable pins its file and its directory, so an installer must not
|
|
@@ -291,10 +292,10 @@ export class AntigravityAcpClient {
|
|
|
291
292
|
return;
|
|
292
293
|
this.closed = true;
|
|
293
294
|
this.failAll(new Error("Antigravity ACP was closed."));
|
|
294
|
-
killCliTree(this.child);
|
|
295
|
+
this.stopping = killCliTree(this.child);
|
|
295
296
|
}
|
|
296
|
-
/**
|
|
297
|
-
async closeAndWait(timeoutMs =
|
|
297
|
+
/** Allow the shared 5s TERM grace and 1s force-stop verification to finish. */
|
|
298
|
+
async closeAndWait(timeoutMs = 7_000) {
|
|
298
299
|
this.close();
|
|
299
300
|
let timer;
|
|
300
301
|
const timedOut = new Promise((resolve) => {
|
|
@@ -302,7 +303,7 @@ export class AntigravityAcpClient {
|
|
|
302
303
|
timer.unref?.();
|
|
303
304
|
});
|
|
304
305
|
try {
|
|
305
|
-
return await Promise.race([this.
|
|
306
|
+
return await Promise.race([this.stopping, timedOut]);
|
|
306
307
|
}
|
|
307
308
|
finally {
|
|
308
309
|
clearTimeout(timer);
|
|
@@ -11,8 +11,13 @@ export const STATIC_ANTIGRAVITY_MODELS = {
|
|
|
11
11
|
{ id: "gemini-3.8-flash-low", label: "Gemini 3.8 Flash (Low)" },
|
|
12
12
|
],
|
|
13
13
|
};
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** Antigravity's own session modes: `yolo` for Full access, `auto_edit` for
|
|
15
|
+
* auto-accept edits, `default` otherwise (Ask, and Auto, which it has no
|
|
16
|
+
* reviewer for). */
|
|
17
|
+
export function antigravityPermissionMode(fullAuto, approvalMode) {
|
|
18
|
+
if (fullAuto)
|
|
19
|
+
return "yolo";
|
|
20
|
+
return approvalMode === "edits" ? "auto_edit" : "default";
|
|
16
21
|
}
|
|
17
22
|
export function antigravityModelsFromSession(value) {
|
|
18
23
|
return catalogFromAntigravityConfigOptions(value, STATIC_ANTIGRAVITY_MODELS.default);
|
|
@@ -88,8 +93,8 @@ const support = {
|
|
|
88
93
|
return { state: "unavailable", reason: error instanceof Error ? error.message : String(error) };
|
|
89
94
|
}
|
|
90
95
|
},
|
|
91
|
-
configureSession: async ({ request, sessionId, config }) => {
|
|
92
|
-
const wanted = antigravityPermissionMode(config.fullAuto);
|
|
96
|
+
configureSession: async ({ request, sessionId, config, turn }) => {
|
|
97
|
+
const wanted = antigravityPermissionMode(config.fullAuto, turn.approvalMode);
|
|
93
98
|
const result = await request("session/set_config_option", {
|
|
94
99
|
sessionId,
|
|
95
100
|
configId: "mode",
|