openmausbot 0.1.73 → 0.1.75
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-BYbK7ber.js → index-BTlyRwLl.js} +1 -1
- package/dist/assets/index-CeMbvsgx.js +305 -0
- package/dist/assets/index-DH7Zax6L.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +9 -0
- package/dist-server/index.js +1402 -1048
- package/dist-server/mcp-gate.js +58 -16
- package/dist-server/openmausbot.js +543 -271
- package/dist-server/pair-cli.js +543 -271
- package/dist-server/permission-proxy.js +119 -14
- package/dist-server/server/auto-approve.js +44 -205
- 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 +58 -4
- package/dist-server/server/drivers/acp/grok.js +15 -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 +113 -13
- package/dist-server/server/drivers/codex.js +37 -15
- package/dist-server/server/drivers/retry.js +4 -0
- package/dist-server/server/engine-install.js +9 -5
- package/dist-server/server/index.js +265 -233
- package/dist-server/server/message-db.js +70 -3
- package/dist-server/server/permission-proxy.js +90 -15
- package/dist-server/server/procs.js +82 -23
- package/dist-server/server/routines.js +67 -8
- package/dist-server/server/setup-mode.js +6 -7
- package/dist-server/server/steer-queue.js +27 -7
- package/dist-server/server/store.js +19 -0
- package/dist-server/server/team-manifest.js +1 -1
- package/dist-server/server/thread-events.js +10 -1
- package/dist-server/server/webhooks.js +4 -1
- package/dist-server/server/workspace-backup.js +24 -9
- package/dist-server/server/workspace.js +13 -0
- package/dist-server/shared/approval-mode.js +11 -10
- package/dist-server/shared/ask-question.js +172 -0
- package/dist-server/shared/provider-safety.js +6 -0
- package/dist-server/vps-container-mcp.js +9 -0
- package/package.json +1 -1
- package/dist/assets/index-BLJ7byG3.js +0 -296
- package/dist/assets/index-De2TRLxu.css +0 -1
- package/dist-server/server/auto-review.js +0 -79
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
// server/permission-proxy.ts
|
|
2
2
|
import { connect } from "node:net";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
// shared/ask-question.ts
|
|
6
|
+
var MAX_QUESTIONS = 6;
|
|
7
|
+
var MAX_OPTIONS = 12;
|
|
8
|
+
var MAX_QUESTION_TEXT = 400;
|
|
9
|
+
var MAX_LABEL = 120;
|
|
10
|
+
var MAX_DESCRIPTION = 400;
|
|
11
|
+
function text(value, limit) {
|
|
12
|
+
if (typeof value !== "string") return void 0;
|
|
13
|
+
const trimmed = value.trim();
|
|
14
|
+
return trimmed ? trimmed.slice(0, limit) : void 0;
|
|
15
|
+
}
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function parseOption(value) {
|
|
20
|
+
if (typeof value === "string") {
|
|
21
|
+
const label2 = text(value, MAX_LABEL);
|
|
22
|
+
return label2 ? { label: label2 } : null;
|
|
23
|
+
}
|
|
24
|
+
if (!isRecord(value)) return null;
|
|
25
|
+
const label = text(value.label, MAX_LABEL);
|
|
26
|
+
if (!label) return null;
|
|
27
|
+
const description = text(value.description, MAX_DESCRIPTION);
|
|
28
|
+
return description ? { label, description } : { label };
|
|
29
|
+
}
|
|
30
|
+
function parseQuestion(value) {
|
|
31
|
+
if (!isRecord(value)) return null;
|
|
32
|
+
const question = text(value.question, MAX_QUESTION_TEXT);
|
|
33
|
+
if (!question) return null;
|
|
34
|
+
const options = [];
|
|
35
|
+
const seen = /* @__PURE__ */ new Set();
|
|
36
|
+
for (const raw of Array.isArray(value.options) ? value.options : []) {
|
|
37
|
+
const option = parseOption(raw);
|
|
38
|
+
if (!option || seen.has(option.label)) continue;
|
|
39
|
+
seen.add(option.label);
|
|
40
|
+
options.push(option);
|
|
41
|
+
if (options.length === MAX_OPTIONS) break;
|
|
42
|
+
}
|
|
43
|
+
const header = text(value.header, MAX_LABEL);
|
|
44
|
+
return {
|
|
45
|
+
question,
|
|
46
|
+
...header ? { header } : {},
|
|
47
|
+
...value.multiSelect === true ? { multiSelect: true } : {},
|
|
48
|
+
options
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function parseAskQuestions(input) {
|
|
52
|
+
if (!isRecord(input) || !Array.isArray(input.questions)) return null;
|
|
53
|
+
const questions = [];
|
|
54
|
+
for (const raw of input.questions) {
|
|
55
|
+
const question = parseQuestion(raw);
|
|
56
|
+
if (!question) continue;
|
|
57
|
+
questions.push(question);
|
|
58
|
+
if (questions.length === MAX_QUESTIONS) break;
|
|
59
|
+
}
|
|
60
|
+
return questions.length ? questions : null;
|
|
61
|
+
}
|
|
62
|
+
function questionAnswersByQuestion(message, questions) {
|
|
63
|
+
const answers = {};
|
|
64
|
+
const known = new Map(questions.map((entry) => [entry.question, entry.question]));
|
|
65
|
+
for (const block of message.split("\n\n")) {
|
|
66
|
+
const match = /^Q: ([\s\S]+?)\nA: ([\s\S]+)$/.exec(block.trim());
|
|
67
|
+
if (!match) continue;
|
|
68
|
+
const question = known.get(match[1].trim());
|
|
69
|
+
if (question) answers[question] = match[2].trim();
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(answers).length) return answers;
|
|
72
|
+
const only = questions.length === 1 ? questions[0] : void 0;
|
|
73
|
+
const flat = message.trim();
|
|
74
|
+
return only && flat ? { [only.question]: flat } : {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// server/permission-proxy.ts
|
|
4
78
|
var socketPath = process.argv[2] ?? "";
|
|
5
79
|
var waiting = /* @__PURE__ */ new Map();
|
|
6
80
|
var conn = connect(socketPath);
|
|
@@ -32,6 +106,40 @@ conn.on("data", (chunk) => {
|
|
|
32
106
|
}
|
|
33
107
|
});
|
|
34
108
|
var send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
|
109
|
+
function askBroker(ask) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
waiting.set(String(ask.id), resolve);
|
|
112
|
+
if (conn.destroyed) return dead();
|
|
113
|
+
try {
|
|
114
|
+
conn.write(JSON.stringify(ask) + "\n");
|
|
115
|
+
} catch {
|
|
116
|
+
dead();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
var ASK_USER_QUESTION = "AskUserQuestion";
|
|
121
|
+
var asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
122
|
+
async function answerNativeQuestions(input) {
|
|
123
|
+
const questions = parseAskQuestions(input);
|
|
124
|
+
if (!questions?.length) {
|
|
125
|
+
return JSON.stringify({
|
|
126
|
+
behavior: "deny",
|
|
127
|
+
message: "OpenMausBot: this AskUserQuestion call had no answerable question (each one needs question text), so nobody was shown it. Ask again with a well-formed call, or continue without it."
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const answer = await askBroker({
|
|
131
|
+
t: "ask",
|
|
132
|
+
id: randomUUID(),
|
|
133
|
+
kind: "question",
|
|
134
|
+
tool: ASK_USER_QUESTION,
|
|
135
|
+
input: { questions }
|
|
136
|
+
});
|
|
137
|
+
if (answer.behavior === "deny") {
|
|
138
|
+
return JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
|
|
139
|
+
}
|
|
140
|
+
const answers = answer.source === "user" && typeof answer.message === "string" ? questionAnswersByQuestion(answer.message, questions) : {};
|
|
141
|
+
return JSON.stringify({ behavior: "allow", updatedInput: { ...asRecord(input), answers } });
|
|
142
|
+
}
|
|
35
143
|
var TOOLS = [
|
|
36
144
|
{
|
|
37
145
|
name: "approve",
|
|
@@ -79,30 +187,27 @@ async function handle(msg) {
|
|
|
79
187
|
if (msg.method === "tools/call") {
|
|
80
188
|
const name = msg.params?.name;
|
|
81
189
|
const args = msg.params?.arguments ?? {};
|
|
190
|
+
const reply = (text3) => send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: text3 }] } });
|
|
191
|
+
if (name === "approve" && args.tool_name === ASK_USER_QUESTION) {
|
|
192
|
+
return reply(await answerNativeQuestions(args.input));
|
|
193
|
+
}
|
|
82
194
|
const askId = randomUUID();
|
|
83
195
|
const isQuestion = name === "ask_user";
|
|
84
196
|
const suggestions = Array.isArray(args.permission_suggestions) ? args.permission_suggestions : Array.isArray(args.suggestions) ? args.suggestions : null;
|
|
85
|
-
const answer = await
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
conn.write(JSON.stringify(ask) + "\n");
|
|
91
|
-
} catch {
|
|
92
|
-
dead();
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
let text = answer.message || "No answer was given \u2014 use your best judgment.";
|
|
197
|
+
const answer = await askBroker(
|
|
198
|
+
isQuestion ? { t: "ask", id: askId, kind: "question", tool: "ask_user", input: { question: args.question, choices: args.choices } } : { t: "ask", id: askId, tool: args.tool_name, input: args.input }
|
|
199
|
+
);
|
|
200
|
+
let text2 = answer.message || "No answer was given \u2014 use your best judgment.";
|
|
96
201
|
if (!isQuestion) {
|
|
97
202
|
if (answer.behavior === "allow") {
|
|
98
203
|
const result = { behavior: "allow", updatedInput: args.input ?? {} };
|
|
99
204
|
if (answer.always && suggestions) result.updatedPermissions = suggestions;
|
|
100
|
-
|
|
205
|
+
text2 = JSON.stringify(result);
|
|
101
206
|
} else {
|
|
102
|
-
|
|
207
|
+
text2 = JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
|
|
103
208
|
}
|
|
104
209
|
}
|
|
105
|
-
return
|
|
210
|
+
return reply(text2);
|
|
106
211
|
}
|
|
107
212
|
if (String(msg.method ?? "").startsWith("notifications/")) return;
|
|
108
213
|
if (msg.id != null) {
|
|
@@ -1,237 +1,76 @@
|
|
|
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
|
-
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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) {
|
|
115
|
-
return { approve: null, source: "explicit-approval-block" };
|
|
116
|
-
}
|
|
117
|
-
// the guards outrank the grants, so an "always allow" can never widen
|
|
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 };
|
|
20
|
+
// Tools that ask a PERSON something. A question exists so that a human
|
|
21
|
+
// decides; any mode answering one on their behalf defeats the only reason
|
|
22
|
+
// it was asked. They normally arrive typed as questions and never reach a
|
|
23
|
+
// verdict at all — this is the backstop for the path where one arrives
|
|
24
|
+
// mis-typed as a permission (a malformed AskUserQuestion call falls back to
|
|
25
|
+
// the permission path in permission-proxy). Approving it there does not
|
|
26
|
+
// produce an answer: the CLI runs the tool with none and the model is told
|
|
27
|
+
// "The user did not answer the questions." — a question silently lost.
|
|
28
|
+
const ASKS_A_PERSON = new Set(["askuserquestion", "ask_user"]);
|
|
29
|
+
export function autoVerdict(mode, tool, context) {
|
|
30
|
+
// A question is for a person, whatever channel it arrived on — and
|
|
31
|
+
// whatever the mode: even Full has no answer to give, only an approval
|
|
32
|
+
// that would run the tool with none.
|
|
33
|
+
if (ASKS_A_PERSON.has(tool.replace(/^mcp__[^_]+__/, "").toLowerCase())) {
|
|
147
34
|
return { approve: null, source: "no-grant" };
|
|
148
35
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 };
|
|
36
|
+
// Full's promise is literal: even a sandbox widening is approved. Entering
|
|
37
|
+
// Full is separately consent-gated by the bot PATCH endpoint, and the
|
|
38
|
+
// request.opened caller invokes this for permissions only, never questions.
|
|
39
|
+
if (mode === "full")
|
|
40
|
+
return { approve: `approved ${tool} (full access)`, source: "full-access" };
|
|
41
|
+
if (context?.requiresExplicitApproval)
|
|
42
|
+
return { approve: null, source: "explicit-approval-block" };
|
|
43
|
+
if (mode === "auto" || mode === "custom")
|
|
44
|
+
return { approve: null, source: "native-approval" };
|
|
167
45
|
return { approve: null, source: "no-grant" };
|
|
168
46
|
}
|
|
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
47
|
/** Every fixed note a held card can show, by catalog key.
|
|
187
48
|
*
|
|
188
49
|
* The card is the last thing between a bot and someone's filesystem, so the
|
|
189
50
|
* one line explaining why it stopped should not be the one line still in
|
|
190
51
|
* English. The client translates by key and falls back to this text, which
|
|
191
52
|
* 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. */
|
|
53
|
+
* and so do the free-text apply errors that have no key at all. */
|
|
196
54
|
export const HELD_NOTE = {
|
|
197
55
|
"approval.held.native": "The provider requires your approval for this action.",
|
|
198
56
|
"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
57
|
"approval.held.undeliveredFull": "Full access couldn't deliver this approval.",
|
|
206
58
|
"approval.held.undelivered": "Approve for me couldn't answer this one.",
|
|
207
59
|
};
|
|
208
60
|
/** Which note, as a key. approvalHeldReason is this plus the English text, so
|
|
209
61
|
* the branching that decides the note lives in exactly one place. */
|
|
210
62
|
export function approvalHeldNote(context) {
|
|
211
|
-
if (context.source === "native-approval")
|
|
212
|
-
return "approval.held.native";
|
|
213
63
|
if (!context.permission)
|
|
214
64
|
return undefined;
|
|
215
|
-
if (context.
|
|
65
|
+
if (context.source === "explicit-approval-block")
|
|
216
66
|
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";
|
|
67
|
+
if (context.source === "native-approval")
|
|
68
|
+
return "approval.held.native";
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/** The note a card shows above its buttons, explaining why the bot stopped
|
|
72
|
+
* rather than answering for itself. */
|
|
73
|
+
export function approvalHeldReason(context) {
|
|
74
|
+
const key = approvalHeldNote(context);
|
|
75
|
+
return key && HELD_NOTE[key];
|
|
237
76
|
}
|
|
@@ -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;
|