Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/extdev.mjs

6.6 KiB, 1 run

created by r2519314175:17, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// dev/extdev.mjs — the DEVELOPMENT build of the extension, which is the only one
2// that will talk to a page on localhost.
3//
4// `ext/manifest.json` is the SHIPPED manifest, and it names one origin:
5// `https://daimond.oxedyne.com`. It did not always. Until 2026-08-02 it also
6// listed `http://127.0.0.1:8777` and `http://localhost:8777`, in
7// `externally_connectable` AND in the content-script matches, and both shipped
8// to users. A reviewer served a bare hostile HTML page from that port, opened it,
9// and completed an exec with a fence of its own choosing. Anything that binds
10// 8777 on a user's machine -- a stray dev server, a static server rooted in
11// ~/Downloads, another account on a shared box, user-level malware -- was one
12// `chrome.runtime.connect` away from running programs as them.
13//
14// The origins cannot simply be deleted, because local end-to-end testing on
15// 127.0.0.1:8777 is how the machine hand is developed. So they live HERE, in a
16// generated tree, and never in the file a release is carved from:
17//
18// node dev/extdev.mjs # build it, print the path
19// node dev/extdev.mjs --print # print the path only, build nothing
20//
21// The generated tree is a COPY of `ext/`, plus a `manifest.json` that is the
22// shipped one with the dev origins added back. It was a symlink farm first,
23// which would have made every edit live -- but Chrome will not inject a content
24// script whose file resolves outside the extension root, so `announce.js` was
25// silently never injected and the page could not find the extension at all. A
26// copy, rebuilt on every call, is the version that works.
27//
28// A developer opts in by loading THAT directory from chrome://extensions
29// instead of `ext/`. The pinned key is in the manifest, so the extension id is
30// the same either way and the native messaging host's `allowed_origins` needs no
31// second entry. After editing anything under `ext/`, run this again before
32// pressing Reload -- the copy is what the browser is holding. Every harness
33// rebuilds it as it launches, so only a hand-loaded browser needs the habit.
34//
35// A release is `ext/` exactly as it sits in the tree. `dev/publish.mjs` refuses
36// to carve a manifest that names a loopback origin, so the dev variant cannot
37// reach the public mirror even if someone patches the shipped file by hand.
38
39import { readdir, readFile, writeFile, mkdir, cp, rm, lstat } from 'node:fs/promises';
40import { join, normalize } from 'node:path';
41import { fileURLToPath } from 'node:url';
42import os from 'node:os';
43
44const ROOT = normalize(join(fileURLToPath(import.meta.url), '..', '..'));
45const EXT = join(ROOT, 'ext');
46// Not /tmp -- it is a tmpfs, and what is written there is RAM charged to this
47// machine's agent fleet. See the SCRATCH note in harness.mjs.
48const SCRATCH = process.env.DAIMOND_SCRATCH || join(os.homedir(), '.cache/daimond');
49const OUT = join(SCRATCH, 'ext-dev');
50
51/// The port `dev/serve.mjs` binds, and therefore the one the dev build trusts.
52///
53/// It follows `DAIMOND_PORT`, so a dev build made inside a numbered world (see
54/// `dev/world.sh`) trusts that world's server rather than the default one.
55export const DEV_PORT = Number(process.env.DAIMOND_DEV_PORT || process.env.DAIMOND_PORT || 8777);
56
57/// The origins a developer needs and a user must never have.
58///
59/// Chrome matches the origin STRING, so the two spellings of loopback are two
60/// origins and both are needed.
61///
62/// # Arguments
63/// * `port` - Which port, for a test that cannot have the usual one.
64export function devOrigins(port = DEV_PORT) {
65 return [
66 `http://127.0.0.1:${port}/*`,
67 `http://localhost:${port}/*`,
68 ];
69}
70
71/// The default pair, for a caller that does not care.
72export const DEV_ORIGINS = devOrigins();
73
74/// Anything in the shipped manifest that looks like one of these has been
75/// patched by hand, and the point of the split has been lost.
76export const LOOPBACK = /(^|\/\/)(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0)(:|\/)/;
77
78/// Reads the shipped manifest, refusing one that already carries a dev origin.
79async function shipped() {
80 const text = await readFile(join(EXT, 'manifest.json'), 'utf8');
81 const pats = [].concat(
82 (JSON.parse(text).externally_connectable || {}).matches || [],
83 ...(JSON.parse(text).content_scripts || []).map((cs) => cs.matches || []));
84 const bad = pats.filter((p) => LOOPBACK.test(p));
85 if (bad.length) {
86 throw new Error(`ext/manifest.json already names ${bad.join(', ')}. That file is what ships: `
87 + `take the loopback origins out of it and let this script add them to the dev build instead.`);
88 }
89 return JSON.parse(text);
90}
91
92/// Builds the dev tree and returns its path.
93///
94/// # Arguments
95/// * `port` - The dev server's port. A test that cannot have the world's -- because
96/// a developer is already serving on it -- gets a build of its own rather than
97/// fighting for the port.
98///
99/// # Returns
100/// The absolute path to load unpacked.
101export async function extDev(port = DEV_PORT) {
102 const m = await shipped();
103 const origins = devOrigins(port);
104 const out = port === DEV_PORT ? OUT : `${OUT}-${port}`;
105
106 m.externally_connectable = m.externally_connectable || { matches: [] };
107 m.externally_connectable.matches = m.externally_connectable.matches.concat(origins);
108 for (const cs of m.content_scripts || []) {
109 cs.matches = (cs.matches || []).concat(origins);
110 }
111 // Nothing else is touched. `name`, `version` and the pinned `key` are the
112 // shipped ones, so the extension id is the same in both builds and the
113 // native messaging host's allowed_origins needs no second entry -- and a
114 // test that reads the manifest reads what a user would.
115 await rm(out, { recursive: true, force: true });
116 await mkdir(out, { recursive: true });
117 for (const ent of await readdir(EXT, { withFileTypes: true })) {
118 if (ent.name === 'manifest.json') { continue; }
119 await cp(join(EXT, ent.name), join(out, ent.name), { recursive: true });
120 }
121 await writeFile(join(out, 'manifest.json'), JSON.stringify(m, null, '\t') + '\n');
122 return out;
123}
124
125/// Whether a path is the extension source directory, so a caller that asked for
126/// `ext/` can be handed the dev build instead.
127///
128/// # Arguments
129/// * `p` - The path a test asked to load.
130export function isExtSource(p) {
131 return !!p && normalize(String(p)).replace(/\/+$/, '') === EXT;
132}
133
134if (process.argv[1] && fileURLToPath(import.meta.url) === normalize(process.argv[1])) {
135 if (process.argv.includes('--print')) {
136 console.log(OUT);
137 } else {
138 const where = await extDev();
139 await lstat(join(where, 'manifest.json'));
140 console.log(where);
141 console.error(`Load unpacked from that directory, not from ext/. It adds ${DEV_ORIGINS.join(' and ')} `
142 + `to the shipped manifest and copies everything else. Run this again after editing ext/, then Reload.`);
143 }
144}