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