@opencode/browse 0.0.6-dev.17 → 0.0.6-dev.19

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.
@@ -1,255 +1,958 @@
1
1
  import {
2
2
  dataRoot,
3
3
  cliCommand
4
- } from "./main-g5mke6rn.mjs";
4
+ } from "./main-yd50d95g.mjs";
5
+ import {
6
+ parseTargetSelection2,
7
+ getObject2,
8
+ getString2
9
+ } from "./main-8a245rm1.mjs";
10
+ import {
11
+ isNodeError,
12
+ writeJsonFileAtomically
13
+ } from "./main-75rdz6bc.mjs";
5
14
 
6
- // src/site-secrets.ts
7
- import crypto2 from "node:crypto";
8
- import fs3 from "node:fs/promises";
9
- import path3 from "node:path";
10
- import { Effect as Effect2, Schema as Schema2 } from "effect";
15
+ // src/relay/user-settings.ts
16
+ var settingKeys = ["window", "grouping", "idleMinutes", "cursor", "humanInput", "handoffNotifications", "autoUpdate"];
17
+ var relaySettingKeys = ["autoUpdate"];
18
+ var settingSpecs = {
19
+ window: { label: "Where agents open their tabs (current: the user's window; separate: an unfocused window of their own)", values: ["current", "separate"], variable: "OPENCODE_BROWSER_AGENT_WINDOW" },
20
+ grouping: { label: "How agent tabs are grouped (one group per session, one shared group, or none)", values: ["session", "shared", "none"] },
21
+ idleMinutes: { label: "Close agent tabs untouched for this many minutes", values: [10, 30, 60, "never"] },
22
+ cursor: { label: "Agent cursor on agent tabs outside recordings (pages can detect it)", values: ["off", "always"], variable: "OPENCODE_BROWSER_GHOST_CURSOR" },
23
+ humanInput: { label: "Human-like input (off is faster, but easier for sites to flag)", values: ["on", "off"], variable: "OPENCODE_BROWSER_HUMAN_INPUT" },
24
+ handoffNotifications: { label: "System notification on a handoff (only while recorded, or every handoff)", values: ["recording", "always"] },
25
+ autoUpdate: { label: "npm installs: install updates automatically when idle, only notify, or never check", values: ["on", "notify", "off"], variable: "OPENCODE_BROWSER_AUTO_UPDATE" }
26
+ };
27
+ var securityVariables = [
28
+ "OPENCODE_BROWSER_ALLOW_NODE",
29
+ "OPENCODE_BROWSER_ALLOW_ALL_COOKIES",
30
+ "OPENCODE_BROWSER_ALLOW_FOREGROUND",
31
+ "OPENCODE_BROWSER_ALLOW_ALL_STORAGE",
32
+ "OPENCODE_BROWSER_ALLOW_FILE_URLS",
33
+ "OPENCODE_BROWSER_UPDATE_CHECK"
34
+ ];
11
35
 
12
- // src/auth-profile.ts
13
- import { Effect, Schema, Semaphore } from "effect";
14
- import { spawn } from "node:child_process";
15
- import fs2 from "node:fs/promises";
16
- import path2 from "node:path";
36
+ class SettingsInputError extends Error {
37
+ }
38
+ var allowed = (key) => settingSpecs[key].values.map(String).join(", ");
39
+ function parseSettingChanges(input) {
40
+ if (input === null || typeof input !== "object" || Array.isArray(input))
41
+ throw new SettingsInputError('Pass the settings to change as an object, for example { grouping: "shared" }.');
42
+ const problems = [];
43
+ const changes = {};
44
+ for (const [key, raw] of Object.entries(input)) {
45
+ if (!settingKeys.includes(key)) {
46
+ problems.push(/allow|update|security|node|cookie|foreground|storage|file/i.test(key) ? `"${key}" is not a setting: security switches (${securityVariables.join(", ")}) are set only in the relay's environment, never here.` : `Unknown setting "${key}". Settings: ${settingKeys.join(", ")}.`);
47
+ continue;
48
+ }
49
+ const setting = key;
50
+ const value = setting === "idleMinutes" ? idleValue(raw) : raw;
51
+ if (value === undefined || !settingSpecs[setting].values.includes(value)) {
52
+ problems.push(`${setting} must be one of ${allowed(setting)} (got ${JSON.stringify(raw)}).`);
53
+ continue;
54
+ }
55
+ changes[setting] = value;
56
+ }
57
+ if (problems.length)
58
+ throw new SettingsInputError(problems.join(" "));
59
+ if (!Object.keys(changes).length)
60
+ throw new SettingsInputError(`Nothing to change. Settings: ${settingKeys.join(", ")}.`);
61
+ return changes;
62
+ }
63
+ function idleValue(raw) {
64
+ if (raw === "never" || raw === 0 || raw === "0")
65
+ return "never";
66
+ if (typeof raw === "number")
67
+ return raw;
68
+ if (typeof raw === "string" && /^\d+$/.test(raw))
69
+ return Number(raw);
70
+ return;
71
+ }
72
+ function settingsFromExtension(wire) {
73
+ const values = wire && typeof wire.settings === "object" && wire.settings !== null && !Array.isArray(wire.settings) ? wire.settings : undefined;
74
+ if (!values)
75
+ throw new Error("The extension answered settings without them");
76
+ const settings = {};
77
+ for (const key of settingKeys) {
78
+ const value = values[key];
79
+ if (relaySettingKeys.includes(key))
80
+ settings[key] = String(settingSpecs[key].values[0]);
81
+ else if (key === "idleMinutes")
82
+ settings[key] = value === 0 ? "never" : typeof value === "number" ? value : 10;
83
+ else
84
+ settings[key] = typeof value === "string" ? value : String(settingSpecs[key].values[0]);
85
+ }
86
+ return settings;
87
+ }
88
+ function settingsToExtension(changes) {
89
+ return Object.fromEntries(Object.entries(changes).filter(([key]) => !relaySettingKeys.includes(key)).map(([key, value]) => [key, key === "idleMinutes" && value === "never" ? 0 : value]));
90
+ }
91
+ function report(stored, overrides) {
92
+ return Object.fromEntries(settingKeys.map((key) => {
93
+ const override = overrides[key];
94
+ return [key, {
95
+ value: stored[key],
96
+ effective: override?.value ?? stored[key],
97
+ allowed: settingSpecs[key].values,
98
+ description: settingSpecs[key].label,
99
+ ...override ? { overriddenBy: override.variable } : {}
100
+ }];
101
+ }));
102
+ }
103
+ var securityNote = `Security switches (${securityVariables.join(", ")}) are environment-only and can't be read or changed here.`;
104
+ function describeSettings(stored, overrides) {
105
+ return { settings: report(stored, overrides), note: securityNote };
106
+ }
107
+ function describeChanges(options) {
108
+ const changed = [];
109
+ const unchanged = [];
110
+ const overridden = [];
111
+ for (const [key, value] of Object.entries(options.requested)) {
112
+ const override = options.overrides[key];
113
+ if (override)
114
+ overridden.push({ key, requested: value, variable: override.variable, effective: override.value });
115
+ else if (options.before[key] === options.after[key])
116
+ unchanged.push(key);
117
+ else
118
+ changed.push({ key, from: options.before[key], to: options.after[key] });
119
+ }
120
+ const lines = [];
121
+ if (changed.length)
122
+ lines.push(`Changed ${changed.map((item) => `${item.key} from ${item.from} to ${item.to}`).join(", ")}. Tell the user what you changed; they can change it back on the extension's options page.`);
123
+ if (unchanged.length)
124
+ lines.push(`Already set: ${unchanged.map((key) => `${key} is ${options.after[key]}`).join(", ")}.`);
125
+ for (const item of overridden)
126
+ lines.push(`Not changed: ${item.key} is fixed to ${item.effective} by ${item.variable} in the relay's environment, so this setting has no effect. Tell the user; to change it they remove or edit ${item.variable} where opencode starts, then run \`${cliCommand("relay restart")}\`.`);
127
+ return { changed, unchanged, overridden, settings: report(options.after, options.overrides), message: lines.join(" ") };
128
+ }
129
+ function formatSettings(settings) {
130
+ const width = Math.max(...settingKeys.map((key) => key.length));
131
+ return settingKeys.map((key) => {
132
+ const item = settings[key];
133
+ const override = item.overriddenBy ? ` (in effect: ${item.effective}, set by ${item.overriddenBy} on the relay)` : "";
134
+ return `${key.padEnd(width)} ${String(item.value).padEnd(9)} one of ${item.allowed.join(", ")}${override}`;
135
+ });
136
+ }
17
137
 
