@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,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isNodeError
|
|
3
|
+
} from "./main-e4stqtf2.mjs";
|
|
4
|
+
import {
|
|
5
|
+
dataRoot
|
|
6
|
+
} from "./main-g5mke6rn.mjs";
|
|
7
|
+
import {
|
|
8
|
+
isValidSessionId2
|
|
9
|
+
} from "./main-3qpkwaa7.mjs";
|
|
10
|
+
|
|
11
|
+
// src/session-journal.ts
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { Option, Schema } from "effect";
|
|
15
|
+
var JournalEntry = Schema.Struct({
|
|
16
|
+
ts: Schema.String,
|
|
17
|
+
sessionId: Schema.String,
|
|
18
|
+
code: Schema.String,
|
|
19
|
+
isError: Schema.Boolean,
|
|
20
|
+
durationMs: Schema.Number,
|
|
21
|
+
resultPreview: Schema.String,
|
|
22
|
+
logCount: Schema.Number,
|
|
23
|
+
startUrl: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
24
|
+
endUrl: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
25
|
+
navigations: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
26
|
+
warnings: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
27
|
+
diagnostic: Schema.optionalKey(Schema.String),
|
|
28
|
+
handoffs: Schema.optionalKey(Schema.Number)
|
|
29
|
+
});
|
|
30
|
+
var decodeJournalEntry = Schema.decodeUnknownOption(JournalEntry);
|
|
31
|
+
var defaultJournalBaseDir = () => {
|
|
32
|
+
return path.join(dataRoot(), "sessions");
|
|
33
|
+
};
|
|
34
|
+
function journalPathForSession(baseDir, sessionId) {
|
|
35
|
+
return path.join(baseDir, sessionId, "journal.jsonl");
|
|
36
|
+
}
|
|
37
|
+
var maxJournalCodeLength = 2000;
|
|
38
|
+
var maxJournalPreviewLength = 400;
|
|
39
|
+
var maxJournalDiagnosticLength = 240;
|
|
40
|
+
function truncateForJournal(text, maxLength) {
|
|
41
|
+
if (text.length <= maxLength) {
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
return `${text.slice(0, maxLength)}… [truncated ${text.length - maxLength} chars]`;
|
|
45
|
+
}
|
|
46
|
+
var redacted = "[REDACTED]";
|
|
47
|
+
var sensitiveWord = String.raw`(?:pass(?:word|wd|code|phrase)?|pwd|otp|one[-_ ]?time|2fa|mfa|totp|pin|code|token|secret|api[-_]?key|access[-_]?key|private[-_]?key|auth(?:orization)?|credential|session|cookie|bearer|cvv|cvc|card[-_ ]?number)`;
|
|
48
|
+
var stringLiteral = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|\`(?:[^\`\\]|\\.)*\`)`;
|
|
49
|
+
var affix = String.raw`{0,64}`;
|
|
50
|
+
var sensitiveAssignment = new RegExp(String.raw`(["'\`]?[\w$-]${affix}${sensitiveWord}[\w$-]${affix}["'\`]?\s*[:=]\s*)${stringLiteral}`, "gi");
|
|
51
|
+
var valueCall = new RegExp(String.raw`(\.(?:fill|type|pressSequentially|setInputValue)\s*\((?:\s*${stringLiteral}\s*,)?\s*|\bfillInput\s*\([^;\n]*?,\s*)${stringLiteral}`, "gi");
|
|
52
|
+
var sensitiveLine = new RegExp(sensitiveWord, "i");
|
|
53
|
+
var valueProperty = new RegExp(String.raw`(\bvalue\s*:\s*)${stringLiteral}`, "gi");
|
|
54
|
+
var sensitiveQuery = new RegExp(String.raw`([?&#][\w.-]${affix}${sensitiveWord}[\w.-]${affix}=)[^&#\s"'\`]+`, "gi");
|
|
55
|
+
var tokenPatterns = [
|
|
56
|
+
/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}/g,
|
|
57
|
+
/\b(?:Bearer|Basic)\s+[\w~+/.=-]{12,}/gi,
|
|
58
|
+
/\b(?:sk|pk|rk|ghp|gho|ghs|ghu|github_pat|xox[abprs]|glpat|AKIA|ASIA)[-_][\w-]{12,}/g
|
|
59
|
+
];
|
|
60
|
+
function redactJournalText(text) {
|
|
61
|
+
let output = text.replace(sensitiveAssignment, `$1"${redacted}"`);
|
|
62
|
+
output = output.split(`
|
|
63
|
+
`).map((line) => {
|
|
64
|
+
if (!sensitiveLine.test(line))
|
|
65
|
+
return line;
|
|
66
|
+
return line.replace(valueCall, `$1"${redacted}"`).replace(valueProperty, `$1"${redacted}"`);
|
|
67
|
+
}).join(`
|
|
68
|
+
`);
|
|
69
|
+
output = output.replace(sensitiveQuery, `$1${redacted}`);
|
|
70
|
+
for (const pattern of tokenPatterns)
|
|
71
|
+
output = output.replace(pattern, redacted);
|
|
72
|
+
return output;
|
|
73
|
+
}
|
|
74
|
+
function makeJournalEntry(options) {
|
|
75
|
+
return {
|
|
76
|
+
ts: new Date().toISOString(),
|
|
77
|
+
sessionId: options.sessionId,
|
|
78
|
+
code: truncateForJournal(redactJournalText(options.code), maxJournalCodeLength),
|
|
79
|
+
isError: options.isError,
|
|
80
|
+
durationMs: options.durationMs,
|
|
81
|
+
resultPreview: truncateForJournal(redactJournalText(options.resultText), maxJournalPreviewLength),
|
|
82
|
+
logCount: options.logCount,
|
|
83
|
+
...options.startUrl === undefined ? {} : { startUrl: redactNullableUrl(options.startUrl) },
|
|
84
|
+
...options.endUrl === undefined ? {} : { endUrl: redactNullableUrl(options.endUrl) },
|
|
85
|
+
...options.navigations && options.navigations.length > 0 ? { navigations: options.navigations.map(redactJournalText) } : {},
|
|
86
|
+
...options.warnings && options.warnings.length > 0 ? { warnings: options.warnings.map(redactJournalText) } : {},
|
|
87
|
+
...options.diagnostic ? { diagnostic: truncateForJournal(redactJournalText(options.diagnostic), maxJournalDiagnosticLength) } : {},
|
|
88
|
+
...options.handoffs ? { handoffs: options.handoffs } : {}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function redactNullableUrl(url) {
|
|
92
|
+
return url === null ? null : redactJournalText(url);
|
|
93
|
+
}
|
|
94
|
+
async function appendJournalEntry(options) {
|
|
95
|
+
const filePath = journalPathForSession(options.baseDir, options.entry.sessionId);
|
|
96
|
+
await fs.promises.mkdir(options.baseDir, { recursive: true, mode: 448 });
|
|
97
|
+
await fs.promises.chmod(options.baseDir, 448);
|
|
98
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 448 });
|
|
99
|
+
await fs.promises.chmod(path.dirname(filePath), 448);
|
|
100
|
+
await fs.promises.appendFile(filePath, `${JSON.stringify(options.entry)}
|
|
101
|
+
`, { encoding: "utf8", mode: 384 });
|
|
102
|
+
await fs.promises.chmod(filePath, 384);
|
|
103
|
+
}
|
|
104
|
+
var defaultJournalRetentionMs = 14 * 24 * 60 * 60 * 1000;
|
|
105
|
+
var defaultJournalMaxCount = 200;
|
|
106
|
+
async function pruneSessionJournals(options) {
|
|
107
|
+
const maxAgeMs = options.maxAgeMs ?? defaultJournalRetentionMs;
|
|
108
|
+
const maxCount = options.maxCount ?? defaultJournalMaxCount;
|
|
109
|
+
const now = options.now ?? Date.now();
|
|
110
|
+
let names;
|
|
111
|
+
try {
|
|
112
|
+
names = await fs.promises.readdir(options.baseDir);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
115
|
+
return [];
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
await fs.promises.chmod(options.baseDir, 448);
|
|
119
|
+
const candidates = [];
|
|
120
|
+
for (const id of names) {
|
|
121
|
+
if (!isValidSessionId2(id))
|
|
122
|
+
continue;
|
|
123
|
+
const directory = path.join(options.baseDir, id);
|
|
124
|
+
const stat = await fs.promises.lstat(directory).catch(() => {
|
|
125
|
+
return;
|
|
126
|
+
});
|
|
127
|
+
if (!stat?.isDirectory())
|
|
128
|
+
continue;
|
|
129
|
+
const journal = await fs.promises.lstat(path.join(directory, "journal.jsonl")).catch(() => {
|
|
130
|
+
return;
|
|
131
|
+
});
|
|
132
|
+
candidates.push({ id, directory, modifiedAt: Math.max(stat.mtimeMs, journal?.mtimeMs ?? 0) });
|
|
133
|
+
}
|
|
134
|
+
const removed = [];
|
|
135
|
+
const kept = [];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
if (!options.isLive(candidate.id) && now - candidate.modifiedAt > maxAgeMs)
|
|
138
|
+
removed.push(candidate.id);
|
|
139
|
+
else
|
|
140
|
+
kept.push(candidate);
|
|
141
|
+
}
|
|
142
|
+
const removable = kept.filter((candidate) => !options.isLive(candidate.id)).sort((left, right) => left.modifiedAt - right.modifiedAt);
|
|
143
|
+
const excess = Math.max(0, kept.length - maxCount);
|
|
144
|
+
const evicted = new Set(removable.slice(0, excess).map((candidate) => candidate.id));
|
|
145
|
+
removed.push(...evicted);
|
|
146
|
+
const removedSet = new Set(removed);
|
|
147
|
+
for (const candidate of candidates) {
|
|
148
|
+
if (removedSet.has(candidate.id)) {
|
|
149
|
+
if (options.isLive(candidate.id)) {
|
|
150
|
+
removedSet.delete(candidate.id);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
await fs.promises.rm(candidate.directory, { recursive: true, force: true });
|
|
154
|
+
} else {
|
|
155
|
+
await fs.promises.chmod(candidate.directory, 448).catch(() => {});
|
|
156
|
+
await fs.promises.chmod(path.join(candidate.directory, "journal.jsonl"), 384).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return removed.filter((id) => removedSet.has(id));
|
|
160
|
+
}
|
|
161
|
+
var journalPruneIntervalMs = 6 * 60 * 60 * 1000;
|
|
162
|
+
function startJournalRetention(options) {
|
|
163
|
+
const prune = () => {
|
|
164
|
+
pruneSessionJournals(options).catch((error) => options.onError?.(error));
|
|
165
|
+
};
|
|
166
|
+
prune();
|
|
167
|
+
const interval = setInterval(prune, journalPruneIntervalMs);
|
|
168
|
+
interval.unref();
|
|
169
|
+
return () => clearInterval(interval);
|
|
170
|
+
}
|
|
171
|
+
async function readJournalEntries(options) {
|
|
172
|
+
const filePath = journalPathForSession(options.baseDir, options.sessionId);
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = await fs.promises.readFile(filePath, "utf8");
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
return parseJournalLines(raw).slice(-options.limit);
|
|
183
|
+
}
|
|
184
|
+
function parseJournalLines(raw) {
|
|
185
|
+
const entries = [];
|
|
186
|
+
for (const line of raw.split(`
|
|
187
|
+
`)) {
|
|
188
|
+
const trimmed = line.trim();
|
|
189
|
+
if (!trimmed) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(trimmed);
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const decoded = decodeJournalEntry(parsed);
|
|
199
|
+
if (Option.isSome(decoded)) {
|
|
200
|
+
entries.push(decoded.value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
205
|
+
function formatJournalEntry(entry) {
|
|
206
|
+
const time = entry.ts.slice(11, 19) || entry.ts;
|
|
207
|
+
const status = entry.isError ? "ERR" : "ok ";
|
|
208
|
+
const codePreview = entry.code.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
209
|
+
const urlPart = entry.endUrl ? ` ${entry.endUrl}` : "";
|
|
210
|
+
const navPart = entry.navigations && entry.navigations.length > 0 ? ` nav=${entry.navigations.length}` : "";
|
|
211
|
+
const handoffPart = entry.handoffs ? ` handoffs=${entry.handoffs}` : "";
|
|
212
|
+
const warningPart = entry.warnings && entry.warnings.length > 0 ? ` warnings=${entry.warnings.length}` : "";
|
|
213
|
+
const diagnosticPart = entry.diagnostic ? ` diagnostic=${entry.diagnostic}` : "";
|
|
214
|
+
return `${time} ${status} ${String(entry.durationMs).padStart(5)}ms${urlPart}${navPart}${handoffPart}${warningPart}${diagnosticPart} ${codePreview}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { defaultJournalBaseDir, makeJournalEntry, appendJournalEntry, startJournalRetention, readJournalEntries, formatJournalEntry };
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensurePrivateDirectory
|
|
3
|
+
} from "./main-4bht27ry.mjs";
|
|
4
|
+
import {
|
|
5
|
+
dataRoot,
|
|
6
|
+
devExtensionUrl,
|
|
7
|
+
devExtensionFolder
|
|
8
|
+
} from "./main-g5mke6rn.mjs";
|
|
9
|
+
|
|
10
|
+
// src/browser-pairing.ts
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
var legacyContext = "opencode-browser-pairing-v2";
|
|
15
|
+
var context = "opencode-browser-pairing-v3";
|
|
16
|
+
var crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
17
|
+
var pairingCodeLength = 8;
|
|
18
|
+
var codeKeyIterations = 1e5;
|
|
19
|
+
var codeKeySalt = `${context}|code`;
|
|
20
|
+
var pairingCodeTtlMs = 10 * 60000;
|
|
21
|
+
var bundledPairingCodeTtlMs = 60 * 60000;
|
|
22
|
+
var maxPairingFailures = 5;
|
|
23
|
+
var maxPendingCodes = 5;
|
|
24
|
+
var extensionPairingFileName = "relay-pairing.json";
|
|
25
|
+
var pairingCodesPath = (root = dataRoot()) => path.join(root, "pairing-codes.json");
|
|
26
|
+
var browserTokensPath = (root = dataRoot()) => path.join(root, "browser-tokens.json");
|
|
27
|
+
var legacyPairingCodePath = (root = dataRoot()) => path.join(root, "extension-pairing");
|
|
28
|
+
function formatPairingCode(code) {
|
|
29
|
+
return code.match(/.{1,4}/g)?.join("-") ?? code;
|
|
30
|
+
}
|
|
31
|
+
function pairingCodeKey(code) {
|
|
32
|
+
return crypto.pbkdf2Sync(code, codeKeySalt, codeKeyIterations, 32, "sha256").toString("hex");
|
|
33
|
+
}
|
|
34
|
+
function readPendingCodes(root, now) {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(fs.readFileSync(pairingCodesPath(root), "utf8"));
|
|
37
|
+
const codes = Array.isArray(value.codes) ? value.codes.filter((entry) => typeof entry?.key === "string" && /^[0-9a-f]{64}$/.test(entry.key) && typeof entry.expiresAt === "number" && entry.expiresAt >= now) : [];
|
|
38
|
+
return { codes, failures: typeof value.failures === "number" ? value.failures : 0 };
|
|
39
|
+
} catch {
|
|
40
|
+
return { codes: [], failures: 0 };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function writePendingCodes(root, pending) {
|
|
44
|
+
if (pending.codes.length === 0 && pending.failures === 0) {
|
|
45
|
+
fs.rmSync(pairingCodesPath(root), { force: true });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
writePrivateJson(pairingCodesPath(root), pending);
|
|
49
|
+
}
|
|
50
|
+
function mintPairingCode(root = dataRoot(), options = {}) {
|
|
51
|
+
const now = options.now ?? Date.now();
|
|
52
|
+
const code = Array.from(crypto.randomBytes(pairingCodeLength), (byte) => crockford[byte & 31]).join("");
|
|
53
|
+
const pending = readPendingCodes(root, now);
|
|
54
|
+
pending.codes.push({ key: pairingCodeKey(code), expiresAt: now + (options.ttlMs ?? pairingCodeTtlMs), ...options.receipt ? { receipt: options.receipt } : {} });
|
|
55
|
+
writePendingCodes(root, { codes: pending.codes.slice(-maxPendingCodes), failures: 0 });
|
|
56
|
+
return code;
|
|
57
|
+
}
|
|
58
|
+
function pendingPairingCodeKeys(root = dataRoot(), now = Date.now()) {
|
|
59
|
+
return readPendingCodes(root, now).codes.map((entry) => entry.key);
|
|
60
|
+
}
|
|
61
|
+
function consumePairingCode(key, root = dataRoot(), now = Date.now()) {
|
|
62
|
+
const pending = readPendingCodes(root, now);
|
|
63
|
+
writePendingCodes(root, { codes: pending.codes.filter((entry) => entry.key !== key), failures: 0 });
|
|
64
|
+
return pending.codes.find((entry) => entry.key === key)?.receipt;
|
|
65
|
+
}
|
|
66
|
+
function recordPairingFailure(root = dataRoot(), now = Date.now()) {
|
|
67
|
+
const pending = readPendingCodes(root, now);
|
|
68
|
+
const failures = pending.failures + 1;
|
|
69
|
+
const exhausted = failures >= maxPairingFailures;
|
|
70
|
+
writePendingCodes(root, exhausted ? { codes: [], failures: 0 } : { codes: pending.codes, failures });
|
|
71
|
+
return exhausted && pending.codes.length > 0;
|
|
72
|
+
}
|
|
73
|
+
function writeExtensionPairing(extensionDirectory, code) {
|
|
74
|
+
writePrivateJson(path.join(extensionDirectory, extensionPairingFileName), { code });
|
|
75
|
+
}
|
|
76
|
+
function readBrowserPairings(root = dataRoot()) {
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(fs.readFileSync(browserTokensPath(root), "utf8"));
|
|
79
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
80
|
+
return {};
|
|
81
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => typeof entry?.token === "string" && /^[A-Za-z0-9_-]{43}$/.test(entry.token)));
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function writeBrowserPairings(root, pairings) {
|
|
87
|
+
ensurePrivateDirectory(root);
|
|
88
|
+
writePrivateJson(browserTokensPath(root), pairings);
|
|
89
|
+
}
|
|
90
|
+
function browserToken(key, root = dataRoot()) {
|
|
91
|
+
return readBrowserPairings(root)[key]?.token;
|
|
92
|
+
}
|
|
93
|
+
var newBrowserToken = () => crypto.randomBytes(32).toString("base64url");
|
|
94
|
+
function issueBrowserToken(key, root = dataRoot(), now = Date.now(), token = newBrowserToken(), receipt) {
|
|
95
|
+
const pairings = readBrowserPairings(root);
|
|
96
|
+
writeBrowserPairings(root, { ...pairings, [key]: { token, pairedAt: now, ...receipt ? { receipt } : {} } });
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
function touchBrowserPairing(key, seen, root = dataRoot(), now = Date.now()) {
|
|
100
|
+
const pairings = readBrowserPairings(root);
|
|
101
|
+
const entry = pairings[key];
|
|
102
|
+
if (!entry)
|
|
103
|
+
return;
|
|
104
|
+
writeBrowserPairings(root, { ...pairings, [key]: { ...entry, lastSeen: now, id: seen.id, name: seen.name } });
|
|
105
|
+
}
|
|
106
|
+
function revokeBrowserToken(key, root = dataRoot()) {
|
|
107
|
+
const pairings = readBrowserPairings(root);
|
|
108
|
+
if (!(key in pairings))
|
|
109
|
+
return false;
|
|
110
|
+
delete pairings[key];
|
|
111
|
+
writeBrowserPairings(root, pairings);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
function legacyPairingCode(root = dataRoot()) {
|
|
115
|
+
try {
|
|
116
|
+
const code = fs.readFileSync(legacyPairingCodePath(root), "utf8").trim();
|
|
117
|
+
return new RegExp(`^[${crockford}]{32}$`).test(code) ? code : undefined;
|
|
118
|
+
} catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function removeLegacyPairingCode(root = dataRoot()) {
|
|
123
|
+
const existed = legacyPairingCode(root) !== undefined;
|
|
124
|
+
fs.rmSync(legacyPairingCodePath(root), { force: true });
|
|
125
|
+
return existed;
|
|
126
|
+
}
|
|
127
|
+
function legacyPairingProof(code, role, port, ownNonce, peerNonce) {
|
|
128
|
+
return crypto.createHmac("sha256", code).update(`${legacyContext}|${role}|${pairingEndpoint(port)}|${peerNonce}|${ownNonce}`).digest("base64url");
|
|
129
|
+
}
|
|
130
|
+
function pairingEndpoint(port) {
|
|
131
|
+
return `127.0.0.1:${port}/extension`;
|
|
132
|
+
}
|
|
133
|
+
function pairingProof(token, role, port, ownNonce, peerNonce) {
|
|
134
|
+
return crypto.createHmac("sha256", token).update(`${context}|${role}|${pairingEndpoint(port)}|${peerNonce}|${ownNonce}`).digest("base64url");
|
|
135
|
+
}
|
|
136
|
+
function ecdhExchange(peerPublicKey) {
|
|
137
|
+
const peer = Buffer.from(peerPublicKey, "base64url");
|
|
138
|
+
if (peer.length !== 65 || peer[0] !== 4)
|
|
139
|
+
return;
|
|
140
|
+
const ecdh = crypto.createECDH("prime256v1");
|
|
141
|
+
const publicKey = ecdh.generateKeys().toString("base64url");
|
|
142
|
+
try {
|
|
143
|
+
return { publicKey, secret: ecdh.computeSecret(peer) };
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function pairingSessionKey(material, secret, transcript) {
|
|
149
|
+
return crypto.createHmac("sha256", material).update(`${context}|session|${pairingEndpoint(transcript.port)}|${transcript.extensionNonce}|${transcript.relayNonce}|${transcript.extensionKey}|${transcript.relayKey}|`).update(secret).digest();
|
|
150
|
+
}
|
|
151
|
+
function sessionProof(sessionKey, role) {
|
|
152
|
+
return crypto.createHmac("sha256", sessionKey).update(`${context}|proof|${role}`).digest("base64url");
|
|
153
|
+
}
|
|
154
|
+
function wrapToken(sessionKey, token) {
|
|
155
|
+
const mask = crypto.createHmac("sha256", sessionKey).update(`${context}|wrap`).digest();
|
|
156
|
+
return Buffer.from(Buffer.from(token, "base64url").map((byte, index) => byte ^ mask[index])).toString("base64url");
|
|
157
|
+
}
|
|
158
|
+
function writePrivateJson(file, value) {
|
|
159
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
160
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}
|
|
161
|
+
`, { mode: 384 });
|
|
162
|
+
fs.renameSync(temporary, file);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/dev-extension.ts
|
|
166
|
+
import { createHash } from "node:crypto";
|
|
167
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
168
|
+
import path2 from "node:path";
|
|
169
|
+
import { crc32, inflateRawSync } from "node:zlib";
|
|
170
|
+
var devExtensionRecord = (root = dataRoot()) => path2.join(root, "extension.json");
|
|
171
|
+
function installedDevExtension(root = dataRoot()) {
|
|
172
|
+
try {
|
|
173
|
+
const record = JSON.parse(readFileSync(devExtensionRecord(root), "utf8"));
|
|
174
|
+
return typeof record.sha256 === "string" && existsSync(path2.join(devExtensionFolder(root), "manifest.json")) ? record : undefined;
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function syncDevExtension(extension, options = {}) {
|
|
180
|
+
const root = options.root ?? dataRoot();
|
|
181
|
+
const folder = devExtensionFolder(root);
|
|
182
|
+
if (installedDevExtension(root)?.sha256 === extension.sha256)
|
|
183
|
+
return "current";
|
|
184
|
+
const url = devExtensionUrl(extension, options.env);
|
|
185
|
+
const response = await (options.fetch ?? fetch)(url, { signal: AbortSignal.timeout(120000), headers: { "user-agent": "browse/dev" } });
|
|
186
|
+
if (!response.ok)
|
|
187
|
+
throw new Error(`downloading the dev extension from ${url} failed: HTTP ${response.status}`);
|
|
188
|
+
const zip = Buffer.from(await response.arrayBuffer());
|
|
189
|
+
const sha256 = createHash("sha256").update(zip).digest("hex");
|
|
190
|
+
if (zip.length !== extension.size || sha256 !== extension.sha256)
|
|
191
|
+
throw new Error(`the dev extension from ${url} isn't the one this version was built with (sha256 ${sha256}, ${zip.length} bytes)`);
|
|
192
|
+
mkdirSync(root, { recursive: true });
|
|
193
|
+
const staging = path2.join(root, `.extension-${process.pid}-${Date.now()}`);
|
|
194
|
+
const old = `${staging}-old`;
|
|
195
|
+
rmSync(staging, { recursive: true, force: true });
|
|
196
|
+
try {
|
|
197
|
+
for (const entry of unzip(zip)) {
|
|
198
|
+
const target = path2.join(staging, ...entry.name.split("/"));
|
|
199
|
+
mkdirSync(path2.dirname(target), { recursive: true });
|
|
200
|
+
if (!entry.directory)
|
|
201
|
+
writeFileSync(target, entry.data);
|
|
202
|
+
}
|
|
203
|
+
const manifest = JSON.parse(readFileSync(path2.join(staging, "manifest.json"), "utf8"));
|
|
204
|
+
if (!manifest.key)
|
|
205
|
+
throw new Error("the dev extension has no manifest key, so it wouldn't have Browse's unpacked ID");
|
|
206
|
+
writeExtensionPairing(staging, mintPairingCode(root, { ttlMs: bundledPairingCodeTtlMs }));
|
|
207
|
+
const build = (() => {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(readFileSync(path2.join(staging, "build.json"), "utf8")).build;
|
|
210
|
+
} catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
if (existsSync(folder))
|
|
215
|
+
renameSync(folder, old);
|
|
216
|
+
renameSync(staging, folder);
|
|
217
|
+
const version = manifest.version_name ?? manifest.version;
|
|
218
|
+
const record = { ...version ? { version } : {}, file: extension.file, sha256, ...build ? { build } : {}, installedAt: new Date().toISOString() };
|
|
219
|
+
writeFileSync(devExtensionRecord(root), JSON.stringify(record, null, 2) + `
|
|
220
|
+
`);
|
|
221
|
+
return "updated";
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(staging, { recursive: true, force: true });
|
|
224
|
+
rmSync(old, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function unzip(zip) {
|
|
228
|
+
let end = -1;
|
|
229
|
+
for (let offset = zip.length - 22;offset >= Math.max(0, zip.length - 22 - 65535); offset--)
|
|
230
|
+
if (zip.readUInt32LE(offset) === 101010256) {
|
|
231
|
+
end = offset;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
if (end < 0)
|
|
235
|
+
throw new Error("not a zip archive");
|
|
236
|
+
const count = zip.readUInt16LE(end + 10);
|
|
237
|
+
let offset = zip.readUInt32LE(end + 16);
|
|
238
|
+
const entries = [];
|
|
239
|
+
for (let index = 0;index < count; index++) {
|
|
240
|
+
if (zip.readUInt32LE(offset) !== 33639248)
|
|
241
|
+
throw new Error("corrupt zip central directory");
|
|
242
|
+
const method = zip.readUInt16LE(offset + 10);
|
|
243
|
+
const crc = zip.readUInt32LE(offset + 16);
|
|
244
|
+
const compressed = zip.readUInt32LE(offset + 20);
|
|
245
|
+
const size = zip.readUInt32LE(offset + 24);
|
|
246
|
+
const nameLength = zip.readUInt16LE(offset + 28);
|
|
247
|
+
const extraLength = zip.readUInt16LE(offset + 30);
|
|
248
|
+
const commentLength = zip.readUInt16LE(offset + 32);
|
|
249
|
+
const mode = zip.readUInt32LE(offset + 38) >>> 16;
|
|
250
|
+
const local = zip.readUInt32LE(offset + 42);
|
|
251
|
+
const name = zip.toString("utf8", offset + 46, offset + 46 + nameLength);
|
|
252
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
253
|
+
if (!name || name.startsWith("/") || name.includes("\\") || name.split("/").some((part) => part === ".." || part === "."))
|
|
254
|
+
throw new Error(`unsafe path in zip: ${JSON.stringify(name)}`);
|
|
255
|
+
if ((mode & 61440) === 40960)
|
|
256
|
+
throw new Error(`symbolic link in zip: ${name}`);
|
|
257
|
+
if (compressed === 4294967295 || size === 4294967295)
|
|
258
|
+
throw new Error("ZIP64 archives aren't supported");
|
|
259
|
+
const directory = name.endsWith("/");
|
|
260
|
+
if (directory) {
|
|
261
|
+
entries.push({ name: name.slice(0, -1), directory, data: Buffer.alloc(0) });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (zip.readUInt32LE(local) !== 67324752)
|
|
265
|
+
throw new Error(`corrupt zip entry ${name}`);
|
|
266
|
+
const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
|
|
267
|
+
const raw = zip.subarray(start, start + compressed);
|
|
268
|
+
const data = method === 0 ? Buffer.from(raw) : method === 8 ? inflateRawSync(raw) : undefined;
|
|
269
|
+
if (!data)
|
|
270
|
+
throw new Error(`unsupported compression method ${method} for ${name}`);
|
|
271
|
+
if (data.length !== size || crc32(data) !== crc)
|
|
272
|
+
throw new Error(`zip entry ${name} is corrupt`);
|
|
273
|
+
entries.push({ name, directory, data });
|
|
274
|
+
}
|
|
275
|
+
return entries;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/relay-config.ts
|
|
279
|
+
import fs2 from "node:fs";
|
|
280
|
+
import path3 from "node:path";
|
|
281
|
+
var relayConfigPath = (root = dataRoot()) => path3.join(root, "config.json");
|
|
282
|
+
function parseHostname(input) {
|
|
283
|
+
if (typeof input !== "string")
|
|
284
|
+
return;
|
|
285
|
+
const value = input.trim().toLowerCase();
|
|
286
|
+
if (value === "127.0.0.1" || value === "localhost")
|
|
287
|
+
return "127.0.0.1";
|
|
288
|
+
if (value === "0.0.0.0")
|
|
289
|
+
return "0.0.0.0";
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
function readRelayConfig(root = dataRoot()) {
|
|
293
|
+
try {
|
|
294
|
+
const value = JSON.parse(fs2.readFileSync(relayConfigPath(root), "utf8"));
|
|
295
|
+
const hostname = parseHostname(value?.hostname);
|
|
296
|
+
return hostname === undefined ? {} : { hostname };
|
|
297
|
+
} catch {
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function writeRelayConfig(config, root = dataRoot()) {
|
|
302
|
+
fs2.mkdirSync(root, { recursive: true, mode: 448 });
|
|
303
|
+
const file = relayConfigPath(root);
|
|
304
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
305
|
+
fs2.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}
|
|
306
|
+
`, { mode: 384 });
|
|
307
|
+
fs2.renameSync(temporary, file);
|
|
308
|
+
}
|
|
309
|
+
var relayHostname = (root = dataRoot()) => readRelayConfig(root).hostname ?? "127.0.0.1";
|
|
310
|
+
function isLocalPeer(socket) {
|
|
311
|
+
const remote = socket.remoteAddress?.replace(/^::ffff:/, "");
|
|
312
|
+
return remote !== undefined && (remote === "::1" || remote.startsWith("127."));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export { context, pairingCodeTtlMs, bundledPairingCodeTtlMs, formatPairingCode, pairingCodeKey, mintPairingCode, pendingPairingCodeKeys, consumePairingCode, recordPairingFailure, writeExtensionPairing, readBrowserPairings, browserToken, newBrowserToken, issueBrowserToken, touchBrowserPairing, revokeBrowserToken, legacyPairingCode, removeLegacyPairingCode, legacyPairingProof, pairingProof, ecdhExchange, pairingSessionKey, sessionProof, wrapToken, installedDevExtension, syncDevExtension, parseHostname, readRelayConfig, writeRelayConfig, relayHostname, isLocalPeer };
|