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