18
- // src/child-process.ts
19
- async function terminateChildProcess(options) {
20
- const isExited = options.isExited ?? (() => options.child.exitCode !== null || options.child.signalCode !== null);
21
- if (isExited())
138
+ // src/mcp/schema.ts
139
+ import { Effect, Predicate } from "effect";
140
+ import { McpSchema } from "effect/ai";
141
+ function browsersNote2(browsers) {
142
+ if (browsers.length < 2)
143
+ return "";
144
+ const list = browsers.map((browser) => `${browser.id} (${browser.name}${browser.default ? ", default" : ""})`).join(", ");
145
+ return ` Connected browsers: ${list}. Pass browser to use one other than the default.`;
146
+ }
147
+ function mcpUpdateNote2(notice) {
148
+ return `Note: Browse has an update: ${notice.summary}. Tell the user they can run \`${notice.command}\` and then restart opencode, or ask you to run it for them.`;
149
+ }
150
+ function objectSchema(properties, required = []) {
151
+ return {
152
+ type: "object",
153
+ properties,
154
+ required: [...required],
155
+ additionalProperties: false
156
+ };
157
+ }
158
+ var emptyInputSchema = objectSchema({});
159
+ var sessionSchemaProperty = {
160
+ type: "string",
161
+ description: "Optional session id. Defaults to this MCP server's current session."
162
+ };
163
+ var sessionAliasSchemaProperty = { type: "string", description: "Same as id." };
164
+ var sessionOnlyInputSchema = objectSchema({
165
+ session: sessionSchemaProperty
166
+ });
167
+ var ensureSessionExists = Effect.fnUntraced(function* (relay, id) {
168
+ const sessions = yield* relay.sessions;
169
+ const exists = sessions.some((session) => {
170
+ return session.id === id;
171
+ });
172
+ if (!exists) {
173
+ return yield* Effect.fail(new Error(`Session not found: ${id}`));
174
+ }
175
+ });
176
+ function parseExecuteArguments(input) {
177
+ const object = requireObject(input);
178
+ const code = requiredStringField(object, "code");
179
+ const session = optionalStringField(object, "session");
180
+ const timeoutMs = optionalNonNegativeIntegerField(object, "timeoutMs");
181
+ const targetSelection = parseMcpTargetSelection(object);
182
+ const browser = optionalStringField(object, "browser");
183
+ return {
184
+ code,
185
+ ...session ? { session } : {},
186
+ ...timeoutMs === undefined ? {} : { timeoutMs },
187
+ ...targetSelection ? { targetSelection } : {},
188
+ ...browser ? { browser } : {}
189
+ };
190
+ }
191
+ function parseAdoptArguments(input) {
192
+ const object = requireObject(input);
193
+ const session = optionalStringField(object, "session");
194
+ const targetSelection = parseMcpTargetSelection(object);
195
+ const tabId = typeof object.tabId === "number" && Number.isInteger(object.tabId) ? object.tabId : undefined;
196
+ if (tabId !== undefined && targetSelection)
197
+ throw new Error("Use only one of tabId, targetUrl, or targetIndex");
198
+ return {
199
+ ...session ? { session } : {},
200
+ ...targetSelection ? { targetSelection } : {},
201
+ ...tabId === undefined ? {} : { tabId }
202
+ };
203
+ }
204
+ function parseMcpTargetSelection(input) {
205
+ const urlIncludes = optionalStringField(input, "targetUrl");
206
+ const selection = parseTargetSelection2({
207
+ ...urlIncludes ? { urlIncludes } : {},
208
+ ...input.targetIndex === undefined ? {} : { index: input.targetIndex }
209
+ });
210
+ return selection?.urlIncludes || selection?.index !== undefined ? selection : undefined;
211
+ }
212
+ function requiredStringField(input, field) {
213
+ const value = optionalStringField(input, field);
214
+ if (!value) {
215
+ throw new Error(`${field} is required`);
216
+ }
217
+ return value;
218
+ }
219
+ function optionalStringField(input, field) {
220
+ const value = getString2(requireObject(input), field);
221
+ return value ? value : undefined;
222
+ }
223
+ function optionalBooleanField(input, field) {
224
+ const value = requireObject(input)[field];
225
+ return Predicate.isBoolean(value) ? value : undefined;
226
+ }
227
+ function optionalPositiveIntegerField(input, field) {
228
+ const value = requireObject(input)[field];
229
+ if (value === undefined)
22
230
  return;
23
- signalChildProcess(options.child, "SIGTERM");
24
- let timeout;
25
- await Promise.race([
26
- options.exit,
27
- new Promise((resolve) => {
28
- timeout = setTimeout(resolve, options.graceMs);
29
- })
30
- ]);
31
- if (timeout)
32
- clearTimeout(timeout);
33
- if (isExited())
231
+ if (!Predicate.isNumber(value) || !Number.isInteger(value) || value <= 0) {
232
+ throw new Error(`${field} must be a positive integer`);
233
+ }
234
+ return value;
235
+ }
236
+ function optionalNonNegativeIntegerField(input, field) {
237
+ const value = requireObject(input)[field];
238
+ if (value === undefined)
34
239
  return;
35
- signalChildProcess(options.child, "SIGKILL");
36
- await options.exit;
240
+ if (!Predicate.isNumber(value) || !Number.isInteger(value) || value < 0) {
241
+ throw new Error(`${field} must be a non-negative integer`);
242
+ }
243
+ return value;
37
244
  }
38
- function signalChildProcess(child, signal) {
39
- if (process.platform !== "win32" && child.pid !== undefined) {
40
- try {
41
- process.kill(-child.pid, signal);
42
- return;
43
- } catch {}
245
+ function optionalStringArrayField(input, field) {
246
+ const value = requireObject(input)[field];
247
+ if (value === undefined)
248
+ return;
249
+ if (!Array.isArray(value) || !value.every((item) => Predicate.isString(item) && item.length > 0)) {
250
+ throw new Error(`${field} must be an array of non-empty strings`);
44
251
  }
45
- child.kill(signal);
252
+ return value;
253
+ }
254
+ function requireObject(input) {
255
+ const object = getObject2(input);
256
+ if (!object) {
257
+ throw new Error("Expected arguments object");
258
+ }
259
+ return object;
260
+ }
261
+ function stringifyResult(value) {
262
+ if (Predicate.isString(value)) {
263
+ return value;
264
+ }
265
+ return JSON.stringify(value, null, 2);
266
+ }
267
+ function toolResultForValue2(value) {
268
+ const object = getObject2(value);
269
+ const isError = object?.isError === true;
270
+ const media = Array.isArray(object?.media) ? object.media.flatMap((item) => {
271
+ const image = getObject2(item);
272
+ const mimeType = getString2(image, "mimeType");
273
+ const data = getString2(image, "data");
274
+ return image?.type === "image" && mimeType !== undefined && data !== undefined ? [{ data, mimeType }] : [];
275
+ }) : [];
276
+ const errorText = isError ? getString2(object, "text") : undefined;
277
+ if (media.length > 0) {
278
+ const { media: _media, ...structuredContent } = object ?? {};
279
+ const text = errorText ?? stringifyResult(structuredContent);
280
+ return new McpSchema.CallToolResult({
281
+ content: [
282
+ McpSchema.TextContent.make({ text }),
283
+ ...media.map((image) => McpSchema.ImageContent.make({
284
+ data: new Uint8Array(Buffer.from(image.data, "base64")),
285
+ mimeType: image.mimeType
286
+ }))
287
+ ],
288
+ structuredContent,
289
+ isError
290
+ });
291
+ }
292
+ const text = errorText ?? stringifyResult(value);
293
+ return toolResult({ text, ...object ? { structuredContent: object } : {}, isError });
294
+ }
295
+ function mcpErrorMessage2(tool, message) {
296
+ if (!message.startsWith("Session not found:")) {
297
+ return message;
298
+ }
299
+ return tool === "execute" || tool === "session_adopt" ? `${message} Create it with session_new first, or omit the explicit session id to use the MCP current session.` : `${message} Create it with session_new first.`;
300
+ }
301
+ function toolResult(options) {
302
+ return new McpSchema.CallToolResult({
303
+ content: [McpSchema.TextContent.make({ text: options.text })],
304
+ structuredContent: options.structuredContent,
305
+ isError: options.isError
306
+ });
46
307
  }
47
308
 
48
- // src/fs-durability.ts
49
- import { Predicate } from "effect";
309
+ // src/auth/deadbolt.ts
310
+ import { spawn, spawnSync } from "node:child_process";
50
311
  import crypto from "node:crypto";
51
- import fs from "node:fs/promises";
312
+ import fs from "node:fs";
313
+ import net from "node:net";
314
+ import os from "node:os";
52
315
  import path from "node:path";
53
- var unsupportedDirectorySyncCodes = new Set(["EPERM", "EINVAL", "ENOTSUP"]);
54
- function isNodeError(error) {
55
- return error instanceof Error && "code" in error;
56
- }
57
- function isUnsupportedDirectorySyncError(error) {
58
- return isNodeError(error) && Predicate.isString(error.code) && unsupportedDirectorySyncCodes.has(error.code);
59
- }
60
- async function writeJsonFileAtomically(filePath, value, options = {}) {
61
- const directory = path.dirname(filePath);
62
- await fs.mkdir(directory, {
63
- recursive: true,
64
- ...options.dirMode === undefined ? {} : { mode: options.dirMode }
65
- });
66
- if (options.dirMode !== undefined) {
67
- await fs.chmod(directory, options.dirMode);
316
+ class DeadboltError extends Error {
317
+ name = "DeadboltError";
318
+ code;
319
+ detail;
320
+ constructor(code, message, detail = {}) {
321
+ super(message);
322
+ this.code = code;
323
+ this.detail = detail;
68
324
  }
69
- const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
70
- let renamed = false;
325
+ }
326
+ var clientFileName = "deadbolt-client.json";
327
+ function clientFilePath(root = dataRoot()) {
328
+ return path.join(root, clientFileName);
329
+ }
330
+ function readClientFile(root = dataRoot()) {
331
+ let text;
71
332
  try {
72
- await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}
73
- `, { mode: 384 });
74
- await fs.rename(temporaryPath, filePath);
75
- renamed = true;
76
- await fs.chmod(filePath, 384);
77
- } finally {
78
- if (!renamed)
79
- await fs.rm(temporaryPath, { force: true }).catch(() => {});
333
+ const file = clientFilePath(root);
334
+ const stat = fs.statSync(file);
335
+ if (process.platform !== "win32" && (stat.mode & 63) !== 0)
336
+ fs.chmodSync(file, 384);
337
+ text = fs.readFileSync(file, "utf8");
338
+ } catch {
339
+ return;
340
+ }
341
+ try {
342
+ const value = JSON.parse(text);
343
+ if (typeof value.id !== "string" || !value.id || typeof value.token !== "string" || !value.token)
344
+ return;
345
+ return {
346
+ version: 1,
347
+ id: value.id,
348
+ token: value.token,
349
+ connectedAt: typeof value.connectedAt === "string" ? value.connectedAt : ""
350
+ };
351
+ } catch {
352
+ return;
80
353
  }
81
354
  }
82
-
83
- // src/network-redaction.ts
84
- import { Predicate as Predicate2 } from "effect";
85
- var secretNamePattern = /auth(?:orization)?|cookie|credential|csrf|xsrf|token|secret|session|password|passwd|pwd|passcode|otp|(?:^|[-_.])code(?:$|[-_.])|(?:api|access|refresh)[-_.]?key|signature|(?:^|[-_.])sig(?:$|[-_.])/i;
86
- var oneTimeFieldPattern = /pass(?:word|wd|code|phrase)|(?:^|[-_.])pwd(?:$|[-_.])|otp|totp|(?:^|[-_.])(?:mfa_?|2fa_?|verification_?|auth_?)?code(?:$|[-_.])|(?:^|[-_.])pin(?:$|[-_.])/i;
87
- var referencePattern = String.raw`BC_(?:SECRET|REDACTED)_\d+`;
88
- var secretHeaders = new Set([
89
- "authorization",
90
- "proxy-authorization",
91
- "x-api-key",
92
- "api-key",
93
- "x-csrf-token",
94
- "csrf-token",
95
- "x-xsrf-token"
96
- ]);
97
-
98
- class SecretCollector {
99
- slotsByRef = new Map;
100
- refsByValue = new Map;
101
- refsBySource = new Map;
102
- retiredRefsByValue = new Map;
103
- redactedRefsByValue = new Map;
104
- nextRedactedRef = 1;
105
- nextRef = 1;
106
- updated = new Set;
107
- observed = new Set;
108
- constructor(existing = []) {
109
- for (const slot of existing) {
110
- const copy = {
111
- ref: slot.ref,
112
- value: slot.value,
113
- sources: [...slot.sources],
114
- ...slot.expiresAt ? { expiresAt: slot.expiresAt } : {}
115
- };
116
- this.slotsByRef.set(copy.ref, copy);
117
- this.refsByValue.set(copy.value, copy.ref);
118
- for (const source of copy.sources) {
119
- this.refsBySource.set(source, copy.ref);
355
+ function readClient(root = dataRoot()) {
356
+ const file = readClientFile(root);
357
+ return file ? { id: file.id, token: file.token } : undefined;
358
+ }
359
+ async function saveClient(client, root = dataRoot()) {
360
+ const value = { version: 1, id: client.id, token: client.token, connectedAt: new Date().toISOString() };
361
+ await writeJsonFileAtomically(clientFilePath(root), value, { dirMode: 448 });
362
+ }
363
+ function removeClient(root = dataRoot()) {
364
+ const file = clientFilePath(root);
365
+ if (!fs.existsSync(file))
366
+ return false;
367
+ fs.rmSync(file, { force: true });
368
+ return true;
369
+ }
370
+ function computedSocketPath(env = process.env) {
371
+ const nonEmpty = (name) => env[name] ? env[name] : undefined;
372
+ const deadboltHome = nonEmpty("DEADBOLT_HOME");
373
+ const suffix = deadboltHome ? `-${blake3Hex(Buffer.from(deadboltHome, "utf8")).slice(0, 8)}` : "";
374
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
375
+ const runtime = nonEmpty("XDG_RUNTIME_DIR") ?? userRuntimeDir(uid);
376
+ const directory = runtime ? path.join(runtime, `deadbolt${suffix}`) : path.join(nonEmpty("TMPDIR") ?? os.tmpdir(), `deadbolt${suffix}-${uid}`);
377
+ return path.join(directory, "agent.sock");
378
+ }
379
+ function userRuntimeDir(uid) {
380
+ const directory = `/run/user/${uid}`;
381
+ try {
382
+ const stat = fs.statSync(directory);
383
+ return stat.isDirectory() && stat.uid === uid ? directory : undefined;
384
+ } catch {
385
+ return;
386
+ }
387
+ }
388
+ function socketPath(options = {}) {
389
+ const env = options.env ?? process.env;
390
+ return env.OPENCODE_BROWSER_DEADBOLT_SOCKET || computedSocketPath(env);
391
+ }
392
+ var requestTimeoutMs = 15000;
393
+ var maxReplyBytes = 4 * 1024 * 1024;
394
+ function errorCode(code) {
395
+ const normalized = typeof code === "string" ? code.replace(/-/g, "_") : "";
396
+ switch (normalized) {
397
+ case "locked":
398
+ case "not_granted":
399
+ case "forbidden":
400
+ case "rate_limited":
401
+ case "domain_mismatch":
402
+ case "not_found":
403
+ case "choose_passkey":
404
+ return normalized;
405
+ case "not_initialized":
406
+ return "locked";
407
+ default:
408
+ return "failed";
409
+ }
410
+ }
411
+ var errorHints = {
412
+ locked: "Deadbolt is locked, so agent sign-ins are paused until the user unlocks it.",
413
+ forbidden: "Deadbolt doesn't know this Browse install (its client was removed or revoked). Ask the user to run `opencode-browser deadbolt connect`.",
414
+ rate_limited: "Deadbolt is rate-limiting this client; wait before trying again."
415
+ };
416
+ function rawRequest(socket, request, timeoutMs = requestTimeoutMs) {
417
+ return new Promise((resolve, reject) => {
418
+ const connection = net.createConnection(socket);
419
+ let buffer = "";
420
+ let settled = false;
421
+ const finish = (error, value) => {
422
+ if (settled)
423
+ return;
424
+ settled = true;
425
+ clearTimeout(timer);
426
+ connection.destroy();
427
+ if (error)
428
+ reject(error);
429
+ else
430
+ resolve(value);
431
+ };
432
+ const timer = setTimeout(() => finish(new DeadboltError("unavailable", `Deadbolt did not answer ${String(request.cmd)} within ${timeoutMs} ms`)), timeoutMs);
433
+ connection.setEncoding("utf8");
434
+ connection.on("connect", () => connection.write(`${JSON.stringify(request)}
435
+ `));
436
+ connection.on("error", (cause) => {
437
+ const missing = cause.code === "ENOENT" || cause.code === "ECONNREFUSED";
438
+ finish(new DeadboltError("unavailable", missing ? "Deadbolt isn't running (no agent socket). Ask the user to start or unlock Deadbolt." : `Could not reach Deadbolt: ${cause.message}`));
439
+ });
440
+ connection.on("data", (chunk) => {
441
+ buffer += chunk;
442
+ if (buffer.length > maxReplyBytes)
443
+ return finish(new DeadboltError("failed", "Deadbolt's reply was too large"));
444
+ let newline;
445
+ while ((newline = buffer.indexOf(`
446
+ `)) !== -1) {
447
+ const line = buffer.slice(0, newline).trim();
448
+ buffer = buffer.slice(newline + 1);
449
+ if (!line)
450
+ continue;
451
+ let message;
452
+ try {
453
+ message = JSON.parse(line);
454
+ } catch {
455
+ return finish(new DeadboltError("failed", "Deadbolt sent a reply that isn't JSON"));
456
+ }
457
+ if (message.type === "ok")
458
+ return finish(undefined, message.reply ?? {});
459
+ if (message.type === "error") {
460
+ const code = errorCode(message.code);
461
+ const text = typeof message.message === "string" ? message.message : String(message.code);
462
+ const detail = message.grant ?? {};
463
+ const choices = passkeyList(message.choices ?? message.passkeys);
464
+ return finish(new DeadboltError(code, errorHints[code] ? `${text.replace(/[\s.]*$/, ".")} ${errorHints[code]}` : text, {
465
+ ...typeof detail.site === "string" ? { site: detail.site } : {},
466
+ ...typeof detail.login === "string" ? { login: detail.login } : {},
467
+ ...choices.length > 0 ? { choices } : {}
468
+ }));
469
+ }
120
470
  }
121
- const number = /^BC_SECRET_(\d+)$/.exec(copy.ref)?.[1];
122
- if (number)
123
- this.nextRef = Math.max(this.nextRef, Number(number) + 1);
124
- }
471
+ });
472
+ connection.on("end", () => finish(new DeadboltError("failed", "Deadbolt closed the connection without a reply")));
473
+ });
474
+ }
475
+ var str = (value) => typeof value === "string" ? value : undefined;
476
+ function passkeyList(value) {
477
+ if (!Array.isArray(value))
478
+ return [];
479
+ return value.filter((entry) => entry !== null && typeof entry === "object").map((entry) => {
480
+ const username = str(entry.username);
481
+ const displayName = str(entry.display_name) ?? str(entry.displayName);
482
+ return { ref: entry.ref ?? entry.login, ...username ? { username } : {}, ...displayName ? { displayName } : {}, granted: entry.granted !== false };
483
+ });
484
+ }
485
+ function toLogin(raw) {
486
+ const signIn = raw.sign_in;
487
+ const fields = Array.isArray(raw.fields) ? raw.fields.filter((field) => typeof field === "string") : undefined;
488
+ const username = str(raw.username);
489
+ return {
490
+ ref: raw.ref ?? raw.login,
491
+ title: str(raw.title) ?? "",
492
+ ...username ? { username } : {},
493
+ hasPassword: raw.has_password === true,
494
+ hasTotp: raw.has_totp === true || raw.has_otp === true,
495
+ hasPasskey: raw.has_passkey === true,
496
+ granted: fields !== undefined && fields.length > 0 || raw.passkeys === true,
497
+ passkeys: raw.passkeys === true,
498
+ ...fields ? { fields } : {},
499
+ ...signIn && typeof signIn.provider === "string" ? { signIn: { provider: signIn.provider, ...str(signIn.account) ? { account: str(signIn.account) } : {} } } : {}
500
+ };
501
+ }
502
+ function connect(options = {}) {
503
+ const client = options.client ?? readClient(options.root);
504
+ if (!client) {
505
+ throw new DeadboltError("not_connected", "Browse isn't connected to Deadbolt. Ask the user to run `opencode-browser deadbolt connect` and to allow the logins agents may use with `deadbolt agents allow <site>`.");
125
506
  }
126
- protectHeaders(headers, location, requestScope = "") {
127
- const occurrences = new Map;
128
- return headers.map(({ name, value }) => {
129
- const lowerName = name.toLowerCase();
130
- const occurrence = occurrences.get(lowerName) ?? 0;
131
- occurrences.set(lowerName, occurrence + 1);
507
+ const socket = options.socket ?? socketPath();
508
+ let protocol;
509
+ const version = async () => {
510
+ if (protocol === undefined) {
511
+ const status = await rawRequest(socket, { v: 0, cmd: "status" });
512
+ protocol = typeof status.protocol === "number" ? status.protocol : 0;
513
+ }
514
+ return protocol;
515
+ };
516
+ const send = async (cmd, fields) => rawRequest(socket, { v: await version(), cmd, client: { id: client.id, token: client.token }, ...fields });
517
+ const site = async (origin) => {
518
+ const reply = await send("agent-logins", { origin });
519
+ const raw = (Array.isArray(reply.logins) ? reply.logins : []).filter((login) => login !== null && typeof login === "object");
520
+ const logins = raw.map(toLogin);
521
+ let passkeys = passkeyList(reply.passkeys);
522
+ if (!Array.isArray(reply.passkeys)) {
523
+ passkeys = logins.filter((login) => login.hasPasskey).map((login) => ({ ref: login.ref, ...login.username ? { username: login.username } : {}, granted: login.passkeys }));
524
+ }
525
+ return { logins, passkeys };
526
+ };
527
+ return {
528
+ socket,
529
+ client,
530
+ status: async () => {
531
+ const status = await rawRequest(socket, { v: 0, cmd: "status" });
532
+ protocol = typeof status.protocol === "number" ? status.protocol : 0;
533
+ return { protocol, unlocked: status.unlocked === true, ...str(status.version) ? { version: str(status.version) } : {} };
534
+ },
535
+ logins: async (origin) => (await site(origin)).logins,
536
+ site: (origin) => site(origin),
537
+ fill: async (origin, login, want) => {
538
+ const reply = await send("agent-fill", { origin, login: login.ref, want: [...want] });
539
+ const values = reply.values ?? reply;
132
540
  return {
133
- name,
134
- value: this.protectHeader(name, value, sourceName(requestScope, `${location}.header.${lowerName}`, occurrence))
541
+ ...str(values.username) !== undefined ? { username: str(values.username) } : {},
542
+ ...str(values.password) !== undefined ? { password: str(values.password) } : {},
543
+ ...str(values.totp) !== undefined ? { totp: str(values.totp) } : {}
135
544
  };
136
- });
545
+ },
546
+ passkeyGet: async (request) => {
547
+ const reply = await send("agent-passkey-get", {
548
+ origin: request.origin,
549
+ ...request.rpId ? { rp_id: request.rpId } : {},
550
+ challenge: request.challenge,
551
+ allow_credentials: [...request.allowCredentials],
552
+ user_verification: request.userVerification,
553
+ ...request.login !== undefined ? { login: request.login } : {}
554
+ });
555
+ if (reply.reply === "choose-passkey") {
556
+ throw new DeadboltError("choose_passkey", "Several passkeys could sign in here; name one by its username.", { choices: passkeyList(reply.choices ?? reply.passkeys) });
557
+ }
558
+ const credentialId = str(reply.credential_id);
559
+ const clientDataJson = str(reply.client_data_json);
560
+ const authenticatorData = str(reply.authenticator_data);
561
+ const signature = str(reply.signature);
562
+ if (!credentialId || !clientDataJson || !authenticatorData || !signature)
563
+ throw new DeadboltError("failed", "Deadbolt's passkey assertion is incomplete");
564
+ return { credentialId, clientDataJson, authenticatorData, signature, ...str(reply.user_handle) ? { userHandle: str(reply.user_handle) } : {} };
565
+ }
566
+ };
567
+ }
568
+ var deadboltNamePrefix = "deadbolt:";
569
+ function parseDeadboltName(name) {
570
+ if (!name.startsWith(deadboltNamePrefix))
571
+ return;
572
+ const rest = name.slice(deadboltNamePrefix.length).trim();
573
+ const origin = /^https?:\/\/[^/]+/i.exec(rest);
574
+ const site = origin ? origin[0] : rest.split("/")[0];
575
+ const username = rest.slice(site.length + 1);
576
+ if (!site)
577
+ return;
578
+ return { site: site.toLowerCase(), ...username ? { username } : {} };
579
+ }
580
+ function originOnSite(origin, site) {
581
+ let url;
582
+ try {
583
+ url = new URL(origin);
584
+ } catch {
585
+ return false;
137
586
  }
138
- protectUrl(rawUrl, requestScope = "") {
139
- const fragmentStart = rawUrl.indexOf("#");
140
- const base = fragmentStart < 0 ? rawUrl : rawUrl.slice(0, fragmentStart);
141
- const protectQuery = (name, value, occurrence) => this.reference(value, sourceName(requestScope, `query.${name}`, occurrence));
142
- const protectPath = (pathname) => protectPathSegments(pathname, (segment) => this.redactionReference(segment));
143
- let protectedUrl;
587
+ if (/^https?:\/\//i.test(site)) {
144
588
  try {
145
- const url = new URL(base);
146
- url.pathname = protectPath(url.pathname);
147
- url.search = protectSearchParams(url.searchParams, protectQuery).toString();
148
- protectedUrl = restoreReferencePlaceholders(url.toString());
589
+ return new URL(site).origin === url.origin;
149
590
  } catch {
150
- protectedUrl = protectRelativeUrl(base, protectQuery, protectPath);
591
+ return false;
151
592
  }
152
- if (fragmentStart < 0)
153
- return protectedUrl;
154
- const fragment = protectFragment(rawUrl.slice(fragmentStart + 1), (name, value, occurrence) => this.reference(value, sourceName(requestScope, `fragment.${name}`, occurrence)));
155
- return `${protectedUrl}#${fragment}`;
156
593
  }
157
- protectBody(body, contentType, location, requestScope = "") {
158
- const type = contentType?.toLowerCase() ?? "";
159
- if (type.includes("json")) {
160
- try {
161
- return JSON.stringify(this.protectJson(JSON.parse(body), sourceName(requestScope, location)));
162
- } catch {
163
- return;
164
- }
165
- }
166
- if (type.includes("application/x-www-form-urlencoded")) {
167
- const protectedParams = protectSearchParams(new URLSearchParams(body), (name, value, occurrence) => this.reference(value, sourceName(requestScope, `${location}.form.${name}`, occurrence)));
168
- return restoreReferencePlaceholders(protectedParams.toString());
169
- }
170
- if (type.includes("multipart/form-data")) {
171
- return this.protectMultipart(body, contentType ?? "", location, requestScope);
172
- }
173
- return;
174
- }
175
- slots() {
176
- return Array.from(this.slotsByRef.values()).sort((left, right) => left.ref.localeCompare(right.ref, undefined, { numeric: true })).map((slot) => ({ ...slot, sources: [...slot.sources] }));
177
- }
178
- updatedRefs() {
179
- return Array.from(this.updated).sort();
180
- }
181
- observedRefs() {
182
- return Array.from(this.observed).sort();
594
+ const host = url.hostname.toLowerCase();
595
+ const bare = site.replace(/^\*\./, "").replace(/:\d+$/, "").toLowerCase();
596
+ const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost");
597
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
598
+ return false;
599
+ return host === bare || host.endsWith(`.${bare}`);
600
+ }
601
+ function pickLogin(logins, site, username, field) {
602
+ const granted = logins.filter((login) => login.granted && (field === undefined || login.fields === undefined || login.fields.includes(field)));
603
+ const candidates = username === undefined ? granted : granted.filter((login) => login.username?.toLowerCase() === username.toLowerCase());
604
+ if (candidates.length === 1)
605
+ return candidates[0];
606
+ if (candidates.length === 0) {
607
+ throw new DeadboltError("not_granted", username === undefined ? `No Deadbolt login is granted to Browse for ${site}${field ? ` (${field})` : ""}. Ask the user to allow one with \`deadbolt agents allow ${site}\`.` : `No Deadbolt login for ${username} is granted to Browse for ${site}${field ? ` (${field})` : ""}. Ask the user to allow it with \`deadbolt agents allow ${site} --login ${username}\`.`, { site, ...username ? { login: username } : {} });
183
608
  }
184
- redactText(text) {
185
- return redactKnownValues(text, this.redactionSlots());
609
+ throw new DeadboltError("failed", `Several Deadbolt logins are granted for ${site}; name one: deadbolt:${site}/<username> (${candidates.map((login) => login.username ?? login.title).join(", ")})`);
610
+ }
611
+ function originsFromPatterns(patterns) {
612
+ const origins = new Set;
613
+ for (const pattern of patterns) {
614
+ const scheme = /^(https?):\/\//i.exec(pattern)?.[1]?.toLowerCase() ?? "https";
615
+ const host = pattern.replace(/^https?:\/\//i, "").replace(/^\*\./, "").replace(/\/$/, "");
616
+ try {
617
+ origins.add(new URL(`${scheme}://${host}`).origin);
618
+ } catch {}
186
619
  }
187
- redactValue(value) {
188
- return redactKnownValue(value, this.redactionSlots());
620
+ return [...origins];
621
+ }
622
+ async function listLogins(origins, connection) {
623
+ let deadbolt;
624
+ try {
625
+ deadbolt = connection ?? connect();
626
+ } catch (error) {
627
+ return { connected: false, logins: [], passkeys: [], error: { code: error instanceof DeadboltError ? error.code : "failed", message: error instanceof Error ? error.message : String(error) } };
189
628
  }
190
- redactExactValue(value, minimumLength) {
191
- if (!Predicate2.isString(value) && !Predicate2.isNumber(value) && !Predicate2.isBoolean(value))
192
- return value;
193
- const serialized = String(value);
194
- if (serialized.length < minimumLength)
195
- return value;
196
- const ref = this.refsByValue.get(serialized) ?? this.retiredRefsByValue.get(serialized) ?? this.redactedRefsByValue.get(serialized);
197
- return ref ? `\${${ref}}` : value;
629
+ const logins = [];
630
+ const passkeys = [];
631
+ for (const origin of origins.slice(0, 20)) {
632
+ try {
633
+ const found = await deadbolt.site(origin);
634
+ for (const { ref: _ref, ...login } of found.logins)
635
+ logins.push({ ...login, origin });
636
+ for (const { ref: _ref, ...passkey } of found.passkeys)
637
+ passkeys.push({ ...passkey, origin });
638
+ } catch (error) {
639
+ if (error instanceof DeadboltError && (error.code === "domain_mismatch" || error.code === "not_found" || error.code === "not_granted"))
640
+ continue;
641
+ return { connected: true, logins, passkeys, error: { code: error instanceof DeadboltError ? error.code : "failed", message: error instanceof Error ? error.message : String(error) } };
642
+ }
198
643
  }
199
- redactKnownSubstrings(text, minimumLength) {
200
- const slots = this.redactionSlots().filter((slot) => slot.value.length >= minimumLength);
201
- const encoded = slots.flatMap((slot) => {
202
- const variant = encodeURIComponent(slot.value);
203
- return variant === slot.value ? [] : [{ ref: slot.ref, value: variant }];
644
+ return { connected: true, logins, passkeys };
645
+ }
646
+ function pickPasskey(passkeys, username) {
647
+ const wanted = username.toLowerCase();
648
+ const granted = passkeys.filter((passkey) => passkey.granted);
649
+ return granted.find((passkey) => passkey.username?.toLowerCase() === wanted) ?? granted.find((passkey) => passkey.displayName?.toLowerCase() === wanted);
650
+ }
651
+ async function pair(options = {}) {
652
+ const command = options.command ?? (process.env.OPENCODE_BROWSER_DEADBOLT_CLI || "deadbolt");
653
+ const stdout = await new Promise((resolve, reject) => {
654
+ const child = spawn(command, ["agents", "add-client", options.name ?? "browse", "--json"], { stdio: ["inherit", "pipe", "inherit"] });
655
+ let output = "";
656
+ child.stdout.setEncoding("utf8");
657
+ child.stdout.on("data", (chunk) => {
658
+ output += chunk;
204
659
  });
205
- return redactKnownValues(text, [...slots, ...encoded]);
206
- }
207
- redactionSlots() {
208
- return [
209
- ...this.slotsByRef.values(),
210
- ...Array.from(this.retiredRefsByValue, ([value, ref]) => ({ ref, value })),
211
- ...Array.from(this.redactedRefsByValue, ([value, ref]) => ({ ref, value }))
212
- ];
660
+ child.on("error", (cause) => reject(new DeadboltError("unavailable", cause.code === "ENOENT" ? `${command} isn't installed or on PATH. Install Deadbolt first.` : `Could not run ${command}: ${cause.message}`)));
661
+ child.on("close", (code) => code === 0 ? resolve(output) : reject(new DeadboltError("failed", `deadbolt agents add-client exited with status ${code}`)));
662
+ });
663
+ let parsed;
664
+ try {
665
+ const line = stdout.trim().split(`
666
+ `).filter(Boolean).at(-1) ?? "";
667
+ parsed = JSON.parse(line);
668
+ } catch {
669
+ throw new DeadboltError("failed", "deadbolt agents add-client --json didn't print a client");
213
670
  }
214
- addRedactionOnly(value) {
215
- if (value)
216
- this.redactionReference(value);
671
+ const client = parsed.client ?? parsed;
672
+ const id = str(client.id);
673
+ const token = str(client.token) ?? str(parsed.token);
674
+ if (!id || !token)
675
+ throw new DeadboltError("failed", "deadbolt agents add-client --json printed no id and token");
676
+ await saveClient({ id, token }, options.root);
677
+ return { id };
678
+ }
679
+ function unpair(options = {}) {
680
+ const client = readClient(options.root);
681
+ if (!client)
682
+ return { removed: false, revoked: false };
683
+ const command = options.command ?? (process.env.OPENCODE_BROWSER_DEADBOLT_CLI || "deadbolt");
684
+ const result = spawnSync(command, ["agents", "remove-client", client.id], { stdio: ["inherit", "ignore", "inherit"], timeout: 120000 });
685
+ return { removed: removeClient(options.root), revoked: result.status === 0 };
686
+ }
687
+ function isBase64Url(value, maxLength = 8192) {
688
+ return typeof value === "string" && value.length <= maxLength && /^[A-Za-z0-9_-]*$/.test(value);
689
+ }
690
+ function randomName(prefix) {
691
+ return `${prefix}${crypto.randomBytes(12).toString("hex")}`;
692
+ }
693
+ var blake3Iv = [1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225];
694
+ var blake3Permutation = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
695
+ function blake3Compress(cv, block, blockLength, flags) {
696
+ const s = new Uint32Array([...cv, blake3Iv[0], blake3Iv[1], blake3Iv[2], blake3Iv[3], 0, 0, blockLength, flags]);
697
+ let m = Uint32Array.from(block);
698
+ const rotr = (x, n) => x >>> n | x << 32 - n;
699
+ const g = (a, b, c, d, x, y) => {
700
+ s[a] = s[a] + s[b] + x;
701
+ s[d] = rotr(s[d] ^ s[a], 16);
702
+ s[c] = s[c] + s[d];
703
+ s[b] = rotr(s[b] ^ s[c], 12);
704
+ s[a] = s[a] + s[b] + y;
705
+ s[d] = rotr(s[d] ^ s[a], 8);
706
+ s[c] = s[c] + s[d];
707
+ s[b] = rotr(s[b] ^ s[c], 7);
708
+ };
709
+ for (let round = 0;round < 7; round++) {
710
+ g(0, 4, 8, 12, m[0], m[1]);
711
+ g(1, 5, 9, 13, m[2], m[3]);
712
+ g(2, 6, 10, 14, m[4], m[5]);
713
+ g(3, 7, 11, 15, m[6], m[7]);
714
+ g(0, 5, 10, 15, m[8], m[9]);
715
+ g(1, 6, 11, 12, m[10], m[11]);
716
+ g(2, 7, 8, 13, m[12], m[13]);
717
+ g(3, 4, 9, 14, m[14], m[15]);
718
+ if (round < 6)
719
+ m = Uint32Array.from(blake3Permutation, (index) => m[index]);
217
720
  }
218
- redactValuesEverywhere(text, values) {
219
- const slots = [];
220
- for (const value of values) {
221
- if (!value)
222
- continue;
223
- const ref = /^\$\{(.+)\}$/.exec(this.redactionReference(value))?.[1] ?? "BC_REDACTED_0";
224
- for (const variant of new Set([value, encodeURIComponent(value), new URLSearchParams({ v: value }).toString().slice(2)])) {
225
- slots.push({ ref, value: variant });
226
- }
227
- }
228
- return redactKnownValues(text, slots);
721
+ return Array.from({ length: 8 }, (_, index) => (s[index] ^ s[index + 8]) >>> 0);
722
+ }
723
+ function blake3Hex(input) {
724
+ if (input.length > 1024)
725
+ throw new Error("blake3Hex handles inputs up to 1024 bytes (DEADBOLT_HOME is longer: set OPENCODE_BROWSER_DEADBOLT_SOCKET)");
726
+ const blocks = Math.max(1, Math.ceil(input.length / 64));
727
+ let cv = [...blake3Iv];
728
+ for (let index = 0;index < blocks; index++) {
729
+ const bytes = new Uint8Array(64);
730
+ const chunk = input.subarray(index * 64, index * 64 + 64);
731
+ bytes.set(chunk);
732
+ const words = new Uint32Array(16);
733
+ const view = new DataView(bytes.buffer);
734
+ for (let word = 0;word < 16; word++)
735
+ words[word] = view.getUint32(word * 4, true);
736
+ const flags = (index === 0 ? 1 : 0) | (index === blocks - 1 ? 2 | 8 : 0);
737
+ cv = blake3Compress(cv, words, chunk.length, flags);
229
738
  }
230
- redactionReference(value) {
231
- const persisted = this.refsByValue.get(value);
232
- if (persisted) {
233
- this.observed.add(persisted);
234
- return `\${${persisted}}`;
235
- }
236
- let ref = this.redactedRefsByValue.get(value);
237
- if (!ref) {
238
- ref = `BC_REDACTED_${this.nextRedactedRef++}`;
239
- this.redactedRefsByValue.set(value, ref);
240
- }
241
- return `\${${ref}}`;
739
+ const out = Buffer.alloc(32);
740
+ cv.forEach((word, index) => out.writeUInt32LE(word, index * 4));
741
+ return out.toString("hex");
742
+ }
743
+
744
+ // src/auth/site-secrets.ts
745
+ import crypto2 from "node:crypto";
746
+ import fs3 from "node:fs/promises";
747
+ import path3 from "node:path";
748
+ import { Effect as Effect3, Schema as Schema2 } from "effect";
749
+
750
+ // src/auth/profile.ts
751
+ import { Effect as Effect2, Schema, Semaphore } from "effect";
752
+ import { spawn as spawn2 } from "node:child_process";
753
+ import fs2 from "node:fs/promises";
754
+ import path2 from "node:path";
755
+
756
+ // src/update/child-process.ts
757
+ async function terminateChildProcess(options) {
758
+ const isExited = options.isExited ?? (() => options.child.exitCode !== null || options.child.signalCode !== null);
759
+ if (isExited())
760
+ return;
761
+ signalChildProcess(options.child, "SIGTERM");
762
+ let timeout;
763
+ await Promise.race([
764
+ options.exit,
765
+ new Promise((resolve) => {
766
+ timeout = setTimeout(resolve, options.graceMs);
767
+ })
768
+ ]);
769
+ if (timeout)
770
+ clearTimeout(timeout);
771
+ if (isExited())
772
+ return;
773
+ signalChildProcess(options.child, "SIGKILL");
774
+ await options.exit;
775
+ }
776
+ function signalChildProcess(child, signal) {
777
+ if (process.platform !== "win32" && child.pid !== undefined) {
778
+ try {
779
+ process.kill(-child.pid, signal);
780
+ return;
781
+ } catch {}
242
782
  }
243
- protectHeader(name, value, source) {
244
- if (!value)
245
- return value;
246
- const lower = name.toLowerCase();
247
- if (lower === "cookie" || lower === "set-cookie") {
248
- const cookieOccurrences = new Map;
249
- return value.split(/;\s*/).map((part, index) => {
250
- const separator = part.indexOf("=");
251
- if (separator < 1)
252
- return part;
783
+ child.kill(signal);
784
+ }
785
+
786
+ // src/network-capture/redaction.ts
787
+ import { Predicate as Predicate2 } from "effect";
788
+ var secretNamePattern = /auth(?:orization)?|cookie|credential|csrf|xsrf|token|secret|session|password|passwd|pwd|passcode|otp|(?:^|[-_.])code(?:$|[-_.])|(?:api|access|refresh)[-_.]?key|signature|(?:^|[-_.])sig(?:$|[-_.])/i;
789
+ var oneTimeFieldPattern = /pass(?:word|wd|code|phrase)|(?:^|[-_.])pwd(?:$|[-_.])|otp|totp|(?:^|[-_.])(?:mfa_?|2fa_?|verification_?|auth_?)?code(?:$|[-_.])|(?:^|[-_.])pin(?:$|[-_.])/i;
790
+ var referencePattern = String.raw`BC_(?:SECRET|REDACTED)_\d+`;
791
+ var secretHeaders = new Set([
792
+ "authorization",
793
+ "proxy-authorization",
794
+ "x-api-key",
795
+ "api-key",
796
+ "x-csrf-token",
797
+ "csrf-token",
798
+ "x-xsrf-token"
799
+ ]);
800
+
801
+ class SecretCollector {
802
+ slotsByRef = new Map;
803
+ refsByValue = new Map;
804
+ refsBySource = new Map;
805
+ retiredRefsByValue = new Map;
806
+ redactedRefsByValue = new Map;
807
+ nextRedactedRef = 1;
808
+ nextRef = 1;
809
+ updated = new Set;
810
+ observed = new Set;
811
+ constructor(existing = []) {
812
+ for (const slot of existing) {
813
+ const copy = {
814
+ ref: slot.ref,
815
+ value: slot.value,
816
+ sources: [...slot.sources],
817
+ ...slot.expiresAt ? { expiresAt: slot.expiresAt } : {}
818
+ };
819
+ this.slotsByRef.set(copy.ref, copy);
820
+ this.refsByValue.set(copy.value, copy.ref);
821
+ for (const source of copy.sources) {
822
+ this.refsBySource.set(source, copy.ref);
823
+ }
824
+ const number = /^BC_SECRET_(\d+)$/.exec(copy.ref)?.[1];
825
+ if (number)
826
+ this.nextRef = Math.max(this.nextRef, Number(number) + 1);
827
+ }
828
+ }
829
+ protectHeaders(headers, location, requestScope = "") {
830
+ const occurrences = new Map;
831
+ return headers.map(({ name, value }) => {
832
+ const lowerName = name.toLowerCase();
833
+ const occurrence = occurrences.get(lowerName) ?? 0;
834
+ occurrences.set(lowerName, occurrence + 1);
835
+ return {
836
+ name,
837
+ value: this.protectHeader(name, value, sourceName(requestScope, `${location}.header.${lowerName}`, occurrence))
838
+ };
839
+ });
840
+ }
841
+ protectUrl(rawUrl, requestScope = "") {
842
+ const fragmentStart = rawUrl.indexOf("#");
843
+ const base = fragmentStart < 0 ? rawUrl : rawUrl.slice(0, fragmentStart);
844
+ const protectQuery = (name, value, occurrence) => this.reference(value, sourceName(requestScope, `query.${name}`, occurrence));
845
+ const protectPath = (pathname) => protectPathSegments(pathname, (segment) => this.redactionReference(segment));
846
+ let protectedUrl;
847
+ try {
848
+ const url = new URL(base);
849
+ url.pathname = protectPath(url.pathname);
850
+ url.search = protectSearchParams(url.searchParams, protectQuery).toString();
851
+ protectedUrl = restoreReferencePlaceholders(url.toString());
852
+ } catch {
853
+ protectedUrl = protectRelativeUrl(base, protectQuery, protectPath);
854
+ }
855
+ if (fragmentStart < 0)
856
+ return protectedUrl;
857
+ const fragment = protectFragment(rawUrl.slice(fragmentStart + 1), (name, value, occurrence) => this.reference(value, sourceName(requestScope, `fragment.${name}`, occurrence)));
858
+ return `${protectedUrl}#${fragment}`;
859
+ }
860
+ protectBody(body, contentType, location, requestScope = "") {
861
+ const type = contentType?.toLowerCase() ?? "";
862
+ if (type.includes("json")) {
863
+ try {
864
+ return JSON.stringify(this.protectJson(JSON.parse(body), sourceName(requestScope, location)));
865
+ } catch {
866
+ return;
867
+ }
868
+ }
869
+ if (type.includes("application/x-www-form-urlencoded")) {
870
+ const protectedParams = protectSearchParams(new URLSearchParams(body), (name, value, occurrence) => this.reference(value, sourceName(requestScope, `${location}.form.${name}`, occurrence)));
871
+ return restoreReferencePlaceholders(protectedParams.toString());
872
+ }
873
+ if (type.includes("multipart/form-data")) {
874
+ return this.protectMultipart(body, contentType ?? "", location, requestScope);
875
+ }
876
+ return;
877
+ }
878
+ slots() {
879
+ return Array.from(this.slotsByRef.values()).sort((left, right) => left.ref.localeCompare(right.ref, undefined, { numeric: true })).map((slot) => ({ ...slot, sources: [...slot.sources] }));
880
+ }
881
+ updatedRefs() {
882
+ return Array.from(this.updated).sort();
883
+ }
884
+ observedRefs() {
885
+ return Array.from(this.observed).sort();
886
+ }
887
+ redactText(text) {
888
+ return redactKnownValues(text, this.redactionSlots());
889
+ }
890
+ redactValue(value) {
891
+ return redactKnownValue(value, this.redactionSlots());
892
+ }
893
+ redactExactValue(value, minimumLength) {
894
+ if (!Predicate2.isString(value) && !Predicate2.isNumber(value) && !Predicate2.isBoolean(value))
895
+ return value;
896
+ const serialized = String(value);
897
+ if (serialized.length < minimumLength)
898
+ return value;
899
+ const ref = this.refsByValue.get(serialized) ?? this.retiredRefsByValue.get(serialized) ?? this.redactedRefsByValue.get(serialized);
900
+ return ref ? `\${${ref}}` : value;
901
+ }
902
+ redactKnownSubstrings(text, minimumLength) {
903
+ const slots = this.redactionSlots().filter((slot) => slot.value.length >= minimumLength);
904
+ const encoded = slots.flatMap((slot) => {
905
+ const variant = encodeURIComponent(slot.value);
906
+ return variant === slot.value ? [] : [{ ref: slot.ref, value: variant }];
907
+ });
908
+ return redactKnownValues(text, [...slots, ...encoded]);
909
+ }
910
+ redactionSlots() {
911
+ return [
912
+ ...this.slotsByRef.values(),
913
+ ...Array.from(this.retiredRefsByValue, ([value, ref]) => ({ ref, value })),
914
+ ...Array.from(this.redactedRefsByValue, ([value, ref]) => ({ ref, value }))
915
+ ];
916
+ }
917
+ addRedactionOnly(value) {
918
+ if (value)
919
+ this.redactionReference(value);
920
+ }
921
+ redactValuesEverywhere(text, values) {
922
+ const slots = [];
923
+ for (const value of values) {
924
+ if (!value)
925
+ continue;
926
+ const ref = /^\$\{(.+)\}$/.exec(this.redactionReference(value))?.[1] ?? "BC_REDACTED_0";
927
+ for (const variant of new Set([value, encodeURIComponent(value), new URLSearchParams({ v: value }).toString().slice(2)])) {
928
+ slots.push({ ref, value: variant });
929
+ }
930
+ }
931
+ return redactKnownValues(text, slots);
932
+ }
933
+ redactionReference(value) {
934
+ const persisted = this.refsByValue.get(value);
935
+ if (persisted) {
936
+ this.observed.add(persisted);
937
+ return `\${${persisted}}`;
938
+ }
939
+ let ref = this.redactedRefsByValue.get(value);
940
+ if (!ref) {
941
+ ref = `BC_REDACTED_${this.nextRedactedRef++}`;
942
+ this.redactedRefsByValue.set(value, ref);
943
+ }
944
+ return `\${${ref}}`;
945
+ }
946
+ protectHeader(name, value, source) {
947
+ if (!value)
948
+ return value;
949
+ const lower = name.toLowerCase();
950
+ if (lower === "cookie" || lower === "set-cookie") {
951
+ const cookieOccurrences = new Map;
952
+ return value.split(/;\s*/).map((part, index) => {
953
+ const separator = part.indexOf("=");
954
+ if (separator < 1)
955
+ return part;
253
956
  const cookieName = part.slice(0, separator).trim();
254
957
  const cookieValue = part.slice(separator + 1);
255
958
  if (lower === "set-cookie" && index > 0 && /^(domain|path|expires|max-age|samesite)$/i.test(cookieName)) {
@@ -363,1336 +1066,778 @@ class SecretCollector {
363
1066
  this.refsBySource.set(source, replacementRef);
364
1067
  this.addSource(replacementRef, source);
365
1068
  this.updated.add(replacementRef);
366
- this.observed.add(replacementRef);
367
- return `\${${replacementRef}}`;
368
- }
369
- }
370
- }
371
- const valueRef = this.refsByValue.get(value);
372
- if (valueRef) {
373
- this.observed.add(valueRef);
374
- this.addSource(valueRef, source);
375
- return `\${${valueRef}}`;
376
- }
377
- const ref = `BC_SECRET_${this.nextRef++}`;
378
- const expiresAt = jwtExpiration(value);
379
- this.slotsByRef.set(ref, { ref, value, sources: [source], ...expiresAt ? { expiresAt } : {} });
380
- this.refsByValue.set(value, ref);
381
- this.refsBySource.set(source, ref);
382
- this.updated.add(ref);
383
- this.observed.add(ref);
384
- return `\${${ref}}`;
385
- }
386
- addSource(ref, source) {
387
- const slot = this.slotsByRef.get(ref);
388
- if (!slot || slot.sources.includes(source))
389
- return;
390
- slot.sources.push(source);
391
- this.refsBySource.set(source, ref);
392
- }
393
- }
394
- function restoreReferencePlaceholders(value) {
395
- return value.replace(/(?:%24|\$)%7B(BC_(?:SECRET|REDACTED)_\d+)%7D/gi, (_match, ref) => `\${${ref}}`);
396
- }
397
- function sourceFieldName(source) {
398
- const segments = source.split(".");
399
- for (let index = segments.length - 1;index >= 0; index -= 1) {
400
- const segment = segments[index] ?? "";
401
- if (!/^\d+$/.test(segment))
402
- return segment;
403
- }
404
- return "";
405
- }
406
- var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
407
- function looksLikeTokenSegment(segment) {
408
- if (segment.length < 24 || uuidPattern.test(segment) || segment.includes("${"))
409
- return false;
410
- if (/^eyJ[\w-]+\.[\w-]+\.[\w-]*$/.test(segment))
411
- return true;
412
- if (/^[0-9a-f]{32,}$/i.test(segment))
413
- return true;
414
- if (!/^[A-Za-z0-9_~+/-]+={0,2}$/.test(segment))
415
- return false;
416
- if (/^[a-z]+(?:[-_][a-z]+)+$/i.test(segment))
417
- return false;
418
- const hasDigit = /\d/.test(segment);
419
- const hasLower = /[a-z]/.test(segment);
420
- const hasUpper = /[A-Z]/.test(segment);
421
- return new Set(segment).size >= 12 && hasDigit && (hasLower || hasUpper) && (hasLower && hasUpper || segment.length >= 32);
422
- }
423
- function protectPathSegments(pathname, protect) {
424
- return pathname.split("/").map((segment) => {
425
- let decoded;
426
- try {
427
- decoded = decodeURIComponent(segment);
428
- } catch {
429
- decoded = segment;
430
- }
431
- return looksLikeTokenSegment(decoded) ? protect(decoded) : segment;
432
- }).join("/");
433
- }
434
- function protectRelativeUrl(rawUrl, protect, protectPath) {
435
- const queryStart = rawUrl.indexOf("?");
436
- if (queryStart < 0)
437
- return protectPath(rawUrl);
438
- const protectedQuery = protectSearchParams(new URLSearchParams(rawUrl.slice(queryStart + 1)), protect).toString();
439
- return restoreReferencePlaceholders(`${protectPath(rawUrl.slice(0, queryStart))}?${protectedQuery}`);
440
- }
441
- function protectSearchParams(params, protect) {
442
- const result = new URLSearchParams;
443
- const occurrences = new Map;
444
- for (const [name, value] of params) {
445
- const occurrence = occurrences.get(name) ?? 0;
446
- occurrences.set(name, occurrence + 1);
447
- result.append(name, value && secretNamePattern.test(name) ? protect(name, value, occurrence) : value);
448
- }
449
- return result;
450
- }
451
- function protectFragment(fragment, protect) {
452
- const occurrences = new Map;
453
- return fragment.split("&").map((part) => {
454
- const separator = part.indexOf("=");
455
- if (separator < 1)
456
- return part;
457
- const entry = new URLSearchParams(part).entries().next().value;
458
- if (!entry)
459
- return part;
460
- const [name, value] = entry;
461
- if (!/^[\w.-]+$/.test(name) || !secretNamePattern.test(name))
462
- return part;
463
- const occurrence = occurrences.get(name) ?? 0;
464
- occurrences.set(name, occurrence + 1);
465
- return value ? `${part.slice(0, separator + 1)}${protect(name, value, occurrence)}` : part;
466
- }).join("&");
467
- }
468
- function sourceName(requestScope, location, occurrence = 0) {
469
- const base = requestScope ? `${requestScope}.${location}` : location;
470
- return occurrence === 0 ? base : `${base}.${occurrence}`;
471
- }
472
- function redactKnownValues(text, slots) {
473
- return [...slots].sort((left, right) => right.value.length - left.value.length).reduce((output, slot) => replaceOutsideReferences(output, slot.value, `\${${slot.ref}}`), text);
474
- }
475
- function redactKnownValue(value, slots) {
476
- if (Predicate2.isString(value))
477
- return redactKnownValues(value, slots);
478
- if (Predicate2.isNumber(value) || Predicate2.isBoolean(value)) {
479
- const slot = slots.find((candidate) => candidate.value === String(value));
480
- return slot ? `\${${slot.ref}}` : value;
481
- }
482
- if (Array.isArray(value))
483
- return value.map((item) => redactKnownValue(item, slots));
484
- if (!Predicate2.isObject(value))
485
- return value;
486
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
487
- key,
488
- secretNamePattern.test(key) ? redactSecretValue(item) : redactKnownValue(item, slots)
489
- ]));
490
- }
491
- function redactSecretValue(value) {
492
- if (Array.isArray(value))
493
- return value.map(redactSecretValue);
494
- if (Predicate2.isObject(value)) {
495
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactSecretValue(item)]));
496
- }
497
- return value === null || value === "" ? value : "[REDACTED]";
498
- }
499
- function replaceOutsideReferences(text, value, replacement) {
500
- if (!value)
501
- return text;
502
- const references = new RegExp(String.raw`\$\{${referencePattern}\}`, "g");
503
- let output = "";
504
- let offset = 0;
505
- for (const match of text.matchAll(references)) {
506
- const index = match.index ?? 0;
507
- output += text.slice(offset, index).split(value).join(replacement);
508
- output += match[0];
509
- offset = index + match[0].length;
510
- }
511
- return output + text.slice(offset).split(value).join(replacement);
512
- }
513
- function jwtExpiration(value) {
514
- const parts = value.split(".");
515
- if (parts.length !== 3 || !parts[1])
516
- return;
517
- try {
518
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
519
- return Predicate2.isNumber(payload.exp) ? new Date(payload.exp * 1000).toISOString() : undefined;
520
- } catch {
521
- return;
522
- }
523
- }
524
-
525
- // src/auth-profile.ts
526
- var StoredCredentialSlot = Schema.Struct({
527
- ref: Schema.String.check(Schema.isPattern(/^BC_SECRET_[1-9][0-9]*$/)),
528
- value: Schema.String,
529
- sources: Schema.Array(Schema.String),
530
- expiresAt: Schema.optionalKey(Schema.String)
531
- });
532
- var StoredAuthProfile = Schema.Struct({
533
- version: Schema.Literal(1),
534
- name: Schema.String,
535
- createdAt: Schema.String,
536
- updatedAt: Schema.String,
537
- slots: Schema.Array(StoredCredentialSlot)
538
- }).check(Schema.makeFilter((profile) => {
539
- const refs = profile.slots.map((slot) => slot.ref);
540
- if (new Set(refs).size !== refs.length)
541
- return "Auth profile refs must be unique";
542
- const sources = profile.slots.flatMap((slot) => slot.sources);
543
- return new Set(sources).size === sources.length ? undefined : "Auth profile sources must be unique";
544
- }));
545
-
546
- class AuthProfileError extends Schema.TaggedError()("AuthProfile.Error", {
547
- message: Schema.String,
548
- operation: Schema.String,
549
- reason: Schema.Literals(["invalid-name", "not-found", "read-failed", "invalid-json", "invalid-profile", "write-failed", "run-failed"]),
550
- cause: Schema.optionalKey(Schema.Defect())
551
- }) {
552
- }
553
- var defaultBaseDir = () => path2.join(dataRoot(), "secrets");
554
- var writeLock = Semaphore.makeUnsafe(1);
555
- var profileLockTimeoutMs = 30000;
556
- var staleProfileLockMs = 60000;
557
- var maximumRunOutputBytes = 1e7;
558
- var maximumTimeoutMs = 2147483647;
559
- var read = Effect.fn("AuthProfile.read")(function* (name, options = {}) {
560
- const filePath = yield* profilePath(options.baseDir ?? defaultBaseDir(), name);
561
- const text = yield* Effect.tryPromise({
562
- try: () => fs2.readFile(filePath, "utf8"),
563
- catch: (cause) => isNodeError(cause) && cause.code === "ENOENT" ? new AuthProfileError({ message: `Auth profile not found: ${name}`, operation: "read", reason: "not-found", cause }) : new AuthProfileError({ message: `Could not read auth profile: ${name}`, operation: "read", reason: "read-failed", cause })
564
- });
565
- return yield* Schema.decodeUnknownEffect(StoredAuthProfile)(yield* Effect.try({
566
- try: () => JSON.parse(text),
567
- catch: (cause) => new AuthProfileError({ message: `Auth profile is not valid JSON: ${name}`, operation: "read", reason: "invalid-json", cause })
568
- })).pipe(Effect.mapError((cause) => cause instanceof AuthProfileError ? cause : new AuthProfileError({ message: `Auth profile has an invalid shape: ${name}`, operation: "read", reason: "invalid-profile", cause })));
569
- });
570
- var readOptional = Effect.fn("AuthProfile.readOptional")(function* (name, options = {}) {
571
- return yield* read(name, options).pipe(Effect.catchIf((error) => error.reason === "not-found", () => Effect.succeed(undefined)));
572
- });
573
- function withLock(name, options, effect) {
574
- const baseDir = options.baseDir ?? defaultBaseDir();
575
- const acquire = Effect.gen(function* () {
576
- const filePath = yield* profilePath(baseDir, name);
577
- const lockPath = `${filePath}.lock`;
578
- yield* Effect.tryPromise({
579
- try: async () => {
580
- await fs2.mkdir(baseDir, { recursive: true, mode: 448 });
581
- await fs2.chmod(baseDir, 448);
582
- const deadline = Date.now() + profileLockTimeoutMs;
583
- while (true) {
584
- try {
585
- const handle = await fs2.open(lockPath, "wx", 384);
586
- let written = false;
587
- try {
588
- await handle.writeFile(`${process.pid}
589
- `);
590
- written = true;
591
- } finally {
592
- await handle.close();
593
- if (!written)
594
- await fs2.unlink(lockPath).catch(() => {});
595
- }
596
- return;
597
- } catch (cause) {
598
- if (!isNodeError(cause) || cause.code !== "EEXIST")
599
- throw cause;
600
- const stat = await fs2.stat(lockPath).catch(() => {
601
- return;
602
- });
603
- if (stat && Date.now() - stat.mtimeMs > staleProfileLockMs) {
604
- await fs2.unlink(lockPath).catch(() => {});
605
- continue;
606
- }
607
- if (Date.now() >= deadline)
608
- throw new Error(`Timed out waiting for auth profile lock: ${name}`);
609
- await new Promise((resolve) => setTimeout(resolve, 25));
610
- }
611
- }
612
- },
613
- catch: (cause) => new AuthProfileError({ message: `Could not lock auth profile: ${name}`, operation: "lock", reason: "write-failed", cause })
614
- });
615
- return lockPath;
616
- });
617
- return Effect.acquireUseRelease(acquire, () => effect, (lockPath) => Effect.tryPromise({
618
- try: () => fs2.unlink(lockPath).catch(() => {}).then(() => {}),
619
- catch: (cause) => new AuthProfileError({ message: `Could not release auth profile lock: ${name}`, operation: "lock", reason: "write-failed", cause })
620
- }));
621
- }
622
- var writeProfile = Effect.fnUntraced(function* (options) {
623
- const baseDir = options.baseDir ?? defaultBaseDir();
624
- const filePath = yield* profilePath(baseDir, options.name);
625
- const existing = yield* readOptional(options.name, { baseDir });
626
- const now = new Date().toISOString();
627
- const profile = {
628
- version: 1,
629
- name: options.name,
630
- createdAt: existing?.createdAt ?? now,
631
- updatedAt: now,
632
- slots: options.slots.map((slot) => ({
633
- ref: slot.ref,
634
- value: slot.value,
635
- sources: [...slot.sources],
636
- ...slot.expiresAt ? { expiresAt: slot.expiresAt } : {}
637
- }))
638
- };
639
- yield* Schema.decodeUnknownEffect(StoredAuthProfile)(profile).pipe(Effect.mapError((cause) => new AuthProfileError({ message: `Auth profile has an invalid shape: ${options.name}`, operation: "write", reason: "invalid-profile", cause })));
640
- if (existing && sameSlots(existing.slots, profile.slots))
641
- return summary(existing);
642
- yield* Effect.tryPromise({
643
- try: () => writeJsonFileAtomically(filePath, profile, { dirMode: 448 }),
644
- catch: (cause) => new AuthProfileError({ message: `Could not write auth profile: ${options.name}`, operation: "write", reason: "write-failed", cause })
645
- });
646
- return summary(profile);
647
- });
648
- var write = Effect.fn("AuthProfile.write")(function* (options) {
649
- return yield* writeLock.withPermit(writeProfile(options));
650
- });
651
- var status = Effect.fn("AuthProfile.status")(function* (name, options = {}) {
652
- return summary(yield* read(name, options));
653
- });
654
- var run = Effect.fn("AuthProfile.run")(function* (options) {
655
- if (!options.command.trim()) {
656
- return yield* new AuthProfileError({ message: "Auth command must not be empty", operation: "run", reason: "run-failed" });
657
- }
658
- const timeoutMs = options.timeoutMs ?? 120000;
659
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > maximumTimeoutMs) {
660
- return yield* new AuthProfileError({ message: `Auth command timeout must be an integer between 1 and ${maximumTimeoutMs} milliseconds`, operation: "run", reason: "run-failed" });
661
- }
662
- const maxOutputBytes = options.maxOutputBytes ?? 1e6;
663
- if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0 || maxOutputBytes > maximumRunOutputBytes) {
664
- return yield* new AuthProfileError({ message: `Auth command output limit must be an integer between 0 and ${maximumRunOutputBytes} bytes`, operation: "run", reason: "run-failed" });
665
- }
666
- const profile = yield* read(options.name, { ...options.baseDir ? { baseDir: options.baseDir } : {} });
667
- const startedAt = Date.now();
668
- const maxSecretBytes = profile.slots.reduce((max, slot) => Math.max(max, Buffer.byteLength(slot.value)), 0);
669
- const result = yield* runChild({
670
- command: options.command,
671
- args: options.args ?? [],
672
- ...options.cwd ? { cwd: options.cwd } : {},
673
- timeoutMs,
674
- maxOutputBytes: maxOutputBytes + maxSecretBytes,
675
- env: Object.fromEntries(profile.slots.map((slot) => [slot.ref, slot.value]))
676
- }).pipe(Effect.mapError((cause) => new AuthProfileError({ message: `Could not run command with auth profile ${options.name}`, operation: "run", reason: "run-failed", cause })));
677
- const stdout = redactOutput(result.stdout, profile.slots, maxOutputBytes);
678
- const stderr = redactOutput(result.stderr, profile.slots, maxOutputBytes);
679
- return {
680
- ...result,
681
- stdout: stdout.text,
682
- stderr: stderr.text,
683
- stdoutTruncated: result.stdoutTruncated || stdout.truncated,
684
- stderrTruncated: result.stderrTruncated || stderr.truncated,
685
- durationMs: Date.now() - startedAt
686
- };
687
- });
688
- function summary(profile) {
689
- const now = Date.now();
690
- return {
691
- name: profile.name,
692
- createdAt: profile.createdAt,
693
- updatedAt: profile.updatedAt,
694
- slotCount: profile.slots.length,
695
- slots: profile.slots.map((slot) => ({
696
- ref: slot.ref,
697
- sources: [...slot.sources],
698
- ...slot.expiresAt ? { expiresAt: slot.expiresAt } : {},
699
- expired: slot.expiresAt ? Date.parse(slot.expiresAt) <= now : false
700
- }))
701
- };
702
- }
703
- function profilePath(baseDir, name) {
704
- return /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name) ? Effect.succeed(path2.join(baseDir, `${name}.json`)) : Effect.fail(new AuthProfileError({ message: `Invalid auth profile name: ${name}`, operation: "path", reason: "invalid-name" }));
705
- }
706
- function sameSlots(left, right) {
707
- return JSON.stringify(left) === JSON.stringify(right);
708
- }
709
- function redactOutput(text, slots, maxBytes) {
710
- const bytes = Buffer.from(text);
711
- let cutoff = Math.min(bytes.length, maxBytes);
712
- let changed = true;
713
- while (changed) {
714
- changed = false;
715
- for (const slot of slots) {
716
- if (!slot.value)
717
- continue;
718
- const secret = Buffer.from(slot.value);
719
- let start = bytes.indexOf(secret);
720
- while (start >= 0 && start < cutoff) {
721
- if (start + secret.length > cutoff) {
722
- cutoff = start;
723
- changed = true;
724
- break;
725
- }
726
- start = bytes.indexOf(secret, start + secret.length);
727
- }
728
- }
729
- }
730
- return {
731
- text: redactKnownValues(bytes.subarray(0, cutoff).toString("utf8"), slots),
732
- truncated: cutoff < bytes.length
733
- };
734
- }
735
- function runChild(options) {
736
- return Effect.callback((resume) => {
737
- const child = spawn(options.command, [...options.args], {
738
- ...options.cwd ? { cwd: options.cwd } : {},
739
- env: childEnvironment(options.env),
740
- detached: process.platform !== "win32",
741
- stdio: ["ignore", "pipe", "pipe"]
742
- });
743
- const stdout = [];
744
- const stderr = [];
745
- let stdoutBytes = 0;
746
- let stderrBytes = 0;
747
- let stdoutTruncated = false;
748
- let stderrTruncated = false;
749
- const append = (chunks, chunk, currentBytes) => {
750
- const remaining = options.maxOutputBytes - currentBytes;
751
- if (remaining <= 0)
752
- return currentBytes;
753
- chunks.push(chunk.subarray(0, remaining));
754
- return currentBytes + Math.min(chunk.length, remaining);
755
- };
756
- child.stdout.on("data", (chunk) => {
757
- const next = append(stdout, chunk, stdoutBytes);
758
- stdoutTruncated ||= next - stdoutBytes < chunk.length;
759
- stdoutBytes = next;
760
- });
761
- child.stderr.on("data", (chunk) => {
762
- const next = append(stderr, chunk, stderrBytes);
763
- stderrTruncated ||= next - stderrBytes < chunk.length;
764
- stderrBytes = next;
765
- });
766
- let closed = false;
767
- let settled = false;
768
- let closePromiseResolve;
769
- const closePromise = new Promise((resolve) => {
770
- closePromiseResolve = resolve;
771
- });
772
- const timeout = setTimeout(() => {
773
- terminateChildProcess({ child, exit: closePromise, graceMs: 1000, isExited: () => closed });
774
- }, options.timeoutMs);
775
- child.once("error", (cause) => {
776
- clearTimeout(timeout);
777
- closed = true;
778
- closePromiseResolve?.();
779
- if (settled)
780
- return;
781
- settled = true;
782
- resume(Effect.fail(cause));
783
- });
784
- child.once("close", (code, signal) => {
785
- clearTimeout(timeout);
786
- closed = true;
787
- closePromiseResolve?.();
788
- if (settled)
789
- return;
790
- settled = true;
791
- resume(Effect.succeed({
792
- exitCode: code ?? 1,
793
- signal,
794
- stdout: Buffer.concat(stdout).toString("utf8"),
795
- stderr: Buffer.concat(stderr).toString("utf8"),
796
- stdoutTruncated,
797
- stderrTruncated
798
- }));
799
- });
800
- return Effect.promise(() => terminateChildProcess({ child, exit: closePromise, graceMs: 1000, isExited: () => closed }));
801
- });
802
- }
803
- function childEnvironment(profile) {
804
- const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^BC_SECRET_[1-9][0-9]*$/.test(name)));
805
- return { ...inherited, ...profile };
806
- }
807
-
808
- // src/domain-policy.ts
809
- import dns from "node:dns/promises";
810
- import net from "node:net";
811
-
812
- class OriginPatternError extends Error {
813
- name = "OriginPatternError";
814
- }
815
- var maxPatterns = 100;
816
- function parseOriginPattern(raw) {
817
- const pattern = raw.trim();
818
- if (!pattern)
819
- throw new OriginPatternError("Empty origin pattern");
820
- if (pattern === "*" || pattern === "*.*")
821
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} allows every site; leave the allowlist unset instead`);
822
- const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(pattern);
823
- const scheme = schemeMatch?.[1]?.toLowerCase();
824
- if (scheme !== undefined && scheme !== "http" && scheme !== "https") {
825
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: only http and https origins can be allowed`);
826
- }
827
- let rest = schemeMatch ? pattern.slice(schemeMatch[0].length) : pattern;
828
- if (/[/?#]/.test(rest.replace(/\/$/, "")))
829
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} must be a host or origin, without a path`);
830
- rest = rest.replace(/\/$/, "");
831
- const wildcard = rest.startsWith("*.");
832
- if (wildcard)
833
- rest = rest.slice(2);
834
- if (rest.includes("*"))
835
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: a wildcard is only allowed as a leading "*."`);
836
- let parsed;
837
- try {
838
- parsed = new URL(`${scheme ?? "http"}://${rest}`);
839
- } catch {
840
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} is not a valid host or origin`);
841
- }
842
- if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !parsed.hostname) {
843
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} must be a host or origin`);
844
- }
845
- const host = unbracket(parsed.hostname.toLowerCase());
846
- if (wildcard && net.isIP(host))
847
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: wildcards don't apply to IP addresses`);
848
- if (wildcard && !host.includes("."))
849
- throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: "*." needs a domain with a dot, like *.example.com`);
850
- const port = parsed.port || (scheme ? scheme === "https" ? "443" : "80" : undefined);
851
- return {
852
- pattern,
853
- ...scheme ? { scheme: `${scheme}:` } : {},
854
- host,
855
- wildcard,
856
- ...port ? { port } : {}
857
- };
858
- }
859
- function parseOriginPatterns(raw) {
860
- if (raw.length > maxPatterns)
861
- throw new OriginPatternError(`At most ${maxPatterns} origin patterns are allowed`);
862
- return raw.map(parseOriginPattern);
863
- }
864
- function normalizeOriginPatterns(raw) {
865
- return Array.from(new Set(parseOriginPatterns(raw.flatMap((entry) => entry.split(",")).map((entry) => entry.trim()).filter(Boolean)).map((rule) => rule.pattern.toLowerCase())));
866
- }
867
- function unbracket(host) {
868
- return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
869
- }
870
- function ipv4ToNumber(ip) {
871
- return ip.split(".").reduce((value, part) => value * 256 + Number(part), 0);
872
- }
873
- var privateIpv4Ranges = [
874
- ["0.0.0.0", 8],
875
- ["10.0.0.0", 8],
876
- ["100.64.0.0", 10],
877
- ["127.0.0.0", 8],
878
- ["169.254.0.0", 16],
879
- ["172.16.0.0", 12],
880
- ["192.0.0.0", 24],
881
- ["192.0.2.0", 24],
882
- ["192.168.0.0", 16],
883
- ["198.18.0.0", 15],
884
- ["198.51.100.0", 24],
885
- ["203.0.113.0", 24],
886
- ["224.0.0.0", 4],
887
- ["240.0.0.0", 4]
888
- ];
889
- function isPrivateAddress(address) {
890
- const ip = unbracket(address.toLowerCase()).replace(/%.*$/, "");
891
- if (net.isIPv4(ip)) {
892
- const value = ipv4ToNumber(ip);
893
- return privateIpv4Ranges.some(([base, bits]) => {
894
- const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
895
- return (value & mask) >>> 0 === (ipv4ToNumber(base) & mask) >>> 0;
896
- });
897
- }
898
- if (net.isIPv6(ip)) {
899
- if (ip === "::" || ip === "::1")
900
- return true;
901
- const mapped = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(ip) ?? /^::(\d+\.\d+\.\d+\.\d+)$/.exec(ip);
902
- if (mapped?.[1])
903
- return isPrivateAddress(mapped[1]);
904
- const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(ip);
905
- if (hexMapped?.[1] && hexMapped[2]) {
906
- const high = parseInt(hexMapped[1], 16);
907
- const low = parseInt(hexMapped[2], 16);
908
- return isPrivateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
909
- }
910
- const first = parseInt(ip.split(":")[0] || "0", 16);
911
- if ((first & 65024) === 64512 || (first & 65472) === 65152 || (first & 65280) === 65280)
912
- return true;
913
- if (ip.startsWith("64:ff9b::"))
914
- return true;
915
- return false;
916
- }
917
- return false;
918
- }
919
- function isLocalhostName(host) {
920
- return host === "localhost" || host.endsWith(".localhost") || host === "localhost.localdomain";
921
- }
922
- function isPrivateHost(host) {
923
- const bare = unbracket(host.toLowerCase());
924
- return isLocalhostName(bare) || net.isIP(bare) !== 0 && isPrivateAddress(bare);
925
- }
926
- function defaultPort(protocol) {
927
- return protocol === "https:" ? "443" : "80";
928
- }
929
- function ruleMatches(rule, url, host, options) {
930
- if (rule.scheme !== undefined && rule.scheme !== url.protocol)
931
- return false;
932
- if (rule.scheme === undefined && options.httpsOnlyWithoutScheme && url.protocol !== "https:" && !isPrivateHost(host))
933
- return false;
934
- if (rule.port !== undefined && rule.port !== (url.port || defaultPort(url.protocol)))
935
- return false;
936
- if (rule.wildcard)
937
- return host === rule.host || host.endsWith(`.${rule.host}`);
938
- return host === rule.host;
939
- }
940
- function explicitlyAllowsHost(rules, url) {
941
- try {
942
- const parsed = new URL(url);
943
- const host = unbracket(parsed.hostname.toLowerCase());
944
- return rules.some((rule) => !rule.wildcard && ruleMatches(rule, parsed, host, {}));
945
- } catch {
946
- return false;
947
- }
948
- }
949
- var neutralSchemes = new Set(["about:", "data:", "blob:"]);
950
- function checkUrl(rawUrl, rules, options = {}) {
951
- let url;
952
- try {
953
- url = new URL(rawUrl);
954
- } catch {
955
- return { allowed: false, reason: `${JSON.stringify(rawUrl.slice(0, 200))} is not a valid URL` };
956
- }
957
- if (neutralSchemes.has(url.protocol))
958
- return { allowed: true };
959
- if (url.protocol !== "http:" && url.protocol !== "https:") {
960
- return { allowed: false, reason: `${url.protocol} URLs are not allowed for this session` };
961
- }
962
- const host = unbracket(url.hostname.toLowerCase());
963
- const matching = rules.filter((rule) => ruleMatches(rule, url, host, options));
964
- if (matching.length === 0) {
965
- return { allowed: false, reason: `${url.host} is not in the allowed origins (${rules.map((rule) => rule.pattern).join(", ") || "none"})` };
1069
+ this.observed.add(replacementRef);
1070
+ return `\${${replacementRef}}`;
1071
+ }
1072
+ }
1073
+ }
1074
+ const valueRef = this.refsByValue.get(value);
1075
+ if (valueRef) {
1076
+ this.observed.add(valueRef);
1077
+ this.addSource(valueRef, source);
1078
+ return `\${${valueRef}}`;
1079
+ }
1080
+ const ref = `BC_SECRET_${this.nextRef++}`;
1081
+ const expiresAt = jwtExpiration(value);
1082
+ this.slotsByRef.set(ref, { ref, value, sources: [source], ...expiresAt ? { expiresAt } : {} });
1083
+ this.refsByValue.set(value, ref);
1084
+ this.refsBySource.set(source, ref);
1085
+ this.updated.add(ref);
1086
+ this.observed.add(ref);
1087
+ return `\${${ref}}`;
966
1088
  }
967
- const explicit = matching.some((rule) => !rule.wildcard);
968
- if (isPrivateHost(host) && !explicit) {
969
- return { allowed: false, reason: `${url.host} is a private, loopback or local address; allow it by naming that exact host` };
1089
+ addSource(ref, source) {
1090
+ const slot = this.slotsByRef.get(ref);
1091
+ if (!slot || slot.sources.includes(source))
1092
+ return;
1093
+ slot.sources.push(source);
1094
+ this.refsBySource.set(source, ref);
970
1095
  }
971
- return { allowed: true, host, explicit };
972
1096
  }
973
- async function resolvesToPrivateAddress(host, timeoutMs = 2000) {
974
- const bare = unbracket(host);
975
- if (net.isIP(bare) || isLocalhostName(bare))
976
- return isPrivateHost(bare) ? bare : undefined;
977
- let timer;
978
- try {
979
- const addresses = await Promise.race([
980
- dns.lookup(bare, { all: true, verbatim: true }),
981
- new Promise((_, reject) => {
982
- timer = setTimeout(() => reject(new Error("DNS lookup timed out")), timeoutMs);
983
- })
984
- ]);
985
- return addresses.find((entry) => isPrivateAddress(entry.address))?.address;
986
- } catch {
987
- return;
988
- } finally {
989
- if (timer)
990
- clearTimeout(timer);
991
- }
1097
+ function restoreReferencePlaceholders(value) {
1098
+ return value.replace(/(?:%24|\$)%7B(BC_(?:SECRET|REDACTED)_\d+)%7D/gi, (_match, ref) => `\${${ref}}`);
992
1099
  }
993
- async function checkNavigationUrl(rawUrl, rules) {
994
- const decision = checkUrl(rawUrl, rules);
995
- if (!decision.allowed || decision.host === undefined || decision.explicit)
996
- return decision;
997
- const privateAddress = await resolvesToPrivateAddress(decision.host);
998
- if (privateAddress) {
999
- return { allowed: false, reason: `${decision.host} resolves to the private address ${privateAddress}; allow it by naming that exact host` };
1100
+ function sourceFieldName(source) {
1101
+ const segments = source.split(".");
1102
+ for (let index = segments.length - 1;index >= 0; index -= 1) {
1103
+ const segment = segments[index] ?? "";
1104
+ if (!/^\d+$/.test(segment))
1105
+ return segment;
1000
1106
  }
1001
- return decision;
1107
+ return "";
1002
1108
  }
1003
-
1004
- // src/site-secrets.ts
1005
- var StoredSiteSecret = Schema2.Struct({
1006
- version: Schema2.Literal(1),
1007
- name: Schema2.String,
1008
- kind: Schema2.Literals(["password", "totp"]),
1009
- value: Schema2.String,
1010
- allowedOrigins: Schema2.Array(Schema2.String),
1011
- createdAt: Schema2.String,
1012
- updatedAt: Schema2.String
1013
- });
1014
-
1015
- class SiteSecretError extends Schema2.TaggedError()("SiteSecret.Error", {
1016
- message: Schema2.String,
1017
- reason: Schema2.Literals(["invalid-name", "invalid-origins", "invalid-value", "not-found", "read-failed", "write-failed"]),
1018
- cause: Schema2.optionalKey(Schema2.Defect())
1019
- }) {
1109
+ var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1110
+ function looksLikeTokenSegment(segment) {
1111
+ if (segment.length < 24 || uuidPattern.test(segment) || segment.includes("${"))
1112
+ return false;
1113
+ if (/^eyJ[\w-]+\.[\w-]+\.[\w-]*$/.test(segment))
1114
+ return true;
1115
+ if (/^[0-9a-f]{32,}$/i.test(segment))
1116
+ return true;
1117
+ if (!/^[A-Za-z0-9_~+/-]+={0,2}$/.test(segment))
1118
+ return false;
1119
+ if (/^[a-z]+(?:[-_][a-z]+)+$/i.test(segment))
1120
+ return false;
1121
+ const hasDigit = /\d/.test(segment);
1122
+ const hasLower = /[a-z]/.test(segment);
1123
+ const hasUpper = /[A-Z]/.test(segment);
1124
+ return new Set(segment).size >= 12 && hasDigit && (hasLower || hasUpper) && (hasLower && hasUpper || segment.length >= 32);
1020
1125
  }
1021
- var defaultSiteSecretsDir = () => path3.join(dataRoot(), "site-secrets");
1022
- var namePattern = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
1023
- function secretPath(baseDir, name) {
1024
- return namePattern.test(name) ? Effect2.succeed(path3.join(baseDir, `${name}.json`)) : Effect2.fail(new SiteSecretError({ message: `Invalid secret name: ${name} (letters, digits, ".", "_" and "-", up to 64)`, reason: "invalid-name" }));
1126
+ function protectPathSegments(pathname, protect) {
1127
+ return pathname.split("/").map((segment) => {
1128
+ let decoded;
1129
+ try {
1130
+ decoded = decodeURIComponent(segment);
1131
+ } catch {
1132
+ decoded = segment;
1133
+ }
1134
+ return looksLikeTokenSegment(decoded) ? protect(decoded) : segment;
1135
+ }).join("/");
1025
1136
  }
1026
- function summary2(secret) {
1027
- return { name: secret.name, kind: secret.kind, allowedOrigins: [...secret.allowedOrigins], createdAt: secret.createdAt, updatedAt: secret.updatedAt };
1137
+ function protectRelativeUrl(rawUrl, protect, protectPath) {
1138
+ const queryStart = rawUrl.indexOf("?");
1139
+ if (queryStart < 0)
1140
+ return protectPath(rawUrl);
1141
+ const protectedQuery = protectSearchParams(new URLSearchParams(rawUrl.slice(queryStart + 1)), protect).toString();
1142
+ return restoreReferencePlaceholders(`${protectPath(rawUrl.slice(0, queryStart))}?${protectedQuery}`);
1028
1143
  }
1029
- var readSecret = Effect2.fn("SiteSecrets.read")(function* (name, options = {}) {
1030
- const filePath = yield* secretPath(options.baseDir ?? defaultSiteSecretsDir(), name);
1031
- const text = yield* Effect2.tryPromise({
1032
- try: () => fs3.readFile(filePath, "utf8"),
1033
- catch: (cause) => isNodeError(cause) && cause.code === "ENOENT" ? new SiteSecretError({ message: `Secret not found: ${name}. Ask the user to add it with \`opencode-browser secrets add ${name} --origin <origin>\`.`, reason: "not-found" }) : new SiteSecretError({ message: `Could not read secret ${name}`, reason: "read-failed", cause })
1034
- });
1035
- return yield* Effect2.try({
1036
- try: () => Schema2.decodeUnknownSync(StoredSiteSecret)(JSON.parse(text)),
1037
- catch: (cause) => new SiteSecretError({ message: `Secret ${name} is not a valid secret file`, reason: "read-failed", cause })
1038
- });
1039
- });
1040
- var addSecret = Effect2.fn("SiteSecrets.add")(function* (options) {
1041
- const baseDir = options.baseDir ?? defaultSiteSecretsDir();
1042
- const filePath = yield* secretPath(baseDir, options.name);
1043
- const kind = options.kind ?? "password";
1044
- const allowedOrigins = yield* Effect2.try({
1045
- try: () => normalizeOriginPatterns(options.allowedOrigins),
1046
- catch: (cause) => new SiteSecretError({ message: cause instanceof Error ? cause.message : "Invalid origins", reason: "invalid-origins", cause })
1047
- });
1048
- if (allowedOrigins.length === 0) {
1049
- return yield* new SiteSecretError({ message: "A secret needs at least one allowed origin (--origin https://example.com)", reason: "invalid-origins" });
1050
- }
1051
- const value = kind === "totp" ? options.value.trim() : options.value.replace(/\r?\n$/, "");
1052
- if (!value)
1053
- return yield* new SiteSecretError({ message: "The secret value is empty", reason: "invalid-value" });
1054
- if (kind === "totp")
1055
- yield* Effect2.try({
1056
- try: () => totpCode(value),
1057
- catch: (cause) => new SiteSecretError({ message: `Not a TOTP seed or otpauth:// URI: ${cause instanceof Error ? cause.message : "invalid"}`, reason: "invalid-value", cause })
1058
- });
1059
- const write = Effect2.gen(function* () {
1060
- const existing = yield* readSecret(options.name, { baseDir }).pipe(Effect2.catchIf((error) => error.reason === "not-found", () => Effect2.succeed(undefined)));
1061
- const now = new Date().toISOString();
1062
- const secret = { version: 1, name: options.name, kind, value, allowedOrigins: [...allowedOrigins], createdAt: existing?.createdAt ?? now, updatedAt: now };
1063
- yield* Effect2.tryPromise({
1064
- try: () => writeJsonFileAtomically(filePath, secret, { dirMode: 448 }),
1065
- catch: (cause) => new SiteSecretError({ message: `Could not write secret ${options.name}`, reason: "write-failed", cause })
1066
- });
1067
- return summary2(secret);
1068
- });
1069
- return yield* withLock(options.name, { baseDir }, write).pipe(Effect2.mapError((error) => error instanceof SiteSecretError ? error : new SiteSecretError({ message: error.message, reason: "write-failed", cause: error })));
1070
- });
1071
- var listSecrets = Effect2.fn("SiteSecrets.list")(function* (options = {}) {
1072
- const baseDir = options.baseDir ?? defaultSiteSecretsDir();
1073
- const names = yield* Effect2.tryPromise({
1074
- try: () => fs3.readdir(baseDir).catch((cause) => isNodeError(cause) && cause.code === "ENOENT" ? [] : Promise.reject(cause)),
1075
- catch: (cause) => new SiteSecretError({ message: "Could not list secrets", reason: "read-failed", cause })
1076
- });
1077
- const secrets = [];
1078
- for (const file of names.filter((entry) => entry.endsWith(".json")).sort()) {
1079
- const secret = yield* readSecret(file.slice(0, -".json".length), { baseDir }).pipe(Effect2.option);
1080
- if (secret._tag === "Some")
1081
- secrets.push(summary2(secret.value));
1082
- }
1083
- return secrets;
1084
- });
1085
- var removeSecret = Effect2.fn("SiteSecrets.remove")(function* (name, options = {}) {
1086
- const baseDir = options.baseDir ?? defaultSiteSecretsDir();
1087
- const filePath = yield* secretPath(baseDir, name);
1088
- return yield* withLock(name, { baseDir }, Effect2.tryPromise({
1089
- try: () => fs3.rm(filePath).then(() => true, (cause) => isNodeError(cause) && cause.code === "ENOENT" ? false : Promise.reject(cause)),
1090
- catch: (cause) => new SiteSecretError({ message: `Could not remove secret ${name}`, reason: "write-failed", cause })
1091
- })).pipe(Effect2.mapError((error) => error instanceof SiteSecretError ? error : new SiteSecretError({ message: error.message, reason: "write-failed", cause: error })));
1092
- });
1093
- function base32Decode(input) {
1094
- const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1095
- const clean = input.toUpperCase().replace(/[\s=-]/g, "");
1096
- if (!clean || /[^A-Z2-7]/.test(clean))
1097
- throw new Error("the seed must be base32");
1098
- let bits = 0;
1099
- let value = 0;
1100
- const bytes = [];
1101
- for (const char of clean) {
1102
- value = value << 5 | alphabet.indexOf(char);
1103
- bits += 5;
1104
- if (bits >= 8) {
1105
- bytes.push(value >>> bits - 8 & 255);
1106
- bits -= 8;
1107
- }
1144
+ function protectSearchParams(params, protect) {
1145
+ const result = new URLSearchParams;
1146
+ const occurrences = new Map;
1147
+ for (const [name, value] of params) {
1148
+ const occurrence = occurrences.get(name) ?? 0;
1149
+ occurrences.set(name, occurrence + 1);
1150
+ result.append(name, value && secretNamePattern.test(name) ? protect(name, value, occurrence) : value);
1108
1151
  }
1109
- return Buffer.from(bytes);
1152
+ return result;
1110
1153
  }
1111
- function totpParameters(seed) {
1112
- if (/^otpauth:\/\//i.test(seed)) {
1113
- const url = new URL(seed);
1114
- const secret = url.searchParams.get("secret");
1115
- if (!secret)
1116
- throw new Error("the otpauth URI has no secret");
1117
- const digits = Number(url.searchParams.get("digits") ?? 6);
1118
- const period = Number(url.searchParams.get("period") ?? 30);
1119
- const algorithm = (url.searchParams.get("algorithm") ?? "SHA1").toLowerCase();
1120
- if (![6, 7, 8].includes(digits) || !Number.isInteger(period) || period <= 0 || !["sha1", "sha256", "sha512"].includes(algorithm)) {
1121
- throw new Error("unsupported otpauth parameters");
1122
- }
1123
- return { key: base32Decode(secret), digits, period, algorithm };
1124
- }
1125
- return { key: base32Decode(seed), digits: 6, period: 30, algorithm: "sha1" };
1154
+ function protectFragment(fragment, protect) {
1155
+ const occurrences = new Map;
1156
+ return fragment.split("&").map((part) => {
1157
+ const separator = part.indexOf("=");
1158
+ if (separator < 1)
1159
+ return part;
1160
+ const entry = new URLSearchParams(part).entries().next().value;
1161
+ if (!entry)
1162
+ return part;
1163
+ const [name, value] = entry;
1164
+ if (!/^[\w.-]+$/.test(name) || !secretNamePattern.test(name))
1165
+ return part;
1166
+ const occurrence = occurrences.get(name) ?? 0;
1167
+ occurrences.set(name, occurrence + 1);
1168
+ return value ? `${part.slice(0, separator + 1)}${protect(name, value, occurrence)}` : part;
1169
+ }).join("&");
1126
1170
  }
1127
- function totpCode(seed, now = Date.now()) {
1128
- const { key, digits, period, algorithm } = totpParameters(seed);
1129
- const counter = Buffer.alloc(8);
1130
- counter.writeBigUInt64BE(BigInt(Math.floor(now / 1000 / period)));
1131
- const hmac = crypto2.createHmac(algorithm, key).update(counter).digest();
1132
- const offset = hmac[hmac.length - 1] & 15;
1133
- const binary = (hmac.readUInt32BE(offset) & 2147483647) % 10 ** digits;
1134
- return String(binary).padStart(digits, "0");
1171
+ function sourceName(requestScope, location, occurrence = 0) {
1172
+ const base = requestScope ? `${requestScope}.${location}` : location;
1173
+ return occurrence === 0 ? base : `${base}.${occurrence}`;
1135
1174
  }
1136
- function secretText(secret) {
1137
- return secret.kind === "totp" ? totpCode(secret.value) : secret.value;
1175
+ function redactKnownValues(text, slots) {
1176
+ return [...slots].sort((left, right) => right.value.length - left.value.length).reduce((output, slot) => replaceOutsideReferences(output, slot.value, `\${${slot.ref}}`), text);
1138
1177
  }
1139
-
1140
- // src/deadbolt.ts
1141
- import { spawn as spawn2, spawnSync } from "node:child_process";
1142
- import crypto3 from "node:crypto";
1143
- import fs4 from "node:fs";
1144
- import net2 from "node:net";
1145
- import os from "node:os";
1146
- import path4 from "node:path";
1147
- class DeadboltError extends Error {
1148
- name = "DeadboltError";
1149
- code;
1150
- detail;
1151
- constructor(code, message, detail = {}) {
1152
- super(message);
1153
- this.code = code;
1154
- this.detail = detail;
1178
+ function redactKnownValue(value, slots) {
1179
+ if (Predicate2.isString(value))
1180
+ return redactKnownValues(value, slots);
1181
+ if (Predicate2.isNumber(value) || Predicate2.isBoolean(value)) {
1182
+ const slot = slots.find((candidate) => candidate.value === String(value));
1183
+ return slot ? `\${${slot.ref}}` : value;
1184
+ }
1185
+ if (Array.isArray(value))
1186
+ return value.map((item) => redactKnownValue(item, slots));
1187
+ if (!Predicate2.isObject(value))
1188
+ return value;
1189
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1190
+ key,
1191
+ secretNamePattern.test(key) ? redactSecretValue(item) : redactKnownValue(item, slots)
1192
+ ]));
1193
+ }
1194
+ function redactSecretValue(value) {
1195
+ if (Array.isArray(value))
1196
+ return value.map(redactSecretValue);
1197
+ if (Predicate2.isObject(value)) {
1198
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactSecretValue(item)]));
1155
1199
  }
1200
+ return value === null || value === "" ? value : "[REDACTED]";
1156
1201
  }
1157
- var clientFileName = "deadbolt-client.json";
1158
- function clientFilePath(root = dataRoot()) {
1159
- return path4.join(root, clientFileName);
1202
+ function replaceOutsideReferences(text, value, replacement) {
1203
+ if (!value)
1204
+ return text;
1205
+ const references = new RegExp(String.raw`\$\{${referencePattern}\}`, "g");
1206
+ let output = "";
1207
+ let offset = 0;
1208
+ for (const match of text.matchAll(references)) {
1209
+ const index = match.index ?? 0;
1210
+ output += text.slice(offset, index).split(value).join(replacement);
1211
+ output += match[0];
1212
+ offset = index + match[0].length;
1213
+ }
1214
+ return output + text.slice(offset).split(value).join(replacement);
1160
1215
  }
1161
- function readClientFile(root = dataRoot()) {
1162
- let text;
1163
- try {
1164
- const file = clientFilePath(root);
1165
- const stat = fs4.statSync(file);
1166
- if (process.platform !== "win32" && (stat.mode & 63) !== 0)
1167
- fs4.chmodSync(file, 384);
1168
- text = fs4.readFileSync(file, "utf8");
1169
- } catch {
1216
+ function jwtExpiration(value) {
1217
+ const parts = value.split(".");
1218
+ if (parts.length !== 3 || !parts[1])
1170
1219
  return;
1171
- }
1172
1220
  try {
1173
- const value = JSON.parse(text);
1174
- if (typeof value.id !== "string" || !value.id || typeof value.token !== "string" || !value.token)
1175
- return;
1176
- return {
1177
- version: 1,
1178
- id: value.id,
1179
- token: value.token,
1180
- connectedAt: typeof value.connectedAt === "string" ? value.connectedAt : ""
1181
- };
1221
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
1222
+ return Predicate2.isNumber(payload.exp) ? new Date(payload.exp * 1000).toISOString() : undefined;
1182
1223
  } catch {
1183
1224
  return;
1184
1225
  }
1185
1226
  }
1186
- function readClient(root = dataRoot()) {
1187
- const file = readClientFile(root);
1188
- return file ? { id: file.id, token: file.token } : undefined;
1189
- }
1190
- async function saveClient(client, root = dataRoot()) {
1191
- const value = { version: 1, id: client.id, token: client.token, connectedAt: new Date().toISOString() };
1192
- await writeJsonFileAtomically(clientFilePath(root), value, { dirMode: 448 });
1193
- }
1194
- function removeClient(root = dataRoot()) {
1195
- const file = clientFilePath(root);
1196
- if (!fs4.existsSync(file))
1197
- return false;
1198
- fs4.rmSync(file, { force: true });
1199
- return true;
1227
+
1228
+ // src/auth/profile.ts
1229
+ var StoredCredentialSlot = Schema.Struct({
1230
+ ref: Schema.String.check(Schema.isPattern(/^BC_SECRET_[1-9][0-9]*$/)),
1231
+ value: Schema.String,
1232
+ sources: Schema.Array(Schema.String),
1233
+ expiresAt: Schema.optionalKey(Schema.String)
1234
+ });
1235
+ var StoredAuthProfile = Schema.Struct({
1236
+ version: Schema.Literal(1),
1237
+ name: Schema.String,
1238
+ createdAt: Schema.String,
1239
+ updatedAt: Schema.String,
1240
+ slots: Schema.Array(StoredCredentialSlot)
1241
+ }).check(Schema.makeFilter((profile) => {
1242
+ const refs = profile.slots.map((slot) => slot.ref);
1243
+ if (new Set(refs).size !== refs.length)
1244
+ return "Auth profile refs must be unique";
1245
+ const sources = profile.slots.flatMap((slot) => slot.sources);
1246
+ return new Set(sources).size === sources.length ? undefined : "Auth profile sources must be unique";
1247
+ }));
1248
+
1249
+ class AuthProfileError extends Schema.TaggedError()("AuthProfile.Error", {
1250
+ message: Schema.String,
1251
+ operation: Schema.String,
1252
+ reason: Schema.Literals(["invalid-name", "not-found", "read-failed", "invalid-json", "invalid-profile", "write-failed", "run-failed"]),
1253
+ cause: Schema.optionalKey(Schema.Defect())
1254
+ }) {
1200
1255
  }
1201
- function computedSocketPath(env = process.env) {
1202
- const nonEmpty = (name) => env[name] ? env[name] : undefined;
1203
- const deadboltHome = nonEmpty("DEADBOLT_HOME");
1204
- const suffix = deadboltHome ? `-${blake3Hex(Buffer.from(deadboltHome, "utf8")).slice(0, 8)}` : "";
1205
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
1206
- const runtime = nonEmpty("XDG_RUNTIME_DIR") ?? userRuntimeDir(uid);
1207
- const directory = runtime ? path4.join(runtime, `deadbolt${suffix}`) : path4.join(nonEmpty("TMPDIR") ?? os.tmpdir(), `deadbolt${suffix}-${uid}`);
1208
- return path4.join(directory, "agent.sock");
1256
+ var defaultBaseDir = () => path2.join(dataRoot(), "secrets");
1257
+ var writeLock = Semaphore.makeUnsafe(1);
1258
+ var profileLockTimeoutMs = 30000;
1259
+ var staleProfileLockMs = 60000;
1260
+ var maximumRunOutputBytes = 1e7;
1261
+ var maximumTimeoutMs = 2147483647;
1262
+ var read = Effect2.fn("AuthProfile.read")(function* (name, options = {}) {
1263
+ const filePath = yield* profilePath(options.baseDir ?? defaultBaseDir(), name);
1264
+ const text = yield* Effect2.tryPromise({
1265
+ try: () => fs2.readFile(filePath, "utf8"),
1266
+ catch: (cause) => isNodeError(cause) && cause.code === "ENOENT" ? new AuthProfileError({ message: `Auth profile not found: ${name}`, operation: "read", reason: "not-found", cause }) : new AuthProfileError({ message: `Could not read auth profile: ${name}`, operation: "read", reason: "read-failed", cause })
1267
+ });
1268
+ return yield* Schema.decodeUnknownEffect(StoredAuthProfile)(yield* Effect2.try({
1269
+ try: () => JSON.parse(text),
1270
+ catch: (cause) => new AuthProfileError({ message: `Auth profile is not valid JSON: ${name}`, operation: "read", reason: "invalid-json", cause })
1271
+ })).pipe(Effect2.mapError((cause) => cause instanceof AuthProfileError ? cause : new AuthProfileError({ message: `Auth profile has an invalid shape: ${name}`, operation: "read", reason: "invalid-profile", cause })));
1272
+ });
1273
+ var readOptional = Effect2.fn("AuthProfile.readOptional")(function* (name, options = {}) {
1274
+ return yield* read(name, options).pipe(Effect2.catchIf((error) => error.reason === "not-found", () => Effect2.succeed(undefined)));
1275
+ });
1276
+ function withLock(name, options, effect) {
1277
+ const baseDir = options.baseDir ?? defaultBaseDir();
1278
+ const acquire = Effect2.gen(function* () {
1279
+ const filePath = yield* profilePath(baseDir, name);
1280
+ const lockPath = `${filePath}.lock`;
1281
+ yield* Effect2.tryPromise({
1282
+ try: async () => {
1283
+ await fs2.mkdir(baseDir, { recursive: true, mode: 448 });
1284
+ await fs2.chmod(baseDir, 448);
1285
+ const deadline = Date.now() + profileLockTimeoutMs;
1286
+ while (true) {
1287
+ try {
1288
+ const handle = await fs2.open(lockPath, "wx", 384);
1289
+ let written = false;
1290
+ try {
1291
+ await handle.writeFile(`${process.pid}
1292
+ `);
1293
+ written = true;
1294
+ } finally {
1295
+ await handle.close();
1296
+ if (!written)
1297
+ await fs2.unlink(lockPath).catch(() => {});
1298
+ }
1299
+ return;
1300
+ } catch (cause) {
1301
+ if (!isNodeError(cause) || cause.code !== "EEXIST")
1302
+ throw cause;
1303
+ const stat = await fs2.stat(lockPath).catch(() => {
1304
+ return;
1305
+ });
1306
+ if (stat && Date.now() - stat.mtimeMs > staleProfileLockMs) {
1307
+ await fs2.unlink(lockPath).catch(() => {});
1308
+ continue;
1309
+ }
1310
+ if (Date.now() >= deadline)
1311
+ throw new Error(`Timed out waiting for auth profile lock: ${name}`);
1312
+ await new Promise((resolve) => setTimeout(resolve, 25));
1313
+ }
1314
+ }
1315
+ },
1316
+ catch: (cause) => new AuthProfileError({ message: `Could not lock auth profile: ${name}`, operation: "lock", reason: "write-failed", cause })
1317
+ });
1318
+ return lockPath;
1319
+ });
1320
+ return Effect2.acquireUseRelease(acquire, () => effect, (lockPath) => Effect2.tryPromise({
1321
+ try: () => fs2.unlink(lockPath).catch(() => {}).then(() => {}),
1322
+ catch: (cause) => new AuthProfileError({ message: `Could not release auth profile lock: ${name}`, operation: "lock", reason: "write-failed", cause })
1323
+ }));
1209
1324
  }
1210
- function userRuntimeDir(uid) {
1211
- const directory = `/run/user/${uid}`;
1212
- try {
1213
- const stat = fs4.statSync(directory);
1214
- return stat.isDirectory() && stat.uid === uid ? directory : undefined;
1215
- } catch {
1216
- return;
1325
+ var writeProfile = Effect2.fnUntraced(function* (options) {
1326
+ const baseDir = options.baseDir ?? defaultBaseDir();
1327
+ const filePath = yield* profilePath(baseDir, options.name);
1328
+ const existing = yield* readOptional(options.name, { baseDir });
1329
+ const now = new Date().toISOString();
1330
+ const profile = {
1331
+ version: 1,
1332
+ name: options.name,
1333
+ createdAt: existing?.createdAt ?? now,
1334
+ updatedAt: now,
1335
+ slots: options.slots.map((slot) => ({
1336
+ ref: slot.ref,
1337
+ value: slot.value,
1338
+ sources: [...slot.sources],
1339
+ ...slot.expiresAt ? { expiresAt: slot.expiresAt } : {}
1340
+ }))
1341
+ };
1342
+ yield* Schema.decodeUnknownEffect(StoredAuthProfile)(profile).pipe(Effect2.mapError((cause) => new AuthProfileError({ message: `Auth profile has an invalid shape: ${options.name}`, operation: "write", reason: "invalid-profile", cause })));
1343
+ if (existing && sameSlots(existing.slots, profile.slots))
1344
+ return summary(existing);
1345
+ yield* Effect2.tryPromise({
1346
+ try: () => writeJsonFileAtomically(filePath, profile, { dirMode: 448 }),
1347
+ catch: (cause) => new AuthProfileError({ message: `Could not write auth profile: ${options.name}`, operation: "write", reason: "write-failed", cause })
1348
+ });
1349
+ return summary(profile);
1350
+ });
1351
+ var write = Effect2.fn("AuthProfile.write")(function* (options) {
1352
+ return yield* writeLock.withPermit(writeProfile(options));
1353
+ });
1354
+ var status = Effect2.fn("AuthProfile.status")(function* (name, options = {}) {
1355
+ return summary(yield* read(name, options));
1356
+ });
1357
+ var run = Effect2.fn("AuthProfile.run")(function* (options) {
1358
+ if (!options.command.trim()) {
1359
+ return yield* new AuthProfileError({ message: "Auth command must not be empty", operation: "run", reason: "run-failed" });
1360
+ }
1361
+ const timeoutMs = options.timeoutMs ?? 120000;
1362
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > maximumTimeoutMs) {
1363
+ return yield* new AuthProfileError({ message: `Auth command timeout must be an integer between 1 and ${maximumTimeoutMs} milliseconds`, operation: "run", reason: "run-failed" });
1364
+ }
1365
+ const maxOutputBytes = options.maxOutputBytes ?? 1e6;
1366
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0 || maxOutputBytes > maximumRunOutputBytes) {
1367
+ return yield* new AuthProfileError({ message: `Auth command output limit must be an integer between 0 and ${maximumRunOutputBytes} bytes`, operation: "run", reason: "run-failed" });
1217
1368
  }
1369
+ const profile = yield* read(options.name, { ...options.baseDir ? { baseDir: options.baseDir } : {} });
1370
+ const startedAt = Date.now();
1371
+ const maxSecretBytes = profile.slots.reduce((max, slot) => Math.max(max, Buffer.byteLength(slot.value)), 0);
1372
+ const result = yield* runChild({
1373
+ command: options.command,
1374
+ args: options.args ?? [],
1375
+ ...options.cwd ? { cwd: options.cwd } : {},
1376
+ timeoutMs,
1377
+ maxOutputBytes: maxOutputBytes + maxSecretBytes,
1378
+ env: Object.fromEntries(profile.slots.map((slot) => [slot.ref, slot.value]))
1379
+ }).pipe(Effect2.mapError((cause) => new AuthProfileError({ message: `Could not run command with auth profile ${options.name}`, operation: "run", reason: "run-failed", cause })));
1380
+ const stdout = redactOutput(result.stdout, profile.slots, maxOutputBytes);
1381
+ const stderr = redactOutput(result.stderr, profile.slots, maxOutputBytes);
1382
+ return {
1383
+ ...result,
1384
+ stdout: stdout.text,
1385
+ stderr: stderr.text,
1386
+ stdoutTruncated: result.stdoutTruncated || stdout.truncated,
1387
+ stderrTruncated: result.stderrTruncated || stderr.truncated,
1388
+ durationMs: Date.now() - startedAt
1389
+ };
1390
+ });
1391
+ function summary(profile) {
1392
+ const now = Date.now();
1393
+ return {
1394
+ name: profile.name,
1395
+ createdAt: profile.createdAt,
1396
+ updatedAt: profile.updatedAt,
1397
+ slotCount: profile.slots.length,
1398
+ slots: profile.slots.map((slot) => ({
1399
+ ref: slot.ref,
1400
+ sources: [...slot.sources],
1401
+ ...slot.expiresAt ? { expiresAt: slot.expiresAt } : {},
1402
+ expired: slot.expiresAt ? Date.parse(slot.expiresAt) <= now : false
1403
+ }))
1404
+ };
1218
1405
  }
1219
- function socketPath(options = {}) {
1220
- const env = options.env ?? process.env;
1221
- return env.OPENCODE_BROWSER_DEADBOLT_SOCKET || computedSocketPath(env);
1406
+ var profileNamePattern = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
1407
+ function profilePath(baseDir, name) {
1408
+ return profileNamePattern.test(name) ? Effect2.succeed(path2.join(baseDir, `${name}.json`)) : Effect2.fail(new AuthProfileError({ message: `Invalid auth profile name: ${name}`, operation: "path", reason: "invalid-name" }));
1222
1409
  }
1223
- var requestTimeoutMs = 15000;
1224
- var maxReplyBytes = 4 * 1024 * 1024;
1225
- function errorCode(code) {
1226
- const normalized = typeof code === "string" ? code.replace(/-/g, "_") : "";
1227
- switch (normalized) {
1228
- case "locked":
1229
- case "not_granted":
1230
- case "forbidden":
1231
- case "rate_limited":
1232
- case "domain_mismatch":
1233
- case "not_found":
1234
- case "choose_passkey":
1235
- return normalized;
1236
- case "not_initialized":
1237
- return "locked";
1238
- default:
1239
- return "failed";
1410
+ function sameSlots(left, right) {
1411
+ return JSON.stringify(left) === JSON.stringify(right);
1412
+ }
1413
+ function redactOutput(text, slots, maxBytes) {
1414
+ const bytes = Buffer.from(text);
1415
+ let cutoff = Math.min(bytes.length, maxBytes);
1416
+ let changed = true;
1417
+ while (changed) {
1418
+ changed = false;
1419
+ for (const slot of slots) {
1420
+ if (!slot.value)
1421
+ continue;
1422
+ const secret = Buffer.from(slot.value);
1423
+ let start = bytes.indexOf(secret);
1424
+ while (start >= 0 && start < cutoff) {
1425
+ if (start + secret.length > cutoff) {
1426
+ cutoff = start;
1427
+ changed = true;
1428
+ break;
1429
+ }
1430
+ start = bytes.indexOf(secret, start + secret.length);
1431
+ }
1432
+ }
1240
1433
  }
1434
+ return {
1435
+ text: redactKnownValues(bytes.subarray(0, cutoff).toString("utf8"), slots),
1436
+ truncated: cutoff < bytes.length
1437
+ };
1241
1438
  }
1242
- var errorHints = {
1243
- locked: "Deadbolt is locked, so agent sign-ins are paused until the user unlocks it.",
1244
- forbidden: "Deadbolt doesn't know this Browse install (its client was removed or revoked). Ask the user to run `opencode-browser deadbolt connect`.",
1245
- rate_limited: "Deadbolt is rate-limiting this client; wait before trying again."
1246
- };
1247
- function rawRequest(socket, request, timeoutMs = requestTimeoutMs) {
1248
- return new Promise((resolve, reject) => {
1249
- const connection = net2.createConnection(socket);
1250
- let buffer = "";
1439
+ function runChild(options) {
1440
+ return Effect2.callback((resume) => {
1441
+ const child = spawn2(options.command, [...options.args], {
1442
+ ...options.cwd ? { cwd: options.cwd } : {},
1443
+ env: childEnvironment(options.env),
1444
+ detached: process.platform !== "win32",
1445
+ stdio: ["ignore", "pipe", "pipe"]
1446
+ });
1447
+ const stdout = [];
1448
+ const stderr = [];
1449
+ let stdoutBytes = 0;
1450
+ let stderrBytes = 0;
1451
+ let stdoutTruncated = false;
1452
+ let stderrTruncated = false;
1453
+ const append = (chunks, chunk, currentBytes) => {
1454
+ const remaining = options.maxOutputBytes - currentBytes;
1455
+ if (remaining <= 0)
1456
+ return currentBytes;
1457
+ chunks.push(chunk.subarray(0, remaining));
1458
+ return currentBytes + Math.min(chunk.length, remaining);
1459
+ };
1460
+ child.stdout.on("data", (chunk) => {
1461
+ const next = append(stdout, chunk, stdoutBytes);
1462
+ stdoutTruncated ||= next - stdoutBytes < chunk.length;
1463
+ stdoutBytes = next;
1464
+ });
1465
+ child.stderr.on("data", (chunk) => {
1466
+ const next = append(stderr, chunk, stderrBytes);
1467
+ stderrTruncated ||= next - stderrBytes < chunk.length;
1468
+ stderrBytes = next;
1469
+ });
1470
+ let closed = false;
1251
1471
  let settled = false;
1252
- const finish = (error, value) => {
1472
+ let closePromiseResolve;
1473
+ const closePromise = new Promise((resolve) => {
1474
+ closePromiseResolve = resolve;
1475
+ });
1476
+ const timeout = setTimeout(() => {
1477
+ terminateChildProcess({ child, exit: closePromise, graceMs: 1000, isExited: () => closed });
1478
+ }, options.timeoutMs);
1479
+ child.once("error", (cause) => {
1480
+ clearTimeout(timeout);
1481
+ closed = true;
1482
+ closePromiseResolve?.();
1253
1483
  if (settled)
1254
1484
  return;
1255
1485
  settled = true;
1256
- clearTimeout(timer);
1257
- connection.destroy();
1258
- if (error)
1259
- reject(error);
1260
- else
1261
- resolve(value);
1262
- };
1263
- const timer = setTimeout(() => finish(new DeadboltError("unavailable", `Deadbolt did not answer ${String(request.cmd)} within ${timeoutMs} ms`)), timeoutMs);
1264
- connection.setEncoding("utf8");
1265
- connection.on("connect", () => connection.write(`${JSON.stringify(request)}
1266
- `));
1267
- connection.on("error", (cause) => {
1268
- const missing = cause.code === "ENOENT" || cause.code === "ECONNREFUSED";
1269
- finish(new DeadboltError("unavailable", missing ? "Deadbolt isn't running (no agent socket). Ask the user to start or unlock Deadbolt." : `Could not reach Deadbolt: ${cause.message}`));
1486
+ resume(Effect2.fail(cause));
1270
1487
  });
1271
- connection.on("data", (chunk) => {
1272
- buffer += chunk;
1273
- if (buffer.length > maxReplyBytes)
1274
- return finish(new DeadboltError("failed", "Deadbolt's reply was too large"));
1275
- let newline;
1276
- while ((newline = buffer.indexOf(`
1277
- `)) !== -1) {
1278
- const line = buffer.slice(0, newline).trim();
1279
- buffer = buffer.slice(newline + 1);
1280
- if (!line)
1281
- continue;
1282
- let message;
1283
- try {
1284
- message = JSON.parse(line);
1285
- } catch {
1286
- return finish(new DeadboltError("failed", "Deadbolt sent a reply that isn't JSON"));
1287
- }
1288
- if (message.type === "ok")
1289
- return finish(undefined, message.reply ?? {});
1290
- if (message.type === "error") {
1291
- const code = errorCode(message.code);
1292
- const text = typeof message.message === "string" ? message.message : String(message.code);
1293
- const detail = message.grant ?? {};
1294
- const choices = passkeyList(message.choices ?? message.passkeys);
1295
- return finish(new DeadboltError(code, errorHints[code] ? `${text.replace(/[\s.]*$/, ".")} ${errorHints[code]}` : text, {
1296
- ...typeof detail.site === "string" ? { site: detail.site } : {},
1297
- ...typeof detail.login === "string" ? { login: detail.login } : {},
1298
- ...choices.length > 0 ? { choices } : {}
1299
- }));
1300
- }
1301
- }
1488
+ child.once("close", (code, signal) => {
1489
+ clearTimeout(timeout);
1490
+ closed = true;
1491
+ closePromiseResolve?.();
1492
+ if (settled)
1493
+ return;
1494
+ settled = true;
1495
+ resume(Effect2.succeed({
1496
+ exitCode: code ?? 1,
1497
+ signal,
1498
+ stdout: Buffer.concat(stdout).toString("utf8"),
1499
+ stderr: Buffer.concat(stderr).toString("utf8"),
1500
+ stdoutTruncated,
1501
+ stderrTruncated
1502
+ }));
1302
1503
  });
1303
- connection.on("end", () => finish(new DeadboltError("failed", "Deadbolt closed the connection without a reply")));
1304
- });
1305
- }
1306
- var str = (value) => typeof value === "string" ? value : undefined;
1307
- function passkeyList(value) {
1308
- if (!Array.isArray(value))
1309
- return [];
1310
- return value.filter((entry) => entry !== null && typeof entry === "object").map((entry) => {
1311
- const username = str(entry.username);
1312
- const displayName = str(entry.display_name) ?? str(entry.displayName);
1313
- return { ref: entry.ref ?? entry.login, ...username ? { username } : {}, ...displayName ? { displayName } : {}, granted: entry.granted !== false };
1504
+ return Effect2.promise(() => terminateChildProcess({ child, exit: closePromise, graceMs: 1000, isExited: () => closed }));
1314
1505
  });
1315
1506
  }
1316
- function toLogin(raw) {
1317
- const signIn = raw.sign_in;
1318
- const fields = Array.isArray(raw.fields) ? raw.fields.filter((field) => typeof field === "string") : undefined;
1319
- const username = str(raw.username);
1320
- return {
1321
- ref: raw.ref ?? raw.login,
1322
- title: str(raw.title) ?? "",
1323
- ...username ? { username } : {},
1324
- hasPassword: raw.has_password === true,
1325
- hasTotp: raw.has_totp === true || raw.has_otp === true,
1326
- hasPasskey: raw.has_passkey === true,
1327
- granted: fields !== undefined && fields.length > 0 || raw.passkeys === true,
1328
- passkeys: raw.passkeys === true,
1329
- ...fields ? { fields } : {},
1330
- ...signIn && typeof signIn.provider === "string" ? { signIn: { provider: signIn.provider, ...str(signIn.account) ? { account: str(signIn.account) } : {} } } : {}
1331
- };
1332
- }
1333
- function connect(options = {}) {
1334
- const client = options.client ?? readClient(options.root);
1335
- if (!client) {
1336
- throw new DeadboltError("not_connected", "Browse isn't connected to Deadbolt. Ask the user to run `opencode-browser deadbolt connect` and to allow the logins agents may use with `deadbolt agents allow <site>`.");
1337
- }
1338
- const socket = options.socket ?? socketPath();
1339
- let protocol;
1340
- const version = async () => {
1341
- if (protocol === undefined) {
1342
- const status = await rawRequest(socket, { v: 0, cmd: "status" });
1343
- protocol = typeof status.protocol === "number" ? status.protocol : 0;
1344
- }
1345
- return protocol;
1346
- };
1347
- const send = async (cmd, fields) => rawRequest(socket, { v: await version(), cmd, client: { id: client.id, token: client.token }, ...fields });
1348
- const site = async (origin) => {
1349
- const reply = await send("agent-logins", { origin });
1350
- const raw = (Array.isArray(reply.logins) ? reply.logins : []).filter((login) => login !== null && typeof login === "object");
1351
- const logins = raw.map(toLogin);
1352
- let passkeys = passkeyList(reply.passkeys);
1353
- if (!Array.isArray(reply.passkeys)) {
1354
- passkeys = logins.filter((login) => login.hasPasskey).map((login) => ({ ref: login.ref, ...login.username ? { username: login.username } : {}, granted: login.passkeys }));
1355
- }
1356
- return { logins, passkeys };
1357
- };
1358
- return {
1359
- socket,
1360
- client,
1361
- status: async () => {
1362
- const status = await rawRequest(socket, { v: 0, cmd: "status" });
1363
- protocol = typeof status.protocol === "number" ? status.protocol : 0;
1364
- return { protocol, unlocked: status.unlocked === true, ...str(status.version) ? { version: str(status.version) } : {} };
1365
- },
1366
- logins: async (origin) => (await site(origin)).logins,
1367
- site: (origin) => site(origin),
1368
- fill: async (origin, login, want) => {
1369
- const reply = await send("agent-fill", { origin, login: login.ref, want: [...want] });
1370
- const values = reply.values ?? reply;
1371
- return {
1372
- ...str(values.username) !== undefined ? { username: str(values.username) } : {},
1373
- ...str(values.password) !== undefined ? { password: str(values.password) } : {},
1374
- ...str(values.totp) !== undefined ? { totp: str(values.totp) } : {}
1375
- };
1376
- },
1377
- passkeyGet: async (request) => {
1378
- const reply = await send("agent-passkey-get", {
1379
- origin: request.origin,
1380
- ...request.rpId ? { rp_id: request.rpId } : {},
1381
- challenge: request.challenge,
1382
- allow_credentials: [...request.allowCredentials],
1383
- user_verification: request.userVerification,
1384
- ...request.login !== undefined ? { login: request.login } : {}
1385
- });
1386
- if (reply.reply === "choose-passkey") {
1387
- throw new DeadboltError("choose_passkey", "Several passkeys could sign in here; name one by its username.", { choices: passkeyList(reply.choices ?? reply.passkeys) });
1388
- }
1389
- const credentialId = str(reply.credential_id);
1390
- const clientDataJson = str(reply.client_data_json);
1391
- const authenticatorData = str(reply.authenticator_data);
1392
- const signature = str(reply.signature);
1393
- if (!credentialId || !clientDataJson || !authenticatorData || !signature)
1394
- throw new DeadboltError("failed", "Deadbolt's passkey assertion is incomplete");
1395
- return { credentialId, clientDataJson, authenticatorData, signature, ...str(reply.user_handle) ? { userHandle: str(reply.user_handle) } : {} };
1396
- }
1397
- };
1507
+ function childEnvironment(profile) {
1508
+ const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^BC_SECRET_[1-9][0-9]*$/.test(name)));
1509
+ return { ...inherited, ...profile };
1398
1510
  }
1399
- var deadboltNamePrefix = "deadbolt:";
1400
- function parseDeadboltName(name) {
1401
- if (!name.startsWith(deadboltNamePrefix))
1402
- return;
1403
- const rest = name.slice(deadboltNamePrefix.length).trim();
1404
- const origin = /^https?:\/\/[^/]+/i.exec(rest);
1405
- const site = origin ? origin[0] : rest.split("/")[0];
1406
- const username = rest.slice(site.length + 1);
1407
- if (!site)
1408
- return;
1409
- return { site: site.toLowerCase(), ...username ? { username } : {} };
1511
+
1512
+ // src/auth/domain-policy.ts
1513
+ import dns from "node:dns/promises";
1514
+ import net2 from "node:net";
1515
+
1516
+ class OriginPatternError extends Error {
1517
+ name = "OriginPatternError";
1410
1518
  }
1411
- function originOnSite(origin, site) {
1412
- let url;
1519
+ var maxPatterns = 100;
1520
+ function parseOriginPattern(raw) {
1521
+ const pattern = raw.trim();
1522
+ if (!pattern)
1523
+ throw new OriginPatternError("Empty origin pattern");
1524
+ if (pattern === "*" || pattern === "*.*")
1525
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} allows every site; leave the allowlist unset instead`);
1526
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(pattern);
1527
+ const scheme = schemeMatch?.[1]?.toLowerCase();
1528
+ if (scheme !== undefined && scheme !== "http" && scheme !== "https") {
1529
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: only http and https origins can be allowed`);
1530
+ }
1531
+ let rest = schemeMatch ? pattern.slice(schemeMatch[0].length) : pattern;
1532
+ if (/[/?#]/.test(rest.replace(/\/$/, "")))
1533
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} must be a host or origin, without a path`);
1534
+ rest = rest.replace(/\/$/, "");
1535
+ const wildcard = rest.startsWith("*.");
1536
+ if (wildcard)
1537
+ rest = rest.slice(2);
1538
+ if (rest.includes("*"))
1539
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: a wildcard is only allowed as a leading "*."`);
1540
+ let parsed;
1413
1541
  try {
1414
- url = new URL(origin);
1542
+ parsed = new URL(`${scheme ?? "http"}://${rest}`);
1415
1543
  } catch {
1416
- return false;
1544
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} is not a valid host or origin`);
1417
1545
  }
1418
- if (/^https?:\/\//i.test(site)) {
1419
- try {
1420
- return new URL(site).origin === url.origin;
1421
- } catch {
1422
- return false;
1423
- }
1546
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !parsed.hostname) {
1547
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)} must be a host or origin`);
1424
1548
  }
1425
- const host = url.hostname.toLowerCase();
1426
- const bare = site.replace(/^\*\./, "").replace(/:\d+$/, "").toLowerCase();
1427
- const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost");
1428
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
1429
- return false;
1430
- return host === bare || host.endsWith(`.${bare}`);
1549
+ const host = unbracket(parsed.hostname.toLowerCase());
1550
+ if (wildcard && net2.isIP(host))
1551
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: wildcards don't apply to IP addresses`);
1552
+ if (wildcard && !host.includes("."))
1553
+ throw new OriginPatternError(`Origin pattern ${JSON.stringify(raw)}: "*." needs a domain with a dot, like *.example.com`);
1554
+ const port = parsed.port || (scheme ? scheme === "https" ? "443" : "80" : undefined);
1555
+ return {
1556
+ pattern,
1557
+ ...scheme ? { scheme: `${scheme}:` } : {},
1558
+ host,
1559
+ wildcard,
1560
+ ...port ? { port } : {}
1561
+ };
1431
1562
  }
1432
- function pickLogin(logins, site, username, field) {
1433
- const granted = logins.filter((login) => login.granted && (field === undefined || login.fields === undefined || login.fields.includes(field)));
1434
- const candidates = username === undefined ? granted : granted.filter((login) => login.username?.toLowerCase() === username.toLowerCase());
1435
- if (candidates.length === 1)
1436
- return candidates[0];
1437
- if (candidates.length === 0) {
1438
- throw new DeadboltError("not_granted", username === undefined ? `No Deadbolt login is granted to Browse for ${site}${field ? ` (${field})` : ""}. Ask the user to allow one with \`deadbolt agents allow ${site}\`.` : `No Deadbolt login for ${username} is granted to Browse for ${site}${field ? ` (${field})` : ""}. Ask the user to allow it with \`deadbolt agents allow ${site} --login ${username}\`.`, { site, ...username ? { login: username } : {} });
1439
- }
1440
- throw new DeadboltError("failed", `Several Deadbolt logins are granted for ${site}; name one: deadbolt:${site}/<username> (${candidates.map((login) => login.username ?? login.title).join(", ")})`);
1563
+ function parseOriginPatterns(raw) {
1564
+ if (raw.length > maxPatterns)
1565
+ throw new OriginPatternError(`At most ${maxPatterns} origin patterns are allowed`);
1566
+ return raw.map(parseOriginPattern);
1441
1567
  }
1442
- function originsFromPatterns(patterns) {
1443
- const origins = new Set;
1444
- for (const pattern of patterns) {
1445
- const scheme = /^(https?):\/\//i.exec(pattern)?.[1]?.toLowerCase() ?? "https";
1446
- const host = pattern.replace(/^https?:\/\//i, "").replace(/^\*\./, "").replace(/\/$/, "");
1447
- try {
1448
- origins.add(new URL(`${scheme}://${host}`).origin);
1449
- } catch {}
1450
- }
1451
- return [...origins];
1568
+ function normalizeOriginPatterns(raw) {
1569
+ return Array.from(new Set(parseOriginPatterns(raw.flatMap((entry) => entry.split(",")).map((entry) => entry.trim()).filter(Boolean)).map((rule) => rule.pattern.toLowerCase())));
1452
1570
  }
1453
- async function listLogins(origins, connection) {
1454
- let deadbolt;
1455
- try {
1456
- deadbolt = connection ?? connect();
1457
- } catch (error) {
1458
- return { connected: false, logins: [], passkeys: [], error: { code: error instanceof DeadboltError ? error.code : "failed", message: error instanceof Error ? error.message : String(error) } };
1571
+ function unbracket(host) {
1572
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
1573
+ }
1574
+ function ipv4ToNumber(ip) {
1575
+ return ip.split(".").reduce((value, part) => value * 256 + Number(part), 0);
1576
+ }
1577
+ var privateIpv4Ranges = [
1578
+ ["0.0.0.0", 8],
1579
+ ["10.0.0.0", 8],
1580
+ ["100.64.0.0", 10],
1581
+ ["127.0.0.0", 8],
1582
+ ["169.254.0.0", 16],
1583
+ ["172.16.0.0", 12],
1584
+ ["192.0.0.0", 24],
1585
+ ["192.0.2.0", 24],
1586
+ ["192.168.0.0", 16],
1587
+ ["198.18.0.0", 15],
1588
+ ["198.51.100.0", 24],
1589
+ ["203.0.113.0", 24],
1590
+ ["224.0.0.0", 4],
1591
+ ["240.0.0.0", 4]
1592
+ ];
1593
+ function isPrivateAddress(address) {
1594
+ const ip = unbracket(address.toLowerCase()).replace(/%.*$/, "");
1595
+ if (net2.isIPv4(ip)) {
1596
+ const value = ipv4ToNumber(ip);
1597
+ return privateIpv4Ranges.some(([base, bits]) => {
1598
+ const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
1599
+ return (value & mask) >>> 0 === (ipv4ToNumber(base) & mask) >>> 0;
1600
+ });
1459
1601
  }
1460
- const logins = [];
1461
- const passkeys = [];
1462
- for (const origin of origins.slice(0, 20)) {
1463
- try {
1464
- const found = await deadbolt.site(origin);
1465
- for (const { ref: _ref, ...login } of found.logins)
1466
- logins.push({ ...login, origin });
1467
- for (const { ref: _ref, ...passkey } of found.passkeys)
1468
- passkeys.push({ ...passkey, origin });
1469
- } catch (error) {
1470
- if (error instanceof DeadboltError && (error.code === "domain_mismatch" || error.code === "not_found" || error.code === "not_granted"))
1471
- continue;
1472
- return { connected: true, logins, passkeys, error: { code: error instanceof DeadboltError ? error.code : "failed", message: error instanceof Error ? error.message : String(error) } };
1602
+ if (net2.isIPv6(ip)) {
1603
+ if (ip === "::" || ip === "::1")
1604
+ return true;
1605
+ const mapped = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(ip) ?? /^::(\d+\.\d+\.\d+\.\d+)$/.exec(ip);
1606
+ if (mapped?.[1])
1607
+ return isPrivateAddress(mapped[1]);
1608
+ const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(ip);
1609
+ if (hexMapped?.[1] && hexMapped[2]) {
1610
+ const high = parseInt(hexMapped[1], 16);
1611
+ const low = parseInt(hexMapped[2], 16);
1612
+ return isPrivateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
1473
1613
  }
1614
+ const first = parseInt(ip.split(":")[0] || "0", 16);
1615
+ if ((first & 65024) === 64512 || (first & 65472) === 65152 || (first & 65280) === 65280)
1616
+ return true;
1617
+ if (ip.startsWith("64:ff9b::"))
1618
+ return true;
1619
+ return false;
1474
1620
  }
1475
- return { connected: true, logins, passkeys };
1621
+ return false;
1476
1622
  }
1477
- function pickPasskey(passkeys, username) {
1478
- const wanted = username.toLowerCase();
1479
- const granted = passkeys.filter((passkey) => passkey.granted);
1480
- return granted.find((passkey) => passkey.username?.toLowerCase() === wanted) ?? granted.find((passkey) => passkey.displayName?.toLowerCase() === wanted);
1623
+ function isLocalhostName(host) {
1624
+ return host === "localhost" || host.endsWith(".localhost") || host === "localhost.localdomain";
1481
1625
  }
1482
- async function pair(options = {}) {
1483
- const command = options.command ?? (process.env.OPENCODE_BROWSER_DEADBOLT_CLI || "deadbolt");
1484
- const stdout = await new Promise((resolve, reject) => {
1485
- const child = spawn2(command, ["agents", "add-client", options.name ?? "browse", "--json"], { stdio: ["inherit", "pipe", "inherit"] });
1486
- let output = "";
1487
- child.stdout.setEncoding("utf8");
1488
- child.stdout.on("data", (chunk) => {
1489
- output += chunk;
1490
- });
1491
- child.on("error", (cause) => reject(new DeadboltError("unavailable", cause.code === "ENOENT" ? `${command} isn't installed or on PATH. Install Deadbolt first.` : `Could not run ${command}: ${cause.message}`)));
1492
- child.on("close", (code) => code === 0 ? resolve(output) : reject(new DeadboltError("failed", `deadbolt agents add-client exited with status ${code}`)));
1493
- });
1494
- let parsed;
1626
+ function isPrivateHost(host) {
1627
+ const bare = unbracket(host.toLowerCase());
1628
+ return isLocalhostName(bare) || net2.isIP(bare) !== 0 && isPrivateAddress(bare);
1629
+ }
1630
+ function defaultPort(protocol) {
1631
+ return protocol === "https:" ? "443" : "80";
1632
+ }
1633
+ function ruleMatches(rule, url, host, options) {
1634
+ if (rule.scheme !== undefined && rule.scheme !== url.protocol)
1635
+ return false;
1636
+ if (rule.scheme === undefined && options.httpsOnlyWithoutScheme && url.protocol !== "https:" && !isPrivateHost(host))
1637
+ return false;
1638
+ if (rule.port !== undefined && rule.port !== (url.port || defaultPort(url.protocol)))
1639
+ return false;
1640
+ if (rule.wildcard)
1641
+ return host === rule.host || host.endsWith(`.${rule.host}`);
1642
+ return host === rule.host;
1643
+ }
1644
+ function explicitlyAllowsHost(rules, url) {
1495
1645
  try {
1496
- const line = stdout.trim().split(`
1497
- `).filter(Boolean).at(-1) ?? "";
1498
- parsed = JSON.parse(line);
1646
+ const parsed = new URL(url);
1647
+ const host = unbracket(parsed.hostname.toLowerCase());
1648
+ return rules.some((rule) => !rule.wildcard && ruleMatches(rule, parsed, host, {}));
1499
1649
  } catch {
1500
- throw new DeadboltError("failed", "deadbolt agents add-client --json didn't print a client");
1650
+ return false;
1501
1651
  }
1502
- const client = parsed.client ?? parsed;
1503
- const id = str(client.id);
1504
- const token = str(client.token) ?? str(parsed.token);
1505
- if (!id || !token)
1506
- throw new DeadboltError("failed", "deadbolt agents add-client --json printed no id and token");
1507
- await saveClient({ id, token }, options.root);
1508
- return { id };
1509
- }
1510
- function unpair(options = {}) {
1511
- const client = readClient(options.root);
1512
- if (!client)
1513
- return { removed: false, revoked: false };
1514
- const command = options.command ?? (process.env.OPENCODE_BROWSER_DEADBOLT_CLI || "deadbolt");
1515
- const result = spawnSync(command, ["agents", "remove-client", client.id], { stdio: ["inherit", "ignore", "inherit"], timeout: 120000 });
1516
- return { removed: removeClient(options.root), revoked: result.status === 0 };
1517
- }
1518
- function isBase64Url(value, maxLength = 8192) {
1519
- return typeof value === "string" && value.length <= maxLength && /^[A-Za-z0-9_-]*$/.test(value);
1520
1652
  }
1521
- function randomName(prefix) {
1522
- return `${prefix}${crypto3.randomBytes(12).toString("hex")}`;
1653
+ var neutralSchemes = new Set(["about:", "data:", "blob:"]);
1654
+ function checkUrl(rawUrl, rules, options = {}) {
1655
+ let url;
1656
+ try {
1657
+ url = new URL(rawUrl);
1658
+ } catch {
1659
+ return { allowed: false, reason: `${JSON.stringify(rawUrl.slice(0, 200))} is not a valid URL` };
1660
+ }
1661
+ if (neutralSchemes.has(url.protocol))
1662
+ return { allowed: true };
1663
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1664
+ return { allowed: false, reason: `${url.protocol} URLs are not allowed for this session` };
1665
+ }
1666
+ const host = unbracket(url.hostname.toLowerCase());
1667
+ const matching = rules.filter((rule) => ruleMatches(rule, url, host, options));
1668
+ if (matching.length === 0) {
1669
+ return { allowed: false, reason: `${url.host} is not in the allowed origins (${rules.map((rule) => rule.pattern).join(", ") || "none"})` };
1670
+ }
1671
+ const explicit = matching.some((rule) => !rule.wildcard);
1672
+ if (isPrivateHost(host) && !explicit) {
1673
+ return { allowed: false, reason: `${url.host} is a private, loopback or local address; allow it by naming that exact host` };
1674
+ }
1675
+ return { allowed: true, host, explicit };
1523
1676
  }
1524
- var blake3Iv = [1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225];
1525
- var blake3Permutation = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
1526
- function blake3Compress(cv, block, blockLength, flags) {
1527
- const s = new Uint32Array([...cv, blake3Iv[0], blake3Iv[1], blake3Iv[2], blake3Iv[3], 0, 0, blockLength, flags]);
1528
- let m = Uint32Array.from(block);
1529
- const rotr = (x, n) => x >>> n | x << 32 - n;
1530
- const g = (a, b, c, d, x, y) => {
1531
- s[a] = s[a] + s[b] + x;
1532
- s[d] = rotr(s[d] ^ s[a], 16);
1533
- s[c] = s[c] + s[d];
1534
- s[b] = rotr(s[b] ^ s[c], 12);
1535
- s[a] = s[a] + s[b] + y;
1536
- s[d] = rotr(s[d] ^ s[a], 8);
1537
- s[c] = s[c] + s[d];
1538
- s[b] = rotr(s[b] ^ s[c], 7);
1539
- };
1540
- for (let round = 0;round < 7; round++) {
1541
- g(0, 4, 8, 12, m[0], m[1]);
1542
- g(1, 5, 9, 13, m[2], m[3]);
1543
- g(2, 6, 10, 14, m[4], m[5]);
1544
- g(3, 7, 11, 15, m[6], m[7]);
1545
- g(0, 5, 10, 15, m[8], m[9]);
1546
- g(1, 6, 11, 12, m[10], m[11]);
1547
- g(2, 7, 8, 13, m[12], m[13]);
1548
- g(3, 4, 9, 14, m[14], m[15]);
1549
- if (round < 6)
1550
- m = Uint32Array.from(blake3Permutation, (index) => m[index]);
1677
+ async function resolvesToPrivateAddress(host, timeoutMs = 2000) {
1678
+ const bare = unbracket(host);
1679
+ if (net2.isIP(bare) || isLocalhostName(bare))
1680
+ return isPrivateHost(bare) ? bare : undefined;
1681
+ let timer;
1682
+ try {
1683
+ const addresses = await Promise.race([
1684
+ dns.lookup(bare, { all: true, verbatim: true }),
1685
+ new Promise((_, reject) => {
1686
+ timer = setTimeout(() => reject(new Error("DNS lookup timed out")), timeoutMs);
1687
+ })
1688
+ ]);
1689
+ return addresses.find((entry) => isPrivateAddress(entry.address))?.address;
1690
+ } catch {
1691
+ return;
1692
+ } finally {
1693
+ if (timer)
1694
+ clearTimeout(timer);
1551
1695
  }
1552
- return Array.from({ length: 8 }, (_, index) => (s[index] ^ s[index + 8]) >>> 0);
1553
1696
  }
1554
- function blake3Hex(input) {
1555
- if (input.length > 1024)
1556
- throw new Error("blake3Hex handles inputs up to 1024 bytes (DEADBOLT_HOME is longer: set OPENCODE_BROWSER_DEADBOLT_SOCKET)");
1557
- const blocks = Math.max(1, Math.ceil(input.length / 64));
1558
- let cv = [...blake3Iv];
1559
- for (let index = 0;index < blocks; index++) {
1560
- const bytes = new Uint8Array(64);
1561
- const chunk = input.subarray(index * 64, index * 64 + 64);
1562
- bytes.set(chunk);
1563
- const words = new Uint32Array(16);
1564
- const view = new DataView(bytes.buffer);
1565
- for (let word = 0;word < 16; word++)
1566
- words[word] = view.getUint32(word * 4, true);
1567
- const flags = (index === 0 ? 1 : 0) | (index === blocks - 1 ? 2 | 8 : 0);
1568
- cv = blake3Compress(cv, words, chunk.length, flags);
1697
+ async function checkNavigationUrl(rawUrl, rules) {
1698
+ const decision = checkUrl(rawUrl, rules);
1699
+ if (!decision.allowed || decision.host === undefined || decision.explicit)
1700
+ return decision;
1701
+ const privateAddress = await resolvesToPrivateAddress(decision.host);
1702
+ if (privateAddress) {
1703
+ return { allowed: false, reason: `${decision.host} resolves to the private address ${privateAddress}; allow it by naming that exact host` };
1569
1704
  }
1570
- const out = Buffer.alloc(32);
1571
- cv.forEach((word, index) => out.writeUInt32LE(word, index * 4));
1572
- return out.toString("hex");
1705
+ return decision;
1573
1706
  }
1574
1707
 
1575
- // src/user-settings.ts
1576
- var settingKeys = ["window", "grouping", "idleMinutes", "cursor", "humanInput", "handoffNotifications", "autoUpdate"];
1577
- var relaySettingKeys = ["autoUpdate"];
1578
- var settingSpecs = {
1579
- window: { label: "Where agents open their tabs (current: the user's window; separate: an unfocused window of their own)", values: ["current", "separate"], variable: "OPENCODE_BROWSER_AGENT_WINDOW" },
1580
- grouping: { label: "How agent tabs are grouped (one group per session, one shared group, or none)", values: ["session", "shared", "none"] },
1581
- idleMinutes: { label: "Close agent tabs untouched for this many minutes", values: [10, 30, 60, "never"] },
1582
- cursor: { label: "Agent cursor on agent tabs outside recordings (pages can detect it)", values: ["off", "always"], variable: "OPENCODE_BROWSER_GHOST_CURSOR" },
1583
- humanInput: { label: "Human-like input (off is faster, but easier for sites to flag)", values: ["on", "off"], variable: "OPENCODE_BROWSER_HUMAN_INPUT" },
1584
- handoffNotifications: { label: "System notification on a handoff (only while recorded, or every handoff)", values: ["recording", "always"] },
1585
- autoUpdate: { label: "npm installs: install updates automatically when idle, only notify, or never check", values: ["on", "notify", "off"], variable: "OPENCODE_BROWSER_AUTO_UPDATE" }
1586
- };
1587
- var securityVariables = [
1588
- "OPENCODE_BROWSER_ALLOW_NODE",
1589
- "OPENCODE_BROWSER_ALLOW_ALL_COOKIES",
1590
- "OPENCODE_BROWSER_ALLOW_FOREGROUND",
1591
- "OPENCODE_BROWSER_ALLOW_ALL_STORAGE",
1592
- "OPENCODE_BROWSER_ALLOW_FILE_URLS",
1593
- "OPENCODE_BROWSER_UPDATE_CHECK"
1594
- ];
1708
+ // src/auth/site-secrets.ts
1709
+ var StoredSiteSecret = Schema2.Struct({
1710
+ version: Schema2.Literal(1),
1711
+ name: Schema2.String,
1712
+ kind: Schema2.Literals(["password", "totp"]),
1713
+ value: Schema2.String,
1714
+ allowedOrigins: Schema2.Array(Schema2.String),
1715
+ createdAt: Schema2.String,
1716
+ updatedAt: Schema2.String
1717
+ });
1595
1718
 
1596
- class SettingsInputError extends Error {
1719
+ class SiteSecretError extends Schema2.TaggedError()("SiteSecret.Error", {
1720
+ message: Schema2.String,
1721
+ reason: Schema2.Literals(["invalid-name", "invalid-origins", "invalid-value", "not-found", "read-failed", "write-failed"]),
1722
+ cause: Schema2.optionalKey(Schema2.Defect())
1723
+ }) {
1597
1724
  }
1598
- var allowed = (key) => settingSpecs[key].values.map(String).join(", ");
1599
- function parseSettingChanges(input) {
1600
- if (input === null || typeof input !== "object" || Array.isArray(input))
1601
- throw new SettingsInputError('Pass the settings to change as an object, for example { grouping: "shared" }.');
1602
- const problems = [];
1603
- const changes = {};
1604
- for (const [key, raw] of Object.entries(input)) {
1605
- if (!settingKeys.includes(key)) {
1606
- problems.push(/allow|update|security|node|cookie|foreground|storage|file/i.test(key) ? `"${key}" is not a setting: security switches (${securityVariables.join(", ")}) are set only in the relay's environment, never here.` : `Unknown setting "${key}". Settings: ${settingKeys.join(", ")}.`);
1607
- continue;
1608
- }
1609
- const setting = key;
1610
- const value = setting === "idleMinutes" ? idleValue(raw) : raw;
1611
- if (value === undefined || !settingSpecs[setting].values.includes(value)) {
1612
- problems.push(`${setting} must be one of ${allowed(setting)} (got ${JSON.stringify(raw)}).`);
1613
- continue;
1614
- }
1615
- changes[setting] = value;
1616
- }
1617
- if (problems.length)
1618
- throw new SettingsInputError(problems.join(" "));
1619
- if (!Object.keys(changes).length)
1620
- throw new SettingsInputError(`Nothing to change. Settings: ${settingKeys.join(", ")}.`);
1621
- return changes;
1725
+ var defaultSiteSecretsDir = () => path3.join(dataRoot(), "site-secrets");
1726
+ function secretPath(baseDir, name) {
1727
+ return profileNamePattern.test(name) ? Effect3.succeed(path3.join(baseDir, `${name}.json`)) : Effect3.fail(new SiteSecretError({ message: `Invalid secret name: ${name} (letters, digits, ".", "_" and "-", up to 64)`, reason: "invalid-name" }));
1622
1728
  }
1623
- function idleValue(raw) {
1624
- if (raw === "never" || raw === 0 || raw === "0")
1625
- return "never";
1626
- if (typeof raw === "number")
1627
- return raw;
1628
- if (typeof raw === "string" && /^\d+$/.test(raw))
1629
- return Number(raw);
1630
- return;
1729
+ function summary2(secret) {
1730
+ return { name: secret.name, kind: secret.kind, allowedOrigins: [...secret.allowedOrigins], createdAt: secret.createdAt, updatedAt: secret.updatedAt };
1631
1731
  }
1632
- function settingsFromExtension(wire) {
1633
- const values = wire && typeof wire.settings === "object" && wire.settings !== null && !Array.isArray(wire.settings) ? wire.settings : undefined;
1634
- if (!values)
1635
- throw new Error("The extension answered settings without them");
1636
- const settings = {};
1637
- for (const key of settingKeys) {
1638
- const value = values[key];
1639
- if (relaySettingKeys.includes(key))
1640
- settings[key] = String(settingSpecs[key].values[0]);
1641
- else if (key === "idleMinutes")
1642
- settings[key] = value === 0 ? "never" : typeof value === "number" ? value : 10;
1643
- else
1644
- settings[key] = typeof value === "string" ? value : String(settingSpecs[key].values[0]);
1732
+ var readSecret = Effect3.fn("SiteSecrets.read")(function* (name, options = {}) {
1733
+ const filePath = yield* secretPath(options.baseDir ?? defaultSiteSecretsDir(), name);
1734
+ const text = yield* Effect3.tryPromise({
1735
+ try: () => fs3.readFile(filePath, "utf8"),
1736
+ catch: (cause) => isNodeError(cause) && cause.code === "ENOENT" ? new SiteSecretError({ message: `Secret not found: ${name}. Ask the user to add it with \`opencode-browser secrets add ${name} --origin <origin>\`.`, reason: "not-found" }) : new SiteSecretError({ message: `Could not read secret ${name}`, reason: "read-failed", cause })
1737
+ });
1738
+ return yield* Effect3.try({
1739
+ try: () => Schema2.decodeUnknownSync(StoredSiteSecret)(JSON.parse(text)),
1740
+ catch: (cause) => new SiteSecretError({ message: `Secret ${name} is not a valid secret file`, reason: "read-failed", cause })
1741
+ });
1742
+ });
1743
+ var addSecret = Effect3.fn("SiteSecrets.add")(function* (options) {
1744
+ const baseDir = options.baseDir ?? defaultSiteSecretsDir();
1745
+ const filePath = yield* secretPath(baseDir, options.name);
1746
+ const kind = options.kind ?? "password";
1747
+ const allowedOrigins = yield* Effect3.try({
1748
+ try: () => normalizeOriginPatterns(options.allowedOrigins),
1749
+ catch: (cause) => new SiteSecretError({ message: cause instanceof Error ? cause.message : "Invalid origins", reason: "invalid-origins", cause })
1750
+ });
1751
+ if (allowedOrigins.length === 0) {
1752
+ return yield* new SiteSecretError({ message: "A secret needs at least one allowed origin (--origin https://example.com)", reason: "invalid-origins" });
1645
1753
  }
1646
- return settings;
1647
- }
1648
- function settingsToExtension(changes) {
1649
- return Object.fromEntries(Object.entries(changes).filter(([key]) => !relaySettingKeys.includes(key)).map(([key, value]) => [key, key === "idleMinutes" && value === "never" ? 0 : value]));
1650
- }
1651
- function report(stored, overrides) {
1652
- return Object.fromEntries(settingKeys.map((key) => {
1653
- const override = overrides[key];
1654
- return [key, {
1655
- value: stored[key],
1656
- effective: override?.value ?? stored[key],
1657
- allowed: settingSpecs[key].values,
1658
- description: settingSpecs[key].label,
1659
- ...override ? { overriddenBy: override.variable } : {}
1660
- }];
1661
- }));
1662
- }
1663
- var securityNote = `Security switches (${securityVariables.join(", ")}) are environment-only and can't be read or changed here.`;
1664
- function describeSettings(stored, overrides) {
1665
- return { settings: report(stored, overrides), note: securityNote };
1754
+ const value = kind === "totp" ? options.value.trim() : options.value.replace(/\r?\n$/, "");
1755
+ if (!value)
1756
+ return yield* new SiteSecretError({ message: "The secret value is empty", reason: "invalid-value" });
1757
+ if (kind === "totp")
1758
+ yield* Effect3.try({
1759
+ try: () => totpCode(value),
1760
+ catch: (cause) => new SiteSecretError({ message: `Not a TOTP seed or otpauth:// URI: ${cause instanceof Error ? cause.message : "invalid"}`, reason: "invalid-value", cause })
1761
+ });
1762
+ const write = Effect3.gen(function* () {
1763
+ const existing = yield* readSecret(options.name, { baseDir }).pipe(Effect3.catchIf((error) => error.reason === "not-found", () => Effect3.succeed(undefined)));
1764
+ const now = new Date().toISOString();
1765
+ const secret = { version: 1, name: options.name, kind, value, allowedOrigins: [...allowedOrigins], createdAt: existing?.createdAt ?? now, updatedAt: now };
1766
+ yield* Effect3.tryPromise({
1767
+ try: () => writeJsonFileAtomically(filePath, secret, { dirMode: 448 }),
1768
+ catch: (cause) => new SiteSecretError({ message: `Could not write secret ${options.name}`, reason: "write-failed", cause })
1769
+ });
1770
+ return summary2(secret);
1771
+ });
1772
+ return yield* withLock(options.name, { baseDir }, write).pipe(Effect3.mapError((error) => error instanceof SiteSecretError ? error : new SiteSecretError({ message: error.message, reason: "write-failed", cause: error })));
1773
+ });
1774
+ var listSecrets = Effect3.fn("SiteSecrets.list")(function* (options = {}) {
1775
+ const baseDir = options.baseDir ?? defaultSiteSecretsDir();
1776
+ const names = yield* Effect3.tryPromise({
1777
+ try: () => fs3.readdir(baseDir).catch((cause) => isNodeError(cause) && cause.code === "ENOENT" ? [] : Promise.reject(cause)),
1778
+ catch: (cause) => new SiteSecretError({ message: "Could not list secrets", reason: "read-failed", cause })
1779
+ });
1780
+ const secrets = [];
1781
+ for (const file of names.filter((entry) => entry.endsWith(".json")).sort()) {
1782
+ const secret = yield* readSecret(file.slice(0, -".json".length), { baseDir }).pipe(Effect3.option);
1783
+ if (secret._tag === "Some")
1784
+ secrets.push(summary2(secret.value));
1785
+ }
1786
+ return secrets;
1787
+ });
1788
+ var removeSecret = Effect3.fn("SiteSecrets.remove")(function* (name, options = {}) {
1789
+ const baseDir = options.baseDir ?? defaultSiteSecretsDir();
1790
+ const filePath = yield* secretPath(baseDir, name);
1791
+ return yield* withLock(name, { baseDir }, Effect3.tryPromise({
1792
+ try: () => fs3.rm(filePath).then(() => true, (cause) => isNodeError(cause) && cause.code === "ENOENT" ? false : Promise.reject(cause)),
1793
+ catch: (cause) => new SiteSecretError({ message: `Could not remove secret ${name}`, reason: "write-failed", cause })
1794
+ })).pipe(Effect3.mapError((error) => error instanceof SiteSecretError ? error : new SiteSecretError({ message: error.message, reason: "write-failed", cause: error })));
1795
+ });
1796
+ function base32Decode(input) {
1797
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1798
+ const clean = input.toUpperCase().replace(/[\s=-]/g, "");
1799
+ if (!clean || /[^A-Z2-7]/.test(clean))
1800
+ throw new Error("the seed must be base32");
1801
+ let bits = 0;
1802
+ let value = 0;
1803
+ const bytes = [];
1804
+ for (const char of clean) {
1805
+ value = value << 5 | alphabet.indexOf(char);
1806
+ bits += 5;
1807
+ if (bits >= 8) {
1808
+ bytes.push(value >>> bits - 8 & 255);
1809
+ bits -= 8;
1810
+ }
1811
+ }
1812
+ return Buffer.from(bytes);
1666
1813
  }
1667
- function describeChanges(options) {
1668
- const changed = [];
1669
- const unchanged = [];
1670
- const overridden = [];
1671
- for (const [key, value] of Object.entries(options.requested)) {
1672
- const override = options.overrides[key];
1673
- if (override)
1674
- overridden.push({ key, requested: value, variable: override.variable, effective: override.value });
1675
- else if (options.before[key] === options.after[key])
1676
- unchanged.push(key);
1677
- else
1678
- changed.push({ key, from: options.before[key], to: options.after[key] });
1814
+ function totpParameters(seed) {
1815
+ if (/^otpauth:\/\//i.test(seed)) {
1816
+ const url = new URL(seed);
1817
+ const secret = url.searchParams.get("secret");
1818
+ if (!secret)
1819
+ throw new Error("the otpauth URI has no secret");
1820
+ const digits = Number(url.searchParams.get("digits") ?? 6);
1821
+ const period = Number(url.searchParams.get("period") ?? 30);
1822
+ const algorithm = (url.searchParams.get("algorithm") ?? "SHA1").toLowerCase();
1823
+ if (![6, 7, 8].includes(digits) || !Number.isInteger(period) || period <= 0 || !["sha1", "sha256", "sha512"].includes(algorithm)) {
1824
+ throw new Error("unsupported otpauth parameters");
1825
+ }
1826
+ return { key: base32Decode(secret), digits, period, algorithm };
1679
1827
  }
1680
- const lines = [];
1681
- if (changed.length)
1682
- lines.push(`Changed ${changed.map((item) => `${item.key} from ${item.from} to ${item.to}`).join(", ")}. Tell the user what you changed; they can change it back on the extension's options page.`);
1683
- if (unchanged.length)
1684
- lines.push(`Already set: ${unchanged.map((key) => `${key} is ${options.after[key]}`).join(", ")}.`);
1685
- for (const item of overridden)
1686
- lines.push(`Not changed: ${item.key} is fixed to ${item.effective} by ${item.variable} in the relay's environment, so this setting has no effect. Tell the user; to change it they remove or edit ${item.variable} where opencode starts, then run \`${cliCommand("relay restart")}\`.`);
1687
- return { changed, unchanged, overridden, settings: report(options.after, options.overrides), message: lines.join(" ") };
1828
+ return { key: base32Decode(seed), digits: 6, period: 30, algorithm: "sha1" };
1688
1829
  }
1689
- function formatSettings(settings) {
1690
- const width = Math.max(...settingKeys.map((key) => key.length));
1691
- return settingKeys.map((key) => {
1692
- const item = settings[key];
1693
- const override = item.overriddenBy ? ` (in effect: ${item.effective}, set by ${item.overriddenBy} on the relay)` : "";
1694
- return `${key.padEnd(width)} ${String(item.value).padEnd(9)} one of ${item.allowed.join(", ")}${override}`;
1695
- });
1830
+ function totpCode(seed, now = Date.now()) {
1831
+ const { key, digits, period, algorithm } = totpParameters(seed);
1832
+ const counter = Buffer.alloc(8);
1833
+ counter.writeBigUInt64BE(BigInt(Math.floor(now / 1000 / period)));
1834
+ const hmac = crypto2.createHmac(algorithm, key).update(counter).digest();
1835
+ const offset = hmac[hmac.length - 1] & 15;
1836
+ const binary = (hmac.readUInt32BE(offset) & 2147483647) % 10 ** digits;
1837
+ return String(binary).padStart(digits, "0");
1838
+ }
1839
+ function secretText(secret) {
1840
+ return secret.kind === "totp" ? totpCode(secret.value) : secret.value;
1696
1841
  }
1697
1842
 
1698
- export { terminateChildProcess, isNodeError, isUnsupportedDirectorySyncError, writeJsonFileAtomically, SecretCollector, looksLikeTokenSegment, AuthProfileError, read, readOptional, withLock, write, status, run, OriginPatternError, parseOriginPatterns, normalizeOriginPatterns, isPrivateAddress, explicitlyAllowsHost, checkUrl, checkNavigationUrl, readSecret, addSecret, listSecrets, removeSecret, secretText, DeadboltError, readClient, socketPath, connect, parseDeadboltName, originOnSite, pickLogin, originsFromPatterns, listLogins, pickPasskey, pair, unpair, isBase64Url, randomName, settingKeys, settingSpecs, securityVariables, SettingsInputError, parseSettingChanges, settingsFromExtension, settingsToExtension, describeSettings, describeChanges, formatSettings };
1843
+ export { settingKeys, settingSpecs, securityVariables, SettingsInputError, parseSettingChanges, settingsFromExtension, settingsToExtension, describeSettings, describeChanges, formatSettings, browsersNote2, mcpUpdateNote2, objectSchema, emptyInputSchema, sessionSchemaProperty, sessionAliasSchemaProperty, sessionOnlyInputSchema, ensureSessionExists, parseExecuteArguments, parseAdoptArguments, requiredStringField, optionalStringField, optionalBooleanField, optionalPositiveIntegerField, optionalStringArrayField, requireObject, toolResultForValue2, mcpErrorMessage2, toolResult, DeadboltError, readClient, socketPath, connect, parseDeadboltName, originOnSite, pickLogin, originsFromPatterns, listLogins, pickPasskey, pair, unpair, isBase64Url, randomName, terminateChildProcess, SecretCollector, looksLikeTokenSegment, AuthProfileError, read, readOptional, withLock, write, status, run, OriginPatternError, parseOriginPatterns, normalizeOriginPatterns, isPrivateAddress, explicitlyAllowsHost, checkUrl, checkNavigationUrl, readSecret, addSecret, listSecrets, removeSecret, secretText };