@elu-labs/elu 0.0.0-stage → 0.1.0

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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +327 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +268 -0
  93. package/lib/terminal.mjs +3159 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import { atomic, noSymlinks, readJson, isObject } from '../util.mjs';
3
+ import { tasks } from '../state.mjs';
4
+ import { git } from '../git.mjs';
5
+ import { registeredProjects } from '../projects.mjs';
6
+ import { ABOUT_MAX, BOT_NAME, botOfKey, sharedBots } from '../bots.mjs';
7
+ import { peerFile } from './paths.mjs';
8
+ import { sanitize } from './peerdata.mjs';
9
+ import { BOX, peerBox } from './settings.mjs';
10
+ import { shares } from './shares.mjs';
11
+
12
+ // Status rows (docs/peer-network.md, "Status"): one per live editing task in
13
+ // a project shared with status on. Overlap is computed here, never asked for.
14
+ const LIVE = ['preparing', 'running', 'ready', 'needs_input'];
15
+ const HEX64 = /^[0-9a-f]{64}$/;
16
+ const HANDLE = /^[a-z0-9][a-z0-9-]{0,39}$/;
17
+ const FILES_MAX = 200, ROWS_MAX = 50;
18
+ const firstLine = (value, max) => sanitize(String(value ?? '').split('\n')[0], max).trim();
19
+ function changedFiles(task) {
20
+ const names = git(task.worktree, ['diff', '--no-ext-diff', '--no-textconv', '--name-only', '-z', task.base, '--'], { allowFailure: true });
21
+ const extra = git(task.worktree, ['ls-files', '--others', '--exclude-standard', '-z'], { allowFailure: true });
22
+ return [...new Set([names, extra].flatMap(r => r.status === 0 ? r.stdout.split('\0').filter(Boolean) : []))].sort().slice(0, FILES_MAX);
23
+ }
24
+ // Who is at work, as a teammate reads it. A bot the owner shares is @name; any other bot is
25
+ // only "bot", since teammates are not told its name.
26
+ const working = (role, shared) => { const bot = botOfKey(role); return bot === null ? role : shared.has(bot) ? `@${bot}` : 'bot'; };
27
+ export function statusRows() {
28
+ const repos = registeredProjects(), rows = [];
29
+ let shared = new Set(); try { shared = new Set(sharedBots().map(bot => bot.name)); } catch { /* An unreadable bots file shares nothing. */ }
30
+ for (const share of shares().filter(row => row.status && row.repoId)) {
31
+ const repo = repos.find(row => row.root === share.root); if (!repo) continue;
32
+ let values = []; try { values = tasks(repo); } catch { continue; }
33
+ for (const task of values.filter(task => task.kind !== 'conversation' && LIVE.includes(task.state))) {
34
+ if (rows.length >= ROWS_MAX) return rows;
35
+ let files = []; try { files = changedFiles(task); } catch { files = []; }
36
+ rows.push({ repoId: share.repoId, handle: share.handle, taskId: task.id, label: firstLine(task.goal, 80), now: firstLine(task.activeRole ? `${task.state} · ${working(task.activeRole, shared)}` : task.state, 80), files });
37
+ }
38
+ }
39
+ return rows;
40
+ }
41
+ const plainFile = value => typeof value === 'string' && value.length <= 512 && !value.startsWith('/') && !/[\x00-\x1f\x7f\\]/.test(value) && !value.split('/').some(part => part === '..' || part === '.' || !part);
42
+ // Rows from another machine: anything malformed is dropped, text is sanitized.
43
+ export function cleanRows(value) {
44
+ if (!Array.isArray(value)) return [];
45
+ return value.slice(0, ROWS_MAX).flatMap(row => {
46
+ if (!isObject(row) || !HEX64.test(row.repoId ?? '') || !HANDLE.test(row.handle ?? '') || !/^[1-9][0-9]{0,9}$/.test(String(row.taskId ?? '')) || !Array.isArray(row.files)) return [];
47
+ return [{ repoId: row.repoId, handle: row.handle, taskId: String(row.taskId), label: firstLine(row.label, 80), now: firstLine(row.now, 80), files: row.files.filter(plainFile).slice(0, FILES_MAX) }];
48
+ });
49
+ }
50
+ export function saveRemoteStatus(key, rows) {
51
+ if (!HEX64.test(key)) return;
52
+ atomic(peerFile('status', `${key}.json`), { at: new Date().toISOString(), rows: cleanRows(rows) });
53
+ }
54
+ export function remoteStatus(key) {
55
+ try { const file = peerFile('status', `${key}.json`); noSymlinks(file); if (!HEX64.test(key) || !fs.existsSync(file)) return null; const value = readJson(file); return { at: value.at, rows: cleanRows(value.rows) }; } catch { return null; }
56
+ }
57
+ // elu.status also says what the machine is called (box) and which bots its owner lets
58
+ // teammates use (bots: [{name, about}], at most BOTS_TOLD). A build from before bots reads
59
+ // only the rows and sends neither, which reads here as no box name and no bots.
60
+ export const BOTS_TOLD = 20;
61
+ // From another machine each field stands alone: a box name that is not one is null, a bot
62
+ // with a bad name or an about that is not text is dropped, an about is cut to one plain line.
63
+ export const cleanBox = value => typeof value === 'string' && BOX.test(value) ? value : null;
64
+ export function cleanBots(value) {
65
+ if (!Array.isArray(value)) return [];
66
+ const seen = new Set();
67
+ return value.slice(0, BOTS_TOLD).flatMap(bot => {
68
+ if (!isObject(bot) || typeof bot.name !== 'string' || !BOT_NAME.test(bot.name) || typeof bot.about !== 'string' || seen.has(bot.name)) return [];
69
+ seen.add(bot.name);
70
+ return [{ name: bot.name, about: sanitize(bot.about, 2000).replace(/\s+/g, ' ').trim().slice(0, ABOUT_MAX).trimEnd() }];
71
+ });
72
+ }
73
+ // This machine as teammates are told of it. A bots file that cannot be read shares none.
74
+ export function thisMachine() {
75
+ let bots = []; try { bots = sharedBots(); } catch { /* Nothing is shared until the file is fixed. */ }
76
+ return { box: peerBox(), bots: cleanBots(bots) };
77
+ }
78
+ // Each teammate row gains overlap: files it shares with my live rows of the same repoId.
79
+ export function overlaps(mine, theirs) {
80
+ return theirs.map(row => {
81
+ const local = new Set(mine.filter(m => m.repoId === row.repoId).flatMap(m => m.files));
82
+ return { ...row, overlap: row.files.filter(file => local.has(file)) };
83
+ });
84
+ }
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { atomic, hash, noSymlinks, readJson, refuse, isObject } from '../util.mjs';
4
+ import { canonical } from './canonical.mjs';
5
+ import { waitLock } from '../state.mjs';
6
+ import { peerFile, peerRepo } from './paths.mjs';
7
+ import { isTerminal, validId, validateTask } from './a2a.mjs';
8
+
9
+ // Tasks this machine serves (incoming asks), one file each, so they survive restarts.
10
+ // A record is {task, peer, name, messageId, threadId, kind, project, root, requestId,
11
+ // text, waiting, reply, costUsd, localTaskId, createdAt}. The idempotency index maps
12
+ // (sender key, messageId) to the taskId that SendMessage returned the first time.
13
+ const HEX64 = /^[0-9a-f]{64}$/;
14
+ const INDEX_MAX = 20000;
15
+ const dir = () => peerFile('tasks');
16
+ const file = id => path.join(dir(), `${validId(id, 'Task id')}.json`);
17
+ const indexFile = () => peerFile('idempotency.json');
18
+ const locked = fn => { const release = waitLock(peerRepo(), 'tasks'); try { return fn(); } finally { release(); } };
19
+ export function getRecord(id) {
20
+ let target; try { target = file(id); } catch { return null; }
21
+ noSymlinks(target); if (!fs.existsSync(target)) return null;
22
+ try { const record = readJson(target); return isObject(record) && record.task?.id === id ? record : null; } catch { return null; }
23
+ }
24
+ export function saveRecord(record) {
25
+ if (!HEX64.test(record?.peer ?? '')) refuse('A served task needs the sender key.');
26
+ record.task = validateTask(record.task);
27
+ atomic(file(record.task.id), record); return record;
28
+ }
29
+ // Read-modify-write of one record under the store lock; fn returns the new record or null.
30
+ export function updateRecord(id, fn) {
31
+ return locked(() => { const record = getRecord(id); if (!record) return null; const next = fn(record); return next ? saveRecord(next) : record; });
32
+ }
33
+ export function records() {
34
+ noSymlinks(dir()); if (!fs.existsSync(dir())) return [];
35
+ return fs.readdirSync(dir()).filter(name => name.endsWith('.json')).flatMap(name => { const record = getRecord(name.slice(0, -5)); return record ? [record] : []; });
36
+ }
37
+ export const findByRequest = requestId => records().find(record => record.requestId === requestId) ?? null;
38
+ function readIndex() {
39
+ try { noSymlinks(indexFile()); if (!fs.existsSync(indexFile())) return {}; const value = readJson(indexFile()); return isObject(value) ? value : {}; } catch { return {}; }
40
+ }
41
+ const indexKey = (peer, messageId) => `${peer}:${messageId}`;
42
+ export function lookup(peer, messageId) { const row = readIndex()[indexKey(peer, messageId)]; return typeof row?.taskId === 'string' ? row.taskId : null; }
43
+ // With replace, an entry whose record is missing (a crash after remember) is repointed.
44
+ export function remember(peer, messageId, taskId, { replace = false } = {}) {
45
+ return locked(() => {
46
+ const index = readIndex(), key = indexKey(peer, messageId);
47
+ if (index[key] && !(replace && !getRecord(index[key].taskId))) return index[key].taskId;
48
+ index[key] = { taskId, at: Date.now() };
49
+ const entries = Object.entries(index);
50
+ // The oldest entries go first once the index is full; their tasks are long finished.
51
+ const kept = entries.length > INDEX_MAX ? Object.fromEntries(entries.sort((a, b) => b[1].at - a[1].at).slice(0, INDEX_MAX)) : index;
52
+ atomic(indexFile(), kept); return taskId;
53
+ });
54
+ }
55
+
56
+ // threads/<threadId>.jsonl is the audit log of a thread, both directions. Each line is
57
+ // {at, dir, peer, kind, text, cost, prev, hash}: prev is the previous line's hash and
58
+ // hash is sha256 over the canonical line without it, so an edited line breaks the chain.
59
+ const THREAD = /^[A-Za-z0-9_-]{1,128}$/;
60
+ export const THREAD_MAX = 4 * 1024 * 1024;
61
+ // The last line of a thread file, read from its end so a long thread is never read whole.
62
+ function lastLine(target, size) {
63
+ const length = Math.min(size, 2 * 1024 * 1024), buffer = Buffer.alloc(length), fd = fs.openSync(target, 'r');
64
+ try { fs.readSync(fd, buffer, 0, length, size - length); } finally { fs.closeSync(fd); }
65
+ return buffer.toString('utf8').trimEnd().split('\n').at(-1) || 'null';
66
+ }
67
+ // Lines are appended under the thread lock; a thread past THREAD_MAX records no more.
68
+ export function audit(threadId, { dir: direction, peer, kind, text = '', cost = null }) {
69
+ if (!THREAD.test(threadId ?? '')) return null;
70
+ const target = peerFile('threads', `${threadId}.jsonl`);
71
+ const release = waitLock(peerRepo(), `thread-${threadId}`);
72
+ try {
73
+ noSymlinks(target);
74
+ const size = fs.existsSync(target) ? fs.statSync(target).size : 0;
75
+ if (size > THREAD_MAX) return null;
76
+ let prev = null; try { prev = size ? JSON.parse(lastLine(target, size))?.hash ?? null : null; } catch { prev = null; }
77
+ const line = { at: new Date().toISOString(), dir: direction, peer, kind, text: String(text).slice(0, 160000), cost, prev };
78
+ line.hash = hash(canonical(line));
79
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
80
+ fs.appendFileSync(target, `${JSON.stringify(line)}\n`, { mode: 0o600 }); return line;
81
+ } finally { release(); }
82
+ }
83
+ // Finished records and quiet threads older than RETAIN_MS are removed.
84
+ export const RETAIN_MS = 30 * 24 * 3600 * 1000;
85
+ export function prune(now = Date.now()) {
86
+ for (const record of records()) {
87
+ if (!isTerminal(record.task.status.state) || now - Date.parse(record.createdAt) <= RETAIN_MS) continue;
88
+ fs.rmSync(file(record.task.id), { force: true });
89
+ // The pictures a teammate sent with it (pictures.mjs) go with the record.
90
+ if (HEX64.test(record.peer ?? '')) fs.rmSync(peerFile('pictures', 'kept', record.peer, record.task.id), { recursive: true, force: true });
91
+ }
92
+ const folder = peerFile('threads'); noSymlinks(folder); if (!fs.existsSync(folder)) return;
93
+ for (const name of fs.readdirSync(folder).filter(name => THREAD.test(name.replace(/\.jsonl$/, '')))) {
94
+ const target = path.join(folder, name);
95
+ try { if (now - fs.lstatSync(target).mtimeMs > RETAIN_MS) fs.rmSync(target, { force: true }); } catch { /* Gone already. */ }
96
+ }
97
+ }
98
+ export function thread(threadId) {
99
+ if (!THREAD.test(threadId ?? '')) return [];
100
+ const target = peerFile('threads', `${threadId}.jsonl`); noSymlinks(target);
101
+ if (!fs.existsSync(target)) return [];
102
+ return fs.readFileSync(target, 'utf8').split('\n').filter(Boolean).flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
103
+ }
104
+ export function verifyThread(lines) {
105
+ let prev = null;
106
+ for (const { hash: digest, ...line } of lines) { if (line.prev !== prev || hash(canonical(line)) !== digest) return false; prev = digest; }
107
+ return true;
108
+ }
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs';
2
+ import { EventEmitter } from 'node:events';
3
+ import { atomic, noSymlinks, readJson, refuse } from '../util.mjs';
4
+ import { peerFile } from './paths.mjs';
5
+
6
+ // The binding's transport (docs/peer-network.md, "Binding"): Hyperswarm with the
7
+ // identity seed as the Noise key pair, NDJSON frames of at most 1 MiB, and the team's
8
+ // relay keys as an array. hyperswarm and hyperdht are optional, so they load here only.
9
+ export const FRAME_MAX = 1024 * 1024;
10
+ const HEX64 = /^[0-9a-f]{64}$/;
11
+ export const MISSING = 'Peer mode needs the optional networking packages. Reinstall with optional dependencies.';
12
+ export async function loadNetwork() {
13
+ try {
14
+ const [swarm, dht] = await Promise.all([import('hyperswarm'), import('hyperdht')]);
15
+ return { Hyperswarm: swarm.default, DHT: dht.default };
16
+ } catch { refuse(MISSING); }
17
+ }
18
+ export function parseBootstrap(list) {
19
+ if (list == null) return undefined;
20
+ if (!Array.isArray(list) || list.length > 32) refuse('Bootstrap nodes must be a list of host:port.');
21
+ return list.map(item => {
22
+ const match = typeof item === 'string' && /^([A-Za-z0-9.-]{1,253}|\[[0-9a-fA-F:]+\]):(\d{1,5})$/.exec(item);
23
+ if (!match || Number(match[2]) < 1 || Number(match[2]) > 65535) refuse('Bootstrap nodes must be a list of host:port.');
24
+ return { host: match[1].replace(/^\[|\]$/g, ''), port: Number(match[2]) };
25
+ });
26
+ }
27
+ function savedNodes() {
28
+ try {
29
+ const file = peerFile('routing.json'); noSymlinks(file); if (!fs.existsSync(file)) return undefined;
30
+ const rows = readJson(file);
31
+ const nodes = Array.isArray(rows) ? rows.filter(row => typeof row?.host === 'string' && row.host.length < 256 && Number.isInteger(row.port) && row.port > 0 && row.port < 65536).slice(0, 256).map(({ host, port }) => ({ host, port })) : [];
32
+ return nodes.length ? nodes : undefined;
33
+ } catch { return undefined; }
34
+ }
35
+
36
+ // Splits a stream into JSON frames. A line over FRAME_MAX, or one that is not JSON,
37
+ // is a violation: the caller closes the connection.
38
+ export function frames(socket, onFrame, onViolation) {
39
+ let chunks = [], size = 0, stopped = false;
40
+ const stop = reason => { if (!stopped) { stopped = true; chunks = []; onViolation(reason); } };
41
+ socket.on('data', data => {
42
+ if (stopped) return;
43
+ let start = 0, index;
44
+ while ((index = data.indexOf(10, start)) !== -1) {
45
+ const part = data.subarray(start, index); start = index + 1;
46
+ if (size + part.length > FRAME_MAX) return stop('frame too large');
47
+ const line = chunks.length ? Buffer.concat([...chunks, part]).toString('utf8') : part.toString('utf8');
48
+ chunks = []; size = 0;
49
+ if (!line.trim()) continue;
50
+ let value; try { value = JSON.parse(line); } catch { return stop('invalid JSON'); }
51
+ onFrame(value); if (stopped) return;
52
+ }
53
+ const rest = data.subarray(start);
54
+ if (size + rest.length > FRAME_MAX) return stop('frame too large');
55
+ if (rest.length) { chunks.push(rest); size += rest.length; }
56
+ });
57
+ }
58
+ export function writeFrame(socket, value) {
59
+ const line = `${JSON.stringify(value)}\n`;
60
+ if (Buffer.byteLength(line) > FRAME_MAX) refuse('A peer message is larger than 1 MiB.');
61
+ if (!socket.destroyed) socket.write(line);
62
+ }
63
+
64
+ // testing: {dht: extra DHT options, connect: extra connect options for peer dials,
65
+ // noServerPunch} lets the relay test force relaying; production passes none of these.
66
+ export async function createNode({ identity, bootstrap, relays = [], nodes, testing = {} } = {}) {
67
+ const { Hyperswarm, DHT } = await loadNetwork();
68
+ const keyPair = DHT.keyPair(identity.seed), relayKeys = [...new Set(relays)].filter(key => HEX64.test(key)).slice(0, 16);
69
+ // The DHT carries the identity key pair too, so relay connections present it and a
70
+ // relay can admit team members only. The node stays ephemeral: it never joins other
71
+ // nodes' routing tables, so strangers' lookups, announcements and connections are not
72
+ // routed through it, however long it runs. It still announces its key and connects.
73
+ const dht = new DHT({ keyPair, bootstrap: parseBootstrap(bootstrap), nodes: nodes ?? savedNodes(), ephemeral: true, ...testing.dht });
74
+ const node = new EventEmitter(), sockets = new Set(), connect = dht.connect.bind(dht), peers = new Set();
75
+ const counters = { direct: 0, relayed: 0, failed: 0 };
76
+ // Every socket hyperdht opens goes through here, including its own relay sockets.
77
+ dht.connect = (key, opts = {}) => {
78
+ const hex = Buffer.from(key).toString('hex'), peer = peers.has(hex);
79
+ const socket = connect(key, peer ? { ...opts, ...testing.connect } : opts);
80
+ sockets.add(socket); let opened = false;
81
+ socket.on('open', () => { opened = true; });
82
+ socket.on('error', () => { if (!opened && peer) counters.failed++; });
83
+ socket.on('close', () => sockets.delete(socket));
84
+ return socket;
85
+ };
86
+ const swarm = new Hyperswarm({ dht, keyPair, firewall: () => false, ...(relayKeys.length ? { relayThrough: relayKeys.map(key => Buffer.from(key, 'hex')) } : {}) });
87
+ if (testing.noServerPunch) swarm.server.holepunch = () => false;
88
+ // A relayed stream is connected to the same UDP socket and address as hyperdht's own
89
+ // socket to the relay; a direct one is not. This is re-checked on every read, because a
90
+ // relayed connection can later upgrade to direct.
91
+ const pathOf = socket => {
92
+ const raw = socket.rawStream; if (!raw || socket.destroyed) return null;
93
+ for (const other of sockets) {
94
+ const o = other.rawStream;
95
+ if (other !== socket && o && o.socket === raw.socket && o.remoteHost === raw.remoteHost && o.remotePort === raw.remotePort && other.remotePublicKey && !other.remotePublicKey.equals(socket.remotePublicKey)) return 'relayed';
96
+ }
97
+ return 'direct';
98
+ };
99
+ const connections = new Map();
100
+ swarm.on('connection', socket => {
101
+ const key = socket.remotePublicKey.toString('hex'), row = { socket, since: Date.now(), lastSeen: Date.now() };
102
+ Object.defineProperty(row, 'path', { enumerable: true, get: () => pathOf(socket) });
103
+ connections.set(key, row); counters[row.path === 'relayed' ? 'relayed' : 'direct']++;
104
+ socket.on('data', () => { row.lastSeen = Date.now(); });
105
+ socket.on('error', () => {});
106
+ socket.on('close', () => { if (connections.get(key) === row) connections.delete(key); });
107
+ node.emit('connection', socket, key);
108
+ });
109
+ Object.assign(node, {
110
+ key: keyPair.publicKey.toString('hex'), swarm, dht, connections, counters,
111
+ listen: () => swarm.listen(),
112
+ dial(key) { if (!HEX64.test(key) || key === node.key) return; peers.add(key); swarm.joinPeer(Buffer.from(key, 'hex')); },
113
+ leave(key) { if (!peers.delete(key)) return; swarm.leavePeer(Buffer.from(key, 'hex')); },
114
+ // After sleep: announce again and dial every peer afresh.
115
+ async refresh() { swarm.server?.refresh?.(); for (const key of [...peers]) { swarm.leavePeer(Buffer.from(key, 'hex')); swarm.joinPeer(Buffer.from(key, 'hex')); } },
116
+ saveRouting() { const rows = dht.toArray({ limit: 256 }); if (rows.length) atomic(peerFile('routing.json'), rows); },
117
+ async destroy() { try { node.saveRouting(); } catch { /* The cache is optional. */ } await swarm.destroy(); },
118
+ });
119
+ return node;
120
+ }
@@ -0,0 +1,104 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { HOME_SECRETS } from './vendors.mjs';
6
+ import { EluCodeError, refuse } from './util.mjs';
7
+
8
+ // A picture the owner names in a message, when it lies in a part of the home folder no
9
+ // session may read. Screenshot tools save there (CleanShot keeps its captures under
10
+ // ~/Library), so a pasted path would be refused. The picture is copied to a folder of its
11
+ // own in the temporary directory and the message names the copy. Nothing else is opened
12
+ // up: the file must be one the owner wrote out in full, and must really be a picture or a
13
+ // PDF, which its first bytes show, not its name.
14
+
15
+ const NAMED = /\.(png|jpe?g|gif|webp|heic|heif|bmp|tiff?|pdf)$/i, SIZE_MAX = 25 * 1024 * 1024;
16
+ // A path in single quotes, in double quotes, or bare with its spaces escaped, as a terminal
17
+ // pastes a dropped file.
18
+ export const PICTURE_PATHS = /'((?:~\/|\/)[^'\n]+)'|"((?:~\/|\/)[^"\n]+)"|((?:~\/|\/)(?:\\.|[^\s\\'"])+)/g;
19
+
20
+ function pictureBytes(file) {
21
+ const head = Buffer.alloc(12); let fd = null;
22
+ try { fd = fs.openSync(file, 'r'); fs.readSync(fd, head, 0, 12, 0); } catch { return false; } finally { if (fd !== null) fs.closeSync(fd); }
23
+ const text = head.toString('latin1');
24
+ return text.startsWith('\x89PNG') || text.startsWith('\xff\xd8\xff') || text.startsWith('GIF8') || text.startsWith('%PDF') || text.startsWith('BM')
25
+ || text.startsWith('II*\x00') || text.startsWith('MM\x00*') || text.startsWith('RIFF') && text.slice(8, 12) === 'WEBP' || text.slice(4, 8) === 'ftyp';
26
+ }
27
+ // The real file behind a path from a message when it is a picture no session may read, else null.
28
+ function hidden(written, home) {
29
+ const given = written.startsWith('~/') ? path.join(home, written.slice(2)) : written;
30
+ if (!NAMED.test(given)) return null;
31
+ let real; try { real = fs.realpathSync(given); if (!fs.statSync(real).isFile() || fs.statSync(real).size > SIZE_MAX) return null; } catch { return null; }
32
+ const closed = HOME_SECRETS.map(name => path.join(home, name)).some(folder => real === folder || real.startsWith(folder + path.sep));
33
+ return closed && NAMED.test(real) && pictureBytes(real) ? real : null;
34
+ }
35
+ // The message with each such picture replaced by a copy the session can read:
36
+ // { text, copied: [{ from, to }] }. A message that names none comes back as it was.
37
+ export function readablePictures(text, { home = os.homedir(), folder = os.tmpdir() } = {}) {
38
+ const copied = [];
39
+ if (typeof text !== 'string' || !/[~/]/.test(text)) return { text, copied };
40
+ let base; try { base = fs.realpathSync(home); } catch { return { text, copied }; }
41
+ const out = text.replace(PICTURE_PATHS, (whole, single, double, bare) => {
42
+ const real = hidden(single ?? double ?? bare.replace(/\\(.)/g, '$1'), base);
43
+ if (!real) return whole;
44
+ try {
45
+ const target = path.join(fs.realpathSync(folder), 'elu-code-pictures'); fs.mkdirSync(target, { recursive: true, mode: 0o700 });
46
+ // The temp folder can be shared with other users. A folder of that name that is a link,
47
+ // or someone else's, is not used: its owner could read or swap what is put there.
48
+ const made = fs.lstatSync(target);
49
+ if (!made.isDirectory() || (process.getuid && made.uid !== process.getuid())) return whole;
50
+ if ((made.mode & 0o777) !== 0o700) fs.chmodSync(target, 0o700);
51
+ const to = path.join(target, `${crypto.randomUUID().slice(0, 8)}-${path.basename(real).replace(/[^A-Za-z0-9._@-]+/g, '-')}`);
52
+ fs.copyFileSync(real, to, fs.constants.COPYFILE_EXCL); fs.chmodSync(to, 0o600);
53
+ copied.push({ from: real, to });
54
+ return to;
55
+ } catch { return whole; }
56
+ });
57
+ return { text: out, copied };
58
+ }
59
+
60
+ // The kinds of picture a teammate can be sent, told by a file's first bytes:
61
+ // [media type, file ending], or null for anything else.
62
+ export function pictureKind(head) {
63
+ const text = Buffer.from(head.subarray(0, 12)).toString('latin1');
64
+ if (text.startsWith('\x89PNG\r\n\x1a\n')) return ['image/png', 'png'];
65
+ if (text.startsWith('\xff\xd8\xff')) return ['image/jpeg', 'jpg'];
66
+ if (text.startsWith('GIF87a') || text.startsWith('GIF89a')) return ['image/gif', 'gif'];
67
+ if (text.startsWith('RIFF') && text.slice(8, 12) === 'WEBP') return ['image/webp', 'webp'];
68
+ return null;
69
+ }
70
+ // ELU Code's own pictures folder in the temporary directory: a real folder of this user,
71
+ // closed to everyone else. null when it cannot be had.
72
+ export function picturesRoot(folder = os.tmpdir()) {
73
+ try {
74
+ const target = path.join(fs.realpathSync(folder), 'elu-code-pictures'); fs.mkdirSync(target, { recursive: true, mode: 0o700 });
75
+ const made = fs.lstatSync(target);
76
+ if (!made.isDirectory() || (process.getuid && made.uid !== process.getuid())) return null;
77
+ if ((made.mode & 0o777) !== 0o700) fs.chmodSync(target, 0o700);
78
+ return target;
79
+ } catch { return null; }
80
+ }
81
+ // The one folder a teammate's question run may read beside the shared project: the copies
82
+ // of the pictures sent with that question (lib/peer/pictures.mjs). It must be a real folder
83
+ // directly inside the pictures folder above, with no link anywhere in its path, this
84
+ // user's and closed to others, and hold nothing but pictures under generated names.
85
+ const RUN_FOLDER = /^q-[0-9a-f]{16}$/, RUN_FILE = /^[1-9]\.(png|jpg|gif|webp)$/;
86
+ export function checkRunPictures(folder, { temp = os.tmpdir() } = {}) {
87
+ const bad = () => refuse("A teammate's pictures must be in ELU Code's own pictures folder.");
88
+ const closed = (stat, mode) => (!process.getuid || stat.uid === process.getuid()) && (process.platform === 'win32' || (stat.mode & 0o777) === mode);
89
+ if (typeof folder !== 'string' || !path.isAbsolute(folder) || !RUN_FOLDER.test(path.basename(folder))) bad();
90
+ try {
91
+ const root = path.join(fs.realpathSync(temp), 'elu-code-pictures');
92
+ if (path.dirname(folder) !== root || fs.realpathSync(folder) !== folder) bad();
93
+ for (const dir of [root, folder]) { const stat = fs.lstatSync(dir); if (!stat.isDirectory() || !closed(stat, 0o700)) bad(); }
94
+ const names = fs.readdirSync(folder);
95
+ if (!names.length || names.length > 4) bad();
96
+ for (const name of names) {
97
+ const file = path.join(folder, name), stat = fs.lstatSync(file), head = Buffer.alloc(12);
98
+ if (!RUN_FILE.test(name) || !stat.isFile() || !closed(stat, 0o600)) bad();
99
+ const fd = fs.openSync(file, 'r'); try { fs.readSync(fd, head, 0, 12, 0); } finally { fs.closeSync(fd); }
100
+ if (!pictureKind(head)) bad();
101
+ }
102
+ } catch (error) { if (error instanceof EluCodeError) throw error; bad(); }
103
+ return folder;
104
+ }