secure-agents 0.0.0-stage → 0.7.2

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.js ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // secure: set up this computer, run the daemon, serve the agent's MCP tools, and approve from the terminal.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { DEFAULT_PORT, ensureHome, moveOldHome, file, HOME, readJson, readSettings, requireSettings, writeJson } from './config.js';
7
+ import { loadOrCreateAgentKey, startControl } from './control.js';
8
+ import { loadOrCreateIdentity, newIdentity } from './identity.js';
9
+ import { Link } from './link.js';
10
+ import HyperDHT from 'hyperdht';
11
+ import { runMcp } from './mcp.js';
12
+ import { runRelay } from './relay.js';
13
+ import { connectAgents, openPage } from './setup.js';
14
+ import { APP_DIR, appCli, currentVersion, installedVersion, installVersion, newer, runningAs } from './update.js';
15
+ import { spawn } from 'node:child_process';
16
+ import { readPassphrase } from './prompt.js';
17
+ import { audit, Store, verifyAudit } from './store.js';
18
+ import { createVault, exportKey, MIN_PASSPHRASE, unlock, useKey, vaultExists } from './vault.js';
19
+ const [cmd, ...args] = process.argv.slice(2);
20
+ const CLI = fileURLToPath(import.meta.url);
21
+ const START = runningAs() === 'source' ? `node "${CLI}" start` : 'npx secure-agents start';
22
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
23
+ const HELP = `secure: let AI agents on different computers talk, with both people's consent.
24
+
25
+ secure guide setup steps for an AI agent (it follows them for you)
26
+ secure start --name <computer-name> first time: set up, connect your agents, run, open the page
27
+ secure start every time after: run Secure (keep the window open)
28
+ secure init --name <name> | daemon the same in two separate steps
29
+ secure ui print the approval page address
30
+ secure pending list what waits for your approval
31
+ secure deny <id> deny one item (asks for your passphrase)
32
+ Approving, sharing folders, rules and wake-up are on the approval page: each needs a passkey touch
33
+ (Touch ID, Windows Hello or your phone), so an agent can't do them even if it knows your passphrase.
34
+ secure audit check that the audit log was not edited
35
+ secure rotate-identity new key pair for this computer (stop the daemon first; re-pair after)
36
+ secure id this computer's public key (to allow it on a relay)
37
+ secure use-relay <relay-key> | off connect only through a relay: paired peers never see your IP
38
+ secure relay run a relay (on a server you trust), prints its key
39
+ secure relay allow <public-key> let a computer use this relay
40
+ secure brain [search words] list or search your saved decisions
41
+ secure mcp MCP server for your agent (stdio)
42
+
43
+ Your passphrase is never saved. Your agent must never know it: type it yourself, don't put it in a file or a prompt.
44
+
45
+ "secure start" connects Claude Code and Codex for you. For another agent, add a local (stdio) MCP server: secure mcp
46
+ Data folder: ${HOME}`;
47
+ const readSettingsFile = () => readJson('settings.json', {});
48
+ /** Signs in with the passphrase for one command, then signs out. */
49
+ async function human(method, p, body) {
50
+ const { port } = requireSettings();
51
+ const base = `http://127.0.0.1:${port}`;
52
+ const headers = { 'x-secure-human': '1', 'content-type': 'application/json' };
53
+ const go = async (m, q, b, extra = {}) => {
54
+ const res = await fetch(`${base}${q}`, { method: m, headers: { ...headers, ...extra }, body: b ? JSON.stringify(b) : undefined })
55
+ .catch(() => { throw new Error('The daemon is not running. Start it with: secure daemon'); });
56
+ const json = await res.json();
57
+ if (!res.ok)
58
+ throw new Error(json.error ?? String(res.status));
59
+ return json;
60
+ };
61
+ const { token } = await go('POST', '/human/login', { passphrase: await readPassphrase('Secure passphrase: ') });
62
+ try {
63
+ return await go(method, p, body, { 'x-secure-session': token });
64
+ }
65
+ finally {
66
+ await go('POST', '/human/logout', null, { 'x-secure-session': token }).catch(() => { });
67
+ }
68
+ }
69
+ /** "npx secure-agents start": the first time, keep this version in ~/.secure/app; after that always run that pinned copy. Updates are approved on the page. */
70
+ async function runPinned() {
71
+ ensureHome();
72
+ const have = installedVersion(), got = currentVersion();
73
+ if (!have) {
74
+ console.log(`Installing Secure ${got} in ${APP_DIR}…`);
75
+ installVersion(got);
76
+ }
77
+ else if (newer(got, have)) {
78
+ console.log(`Secure ${got} is available (this computer runs ${have}). Install it on the approval page: it needs your passkey.`);
79
+ }
80
+ const child = spawn(process.execPath, [appCli(), 'start', ...args], { stdio: 'inherit' });
81
+ for (const sig of ['SIGINT', 'SIGTERM'])
82
+ process.on(sig, () => child.kill(sig));
83
+ await new Promise((resolve) => child.on('exit', (code) => { process.exitCode = code ?? 0; resolve(); }));
84
+ }
85
+ /** First run: name, passphrase (typed twice), key pair and the agent's key. */
86
+ async function setUp(name) {
87
+ if (!/^[a-z0-9][a-z0-9-]{1,40}$/i.test(name))
88
+ throw new Error('Use letters, numbers and dashes for the name, e.g. my-laptop.');
89
+ ensureHome();
90
+ const port = Number(flag('port') ?? readSettings()?.port ?? DEFAULT_PORT);
91
+ writeJson('settings.json', { ...readSettingsFile(), name, port });
92
+ if (!vaultExists()) {
93
+ console.error(`Choose a passphrase (at least ${MIN_PASSPHRASE} characters). You type it to start Secure and to sign in to its page.\nType it yourself: your agent must never know it. It can't be recovered, so keep it somewhere safe.`);
94
+ const pass = await readPassphrase('Passphrase: ');
95
+ if (process.stdin.isTTY && pass !== await readPassphrase('Again: '))
96
+ throw new Error('The two passphrases are different.');
97
+ createVault(pass);
98
+ }
99
+ const kp = loadOrCreateIdentity();
100
+ loadOrCreateAgentKey();
101
+ console.log(`Set up "${name}" in ${HOME} (key ${kp.publicKey.toString('hex').slice(0, 16)}…)`);
102
+ }
103
+ async function runDaemon(settings, carriedSessions = []) {
104
+ const key = loadOrCreateAgentKey();
105
+ const link = new Link(settings);
106
+ let control = null;
107
+ const stop = async () => { await link.stop(); process.exit(0); };
108
+ link.on('restart', () => { if (control)
109
+ void restartInPlace(link, control, stop); });
110
+ control = await startControl(link, settings.port, key, carriedSessions).catch((e) => {
111
+ throw new Error(e.code === 'EADDRINUSE' ? `Secure is already running on this computer (port ${settings.port}). Use that window, or close it first.` : e.message);
112
+ });
113
+ await link.start();
114
+ audit('daemon_started', { name: settings.name });
115
+ console.log(`Secure "${settings.name}" is running${settings.relay ? ' through the relay (peers cannot see your IP)' : ''}.\nApprovals: http://localhost:${settings.port}/ui (passphrase, then a passkey touch for each approval)`);
116
+ link.on('item', (it) => {
117
+ if (it.status !== 'awaiting_human')
118
+ return;
119
+ const warn = it.data?.warnings?.length ? `\n WARNING: this ${it.data.warnings.join('; ')}` : '';
120
+ console.log(`\n[needs you] ${it.kind} ${it.direction === 'in' ? 'from' : 'to'} ${it.peerName}: ${it.text.slice(0, 120)}${warn}\n approve on http://localhost:${settings.port}/ui`);
121
+ });
122
+ process.on('SIGINT', stop);
123
+ process.on('SIGTERM', stop);
124
+ }
125
+ /** After a passkey-approved update: stop, start the new version in this same window, and hand it the unlocked key and page sign-ins over a private channel. */
126
+ async function restartInPlace(link, control, stop) {
127
+ console.log('\nUpdate installed. Restarting Secure with the new version…');
128
+ const handoff = { key: exportKey(), sessions: control.sessions() };
129
+ process.off('SIGINT', stop);
130
+ process.off('SIGTERM', stop);
131
+ await link.stop();
132
+ control.server.closeAllConnections();
133
+ await new Promise((resolve) => control.server.close(() => resolve()));
134
+ const child = spawn(process.execPath, [appCli(), 'daemon', '--handoff'], { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] });
135
+ child.send(handoff, () => child.disconnect());
136
+ for (const sig of ['SIGINT', 'SIGTERM'])
137
+ process.on(sig, () => child.kill(sig));
138
+ child.on('exit', (code) => process.exit(code ?? 0));
139
+ }
140
+ function takeHandoff() {
141
+ return new Promise((resolve, reject) => {
142
+ if (!process.send)
143
+ return reject(new Error('--handoff is only used by Secure itself after an update.'));
144
+ process.once('message', (m) => resolve(m));
145
+ });
146
+ }
147
+ async function main() {
148
+ if (['start', 'init', 'daemon'].includes(cmd)) {
149
+ const moved = await moveOldHome();
150
+ if (moved)
151
+ console.log(moved);
152
+ }
153
+ switch (cmd) {
154
+ case 'start': {
155
+ if (runningAs() === 'download')
156
+ return runPinned();
157
+ const name = flag('name');
158
+ let settings = readSettings();
159
+ if (!vaultExists()) {
160
+ if (!name)
161
+ throw new Error(`First time on this computer: give it a name.\n ${START} --name my-laptop`);
162
+ await setUp(name);
163
+ settings = requireSettings();
164
+ }
165
+ else {
166
+ if (!settings)
167
+ throw new Error('Secure data is incomplete on this computer.');
168
+ if (name && name !== settings.name)
169
+ console.log(`(This computer is already set up as "${settings.name}"; keeping that name.)`);
170
+ unlock(await readPassphrase('Secure passphrase: '));
171
+ }
172
+ if (!args.includes('--no-agents'))
173
+ for (const line of connectAgents(CLI))
174
+ console.log(line);
175
+ await runDaemon(settings);
176
+ if (!args.includes('--no-open'))
177
+ openPage(`http://localhost:${settings.port}/ui`);
178
+ console.log(`\nKeep this window open: Secure works only while it runs.\nNext time, start it with: ${START}\nRestart your agent (Claude Code, Codex…) once so it loads the Secure tools.`);
179
+ return;
180
+ }
181
+ case 'init': {
182
+ const name = flag('name');
183
+ if (!name)
184
+ throw new Error(`Give this computer a name: ${START} --name my-laptop`);
185
+ if (vaultExists())
186
+ unlock(await readPassphrase('Secure passphrase: '));
187
+ await setUp(name);
188
+ return;
189
+ }
190
+ case 'daemon': {
191
+ const settings = requireSettings();
192
+ if (args.includes('--handoff')) {
193
+ const h = await takeHandoff();
194
+ useKey(h.key);
195
+ await runDaemon(settings, h.sessions);
196
+ return;
197
+ }
198
+ unlock(await readPassphrase('Secure passphrase: '));
199
+ await runDaemon(settings);
200
+ return;
201
+ }
202
+ case 'ui': {
203
+ console.log(`http://localhost:${requireSettings().port}/ui`);
204
+ return;
205
+ }
206
+ case 'pending': {
207
+ const s = await human('GET', '/human/state');
208
+ if (!s.pending.length)
209
+ console.log('Nothing waiting.');
210
+ for (const i of s.pending)
211
+ console.log(`${i.id} ${i.kind} ${i.direction === 'in' ? 'from' : 'to'} ${i.peerName}${i.data?.warnings?.length ? `\n WARNING: this ${i.data.warnings.join('; ')}` : ''}\n ${i.text.slice(0, 300)}`);
212
+ return;
213
+ }
214
+ case 'deny': {
215
+ if (!args[0])
216
+ throw new Error('Usage: secure deny <id>');
217
+ const r = await human('POST', '/human/deny', { id: args[0] });
218
+ console.log(`Denied: ${r.status}`);
219
+ return;
220
+ }
221
+ case 'approve':
222
+ case 'share':
223
+ case 'unshare':
224
+ case 'rule':
225
+ case 'wake': {
226
+ console.log(`This needs your passkey (Touch ID, Windows Hello or your phone), so do it on the approval page:\n http://localhost:${requireSettings().port}/ui\nThe terminal can only list (secure pending) and deny (secure deny <id>).`);
227
+ return;
228
+ }
229
+ case 'audit': {
230
+ requireSettings();
231
+ unlock(await readPassphrase('Secure passphrase: '));
232
+ const r = verifyAudit();
233
+ console.log(r.brokenAt === null ? `Audit log intact: ${r.lines} lines.` : `Audit log was edited: line ${r.brokenAt} of ${r.lines} (${r.reason}).`);
234
+ if (r.brokenAt !== null)
235
+ process.exitCode = 2;
236
+ return;
237
+ }
238
+ case 'rotate-identity': {
239
+ const settings = requireSettings();
240
+ if (await fetch(`http://127.0.0.1:${settings.port}/ui`).then(() => true, () => false))
241
+ throw new Error('Stop the daemon first.');
242
+ unlock(await readPassphrase('Secure passphrase: '));
243
+ const store = new Store(settings.limits.maxItems);
244
+ const before = store.peers.length;
245
+ const kp = newIdentity();
246
+ store.peers = [];
247
+ store.joins = [];
248
+ store.invites = [];
249
+ store.save();
250
+ fs.rmSync(file('brain-copies.sealed'), { force: true });
251
+ audit('identity_rotated', { publicKey: kp.publicKey.toString('hex'), peersRemoved: before });
252
+ console.log(`New identity ${kp.publicKey.toString('hex').slice(0, 16)}…. ${before} paired agent(s) removed: pair with them again.`);
253
+ return;
254
+ }
255
+ case 'id': {
256
+ requireSettings();
257
+ unlock(await readPassphrase('Secure passphrase: '));
258
+ console.log(loadOrCreateIdentity().publicKey.toString('hex'));
259
+ return;
260
+ }
261
+ case 'use-relay': {
262
+ if (!args[0])
263
+ throw new Error('Usage: secure use-relay <relay-key> or secure use-relay off');
264
+ if (args[0] !== 'off' && !/^[0-9a-f]{64}$/.test(args[0]))
265
+ throw new Error('A relay key is 64 hex characters, printed by "secure relay".');
266
+ writeJson('settings.json', { ...readSettingsFile(), relay: args[0] === 'off' ? null : args[0] });
267
+ console.log(args[0] === 'off' ? 'Direct connections again: paired peers can see your IP. Restart the daemon.' : 'This computer now connects only through that relay. Restart the daemon, and set the same relay on the other computer.');
268
+ return;
269
+ }
270
+ case 'relay': {
271
+ ensureHome();
272
+ const allowed = () => new Set(readJson('relay-allow.json', []));
273
+ if (args[0] === 'allow') {
274
+ if (!/^[0-9a-f]{64}$/.test(args[1] ?? ''))
275
+ throw new Error('Usage: secure relay allow <public-key> (the key "secure id" prints on that computer)');
276
+ writeJson('relay-allow.json', [...new Set([...allowed(), args[1]])]);
277
+ console.log(`Allowed ${args[1].slice(0, 16)}…. ${allowed().size} computer(s) may use this relay.`);
278
+ return;
279
+ }
280
+ const saved = readJson('relay-key.json', null);
281
+ const kp = saved ? { publicKey: Buffer.from(saved.publicKey, 'hex'), secretKey: Buffer.from(saved.secretKey, 'hex') } : HyperDHT.keyPair();
282
+ if (!saved)
283
+ writeJson('relay-key.json', { publicKey: kp.publicKey.toString('hex'), secretKey: kp.secretKey.toString('hex') });
284
+ const stop = await runRelay(kp, allowed, (m) => console.log(`${new Date().toISOString()} ${m}`));
285
+ console.log(`Secure relay running.\nRelay key: ${kp.publicKey.toString('hex')}\nOn each computer: secure use-relay ${kp.publicKey.toString('hex')}\nAllowed computers: ${allowed().size} (add with: secure relay allow <key>)`);
286
+ const end = async () => { await stop(); process.exit(0); };
287
+ process.on('SIGINT', end);
288
+ process.on('SIGTERM', end);
289
+ return;
290
+ }
291
+ case 'brain': {
292
+ const { port } = requireSettings();
293
+ const key = fs.readFileSync(file('agent.key'), 'utf8').trim();
294
+ const r = await fetch(`http://127.0.0.1:${port}/agent/brain?q=${encodeURIComponent(args.join(' '))}`, { headers: { 'x-secure-key': key } }).then((x) => x.json());
295
+ if (!r.length)
296
+ console.log('No decisions found.');
297
+ for (const d of r)
298
+ console.log(`${d.shared ? '[shared] ' : '[private]'} ${d.title}\n ${d.decision}\n sources: ${d.sources.join(', ') || 'none'}`);
299
+ return;
300
+ }
301
+ case 'mcp':
302
+ await runMcp();
303
+ return;
304
+ case 'guide':
305
+ console.log(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'AGENTS.md'), 'utf8'));
306
+ return;
307
+ default: console.log(HELP);
308
+ }
309
+ }
310
+ main().catch((e) => { console.error(e instanceof Error ? e.message : e); process.exit(1); });
package/dist/config.js ADDED
@@ -0,0 +1,62 @@
1
+ // Where this computer's Secure data lives, and the few settings it has.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ export const HOME = process.env.SECURE_HOME ?? path.join(os.homedir(), '.secure');
6
+ export const file = (name) => path.join(HOME, name);
7
+ /** Before the rename to Secure, data lived in ~/.securee; it moves here once, keeping the keys, pairings and passphrase. */
8
+ export async function moveOldHome() {
9
+ const old = path.join(os.homedir(), '.securee');
10
+ if (process.env.SECURE_HOME || fs.existsSync(HOME) || !fs.existsSync(old))
11
+ return null;
12
+ const port = (() => { try {
13
+ return JSON.parse(fs.readFileSync(path.join(old, 'settings.json'), 'utf8')).port ?? DEFAULT_PORT;
14
+ }
15
+ catch {
16
+ return DEFAULT_PORT;
17
+ } })();
18
+ const running = await fetch(`http://127.0.0.1:${port}/ui`, { signal: AbortSignal.timeout(1500) }).then(() => true, () => false);
19
+ if (running)
20
+ throw new Error('The old Securee is still running. Close its window (or stop it), then run this again: your data moves to ~/.secure.');
21
+ try {
22
+ fs.renameSync(old, HOME);
23
+ }
24
+ catch (e) {
25
+ const code = e.code;
26
+ if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')
27
+ throw e;
28
+ throw new Error(`Could not move ${old}: something still uses it, usually Claude Code or Codex running the old Securee tools. Close Claude Code and Codex completely (or restart the computer), then run this again.`);
29
+ }
30
+ return `Moved your data from ${old} to ${HOME}: same keys, pairings and passphrase.`;
31
+ }
32
+ export const DEFAULT_LIMITS = { messagesPerMinute: 60, maxPendingPerPeer: 20, maxItems: 2000, maxLoginFailures: 5, lockoutSeconds: 60, sessionHours: 12, pendingHours: 24, updateCheckHours: 6, updateCheckOnOpenMinutes: 60, minFreeDiskMB: 1024, precheckSeconds: 120, historyLogBytes: 16384, notifyGroupSeconds: 3, fileLimitMaxMB: 51200, wakeMinutes: 240 };
33
+ export const DEFAULT_PORT = 47800;
34
+ export const DEFAULT_MAX_FILE_BYTES = 200 * 1024 * 1024;
35
+ export function ensureHome() {
36
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
37
+ }
38
+ export function readJson(name, fallback) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(file(name), 'utf8'));
41
+ }
42
+ catch {
43
+ return fallback;
44
+ }
45
+ }
46
+ /** Atomic write, readable only by this OS user. */
47
+ export function writeJson(name, value) {
48
+ ensureHome();
49
+ const tmp = file(`${name}.tmp`);
50
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
51
+ fs.renameSync(tmp, file(name));
52
+ }
53
+ export function readSettings() {
54
+ const s = readJson('settings.json', null);
55
+ return s?.name ? { name: s.name, port: s.port ?? DEFAULT_PORT, maxFileBytes: s.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, limits: { ...DEFAULT_LIMITS, ...s.limits }, relay: s.relay ?? null, notify: s.notify ?? true } : null;
56
+ }
57
+ export function requireSettings() {
58
+ const s = readSettings();
59
+ if (!s)
60
+ throw new Error(`Secure is not set up here. Run: secure init --name <agent-name>`);
61
+ return s;
62
+ }
@@ -0,0 +1,251 @@
1
+ // Local-only HTTP API on 127.0.0.1. The agent's key can ask, read and reply. Approving anything needs the passphrase, which is never stored on disk.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import { file } from './config.js';
6
+ import { RULE_KEYS, verifyAudit } from './store.js';
7
+ import { Passkeys } from './passkey.js';
8
+ import { approvalPage } from './ui.js';
9
+ import { passphraseMatches } from './vault.js';
10
+ import { presetWake } from './waker.js';
11
+ const MAX_BODY = 1_000_000;
12
+ const COOKIE = 'secure_session';
13
+ /** The agent's key, saved for the MCP bridge. It opens only /agent/* routes. */
14
+ export function loadOrCreateAgentKey() {
15
+ try {
16
+ fs.rmSync(file('human.key'), { force: true });
17
+ }
18
+ catch { /* older versions kept one */ }
19
+ try {
20
+ const k = fs.readFileSync(file('agent.key'), 'utf8').trim();
21
+ if (k)
22
+ return k;
23
+ }
24
+ catch { /* first run */ }
25
+ const k = crypto.randomBytes(24).toString('hex');
26
+ fs.writeFileSync(file('agent.key'), k, { mode: 0o600 });
27
+ return k;
28
+ }
29
+ /** Human sign-ins live only in this process's memory; a restart signs everyone out. */
30
+ class Sessions {
31
+ limits;
32
+ tokens;
33
+ failures = 0;
34
+ lockedUntil = 0;
35
+ constructor(limits, carried = []) {
36
+ this.limits = limits;
37
+ this.tokens = new Map(carried);
38
+ }
39
+ carry() { return [...this.tokens].filter(([, exp]) => exp > Date.now()); }
40
+ login(passphrase) {
41
+ if (Date.now() < this.lockedUntil)
42
+ throw new Error(`Too many wrong passphrases. Try again in ${Math.ceil((this.lockedUntil - Date.now()) / 1000)} s.`);
43
+ if (!passphraseMatches(passphrase)) {
44
+ if (++this.failures >= this.limits.maxLoginFailures) {
45
+ this.failures = 0;
46
+ this.lockedUntil = Date.now() + this.limits.lockoutSeconds * 1000;
47
+ }
48
+ throw new Error('Wrong passphrase.');
49
+ }
50
+ this.failures = 0;
51
+ const t = crypto.randomBytes(32).toString('hex');
52
+ this.tokens.set(t, Date.now() + this.limits.sessionHours * 3600_000);
53
+ return t;
54
+ }
55
+ valid(t) {
56
+ const exp = this.tokens.get(t);
57
+ if (exp && exp > Date.now())
58
+ return true;
59
+ this.tokens.delete(t);
60
+ return false;
61
+ }
62
+ logout(t) { this.tokens.delete(t); }
63
+ }
64
+ export function startControl(link, port, agentKey, carriedSessions = []) {
65
+ const sessions = new Sessions(link.settings.limits, carriedSessions);
66
+ const passkeys = new Passkeys(port);
67
+ // Anything that lets something reach the agent or leave the computer, or loosens a rule, needs a passkey touch for that exact action.
68
+ const protectedActions = {
69
+ approve: (b) => link.approve(str(b.id, 'id')),
70
+ unpair: (b) => { link.unpair(str(b.peer, 'peer')); return { ok: true }; },
71
+ rule: (b) => { link.setRule(str(b.peer, 'peer'), rk(b.key), rule(b.rule)); return { ok: true }; },
72
+ access: (b) => link.setAccess(str(b.peer, 'peer'), b.reach === 'computer' ? 'computer' : 'folders', b.tools === 'all' ? 'all' : 'files'),
73
+ share: (b) => link.shareFolder(str(b.peer, 'peer'), str(b.folder, 'folder'), b.remove === true),
74
+ 'decision-shared': (b) => link.setDecisionShared(str(b.id, 'id'), b.shared === true),
75
+ wake: (b) => ({ wake: link.setWake(wakeFrom(b)) }),
76
+ 'file-limit': (b) => link.setFileLimit(Number(b.mb)),
77
+ 'add-passkey': (_b, session) => passkeys.registerOptions(session, true),
78
+ update: (b) => link.installUpdate(str(b.version, 'version')),
79
+ };
80
+ const agent = {
81
+ 'GET /agent/status': () => link.status(),
82
+ 'POST /agent/invite': () => link.createInvite(),
83
+ 'POST /agent/join': (b) => link.join(str(b.code, 'code')),
84
+ 'POST /agent/ask': (b) => link.ask(str(b.peer, 'peer'), str(b.text, 'text'), typeof b.thread === 'string' && b.thread ? b.thread : undefined),
85
+ 'POST /agent/task-reply': (b) => link.replyToTask(str(b.id, 'id'), str(b.text, 'text')),
86
+ 'GET /agent/inbox': () => link.inbox(),
87
+ 'POST /agent/reply': (b) => link.reply(str(b.id, 'id'), str(b.text, 'text'), strs(b.sources)),
88
+ 'GET /agent/wait': (_b, u) => link.waitFor(u.searchParams.get('id') ?? '', Math.min(Number(u.searchParams.get('timeout') ?? 0) * 1000, 300_000)),
89
+ 'POST /agent/request-file': (b) => link.requestFile(str(b.peer, 'peer'), str(b.path, 'path'), typeof b.reason === 'string' ? b.reason : ''),
90
+ 'POST /agent/offer-file': (b) => link.offerFile(str(b.peer, 'peer'), str(b.path, 'path'), typeof b.note === 'string' ? b.note : ''),
91
+ 'GET /agent/received': () => link.received(),
92
+ 'POST /agent/task': (b) => link.requestTask(str(b.peer, 'peer'), str(b.text, 'text')),
93
+ 'POST /agent/task-update': (b) => link.updateTask(str(b.id, 'id'), str(b.note, 'note')),
94
+ 'POST /agent/task-complete': (b) => link.completeTask(str(b.id, 'id'), str(b.note, 'note'), strs(b.artifacts), b.failed === true, b.needsInput === true),
95
+ 'POST /agent/decision': (b) => link.recordDecision({ title: str(b.title, 'title'), decision: str(b.decision, 'decision'), why: str(b.why, 'why'),
96
+ alternatives: typeof b.alternatives === 'string' ? b.alternatives : undefined, sources: strs(b.sources), tags: strs(b.tags), shared: b.shared !== false }),
97
+ 'GET /agent/brain': (_b, u) => link.searchMine(u.searchParams.get('q') ?? ''),
98
+ 'POST /agent/ask-brain': (b) => link.askBrain(str(b.peer, 'peer'), str(b.query, 'query')),
99
+ 'GET /agent/brain-copy': (_b, u) => link.searchCopy(link.store.peerByName(u.searchParams.get('peer') ?? '')?.publicKey ?? '', u.searchParams.get('q') ?? ''),
100
+ };
101
+ const human = {
102
+ 'POST /human/check-update': async () => ({ update_available: await link.lookForUpdate() }),
103
+ 'GET /human/state': () => (link.lookForUpdateSoon(), { ...link.status(), wakeLast: link.waker.last, wakePreset: link.waker.preset, passkeys: passkeys.count, pending: link.pending(), notices: link.notices(), maxFileBytes: link.settings.maxFileBytes, fileLimitMaxMB: link.settings.limits.fileLimitMaxMB, wakeMinutes: link.waker.minutes(), wakeInternet: link.wakeInternet(), wakeCommands: link.wakeCommands(), decisionsList: link.brain.mine }),
104
+ 'GET /human/history': (_b, u) => link.history(u.searchParams.get('peer') ?? ''),
105
+ 'GET /human/agent-log': (_b, u) => ({ log: link.agentLog(u.searchParams.get('id') ?? '') }),
106
+ 'POST /human/deny': (b) => link.deny(str(b.id, 'id')),
107
+ 'POST /human/dismiss': (b) => link.dismiss(str(b.id, 'id')),
108
+ 'GET /human/decisions': () => link.brain.mine,
109
+ 'GET /human/audit': () => verifyAudit(),
110
+ 'POST /human/passkey/options': (_b, _u, session) => passkeys.registerOptions(session),
111
+ 'POST /human/passkey/register': async (b, _u, session) => ({ passkeys: await passkeys.register(session, str(b.id, 'id'), b.response) }),
112
+ 'POST /human/challenge': (b, _u, session) => {
113
+ const action = str(b.action, 'action');
114
+ if (!protectedActions[action])
115
+ throw new Error(`Unknown action "${action}".`);
116
+ return passkeys.challenge(session, action, (b.body ?? {}));
117
+ },
118
+ 'POST /human/confirm': async (b, _u, session) => {
119
+ const { action, body } = await passkeys.confirm(session, str(b.id, 'id'), b.response);
120
+ return protectedActions[action](body, session);
121
+ },
122
+ };
123
+ const server = http.createServer(async (req, res) => {
124
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
125
+ const send = (code, body, type = 'application/json') => {
126
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
127
+ res.end(type === 'application/json' ? JSON.stringify(body) : String(body));
128
+ };
129
+ // Browsers on other sites must not reach this API: no CORS, and the host must be local.
130
+ if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(req.headers.host ?? ''))
131
+ return send(403, { error: 'local only' });
132
+ // Passkeys only work on a named host, so the page always lives on localhost.
133
+ if (url.pathname === '/ui' && req.method === 'GET' && !(req.headers.host ?? '').startsWith('localhost')) {
134
+ res.writeHead(302, { location: `http://localhost:${port}/ui` });
135
+ return res.end();
136
+ }
137
+ if (url.pathname === '/ui' && req.method === 'GET') {
138
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY',
139
+ 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'" });
140
+ return res.end(approvalPage());
141
+ }
142
+ const route = `${req.method} ${url.pathname}`;
143
+ let session = '';
144
+ const isHuman = url.pathname.startsWith('/human/');
145
+ if (isHuman) {
146
+ // A custom header can't be sent cross-site without a preflight, which this server never allows.
147
+ if (req.headers['x-secure-human'] !== '1')
148
+ return send(403, { error: 'missing header' });
149
+ const token = String(req.headers['x-secure-session'] ?? cookie(req.headers.cookie, COOKIE));
150
+ if (route === 'POST /human/login') {
151
+ try {
152
+ const t = sessions.login(String((await readBody(req)).passphrase ?? ''));
153
+ res.setHeader('set-cookie', `${COOKIE}=${t}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${link.settings.limits.sessionHours * 3600}`);
154
+ return send(200, { token: t });
155
+ }
156
+ catch (e) {
157
+ return send(401, { error: e instanceof Error ? e.message : String(e) });
158
+ }
159
+ }
160
+ if (route === 'POST /human/logout') {
161
+ sessions.logout(token);
162
+ res.setHeader('set-cookie', `${COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`);
163
+ return send(200, { ok: true });
164
+ }
165
+ if (!sessions.valid(token))
166
+ return send(401, { error: 'signin' });
167
+ session = token;
168
+ }
169
+ const h = (isHuman ? human : agent)[route];
170
+ if (!h)
171
+ return send(404, { error: 'not found' });
172
+ if (!isHuman && !same(String(req.headers['x-secure-key'] ?? ''), agentKey))
173
+ return send(401, { error: 'wrong key' });
174
+ try {
175
+ const body = req.method === 'POST' ? await readBody(req) : {};
176
+ send(200, (await h(body, url, session)) ?? null);
177
+ }
178
+ catch (e) {
179
+ send(400, { error: e instanceof Error ? e.message : String(e) });
180
+ }
181
+ });
182
+ return new Promise((resolve, reject) => {
183
+ server.once('error', reject);
184
+ server.listen(port, '127.0.0.1', () => resolve({ server, sessions: () => sessions.carry() }));
185
+ });
186
+ }
187
+ function readBody(req) {
188
+ return new Promise((resolve, reject) => {
189
+ let size = 0;
190
+ const chunks = [];
191
+ req.on('data', (c) => { size += c.length; if (size > MAX_BODY) {
192
+ reject(new Error('Message too large.'));
193
+ req.destroy();
194
+ }
195
+ else
196
+ chunks.push(c); });
197
+ req.on('end', () => { try {
198
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
199
+ }
200
+ catch {
201
+ reject(new Error('Body must be JSON.'));
202
+ } });
203
+ req.on('error', reject);
204
+ });
205
+ }
206
+ function cookie(header, name) {
207
+ for (const part of (header ?? '').split(';')) {
208
+ const [k, ...v] = part.trim().split('=');
209
+ if (k === name)
210
+ return v.join('=');
211
+ }
212
+ return '';
213
+ }
214
+ function same(a, b) {
215
+ const x = Buffer.from(a), y = Buffer.from(b);
216
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
217
+ }
218
+ function wakeFrom(b) {
219
+ if (b.preset === 'off')
220
+ return null;
221
+ const cwd = str(b.cwd, 'cwd');
222
+ if (!fs.statSync(cwd).isDirectory())
223
+ throw new Error('cwd must be a folder.');
224
+ const minutes = b.timeoutMinutes === undefined || b.timeoutMinutes === '' ? undefined : Number(b.timeoutMinutes);
225
+ if (minutes !== undefined && !(Number.isInteger(minutes) && minutes >= 1 && minutes <= 10080))
226
+ throw new Error('The time limit must be a whole number of minutes, from 1 to 10080 (a week).');
227
+ if (b.preset === 'claude' || b.preset === 'codex')
228
+ return presetWake(b.preset, cwd, b.allowCommands === true, minutes, b.internet === true);
229
+ if (b.preset !== 'custom')
230
+ throw new Error('preset must be claude, codex, custom or off.');
231
+ const line = str(b.line, 'command');
232
+ const [command, ...args] = splitLine(line);
233
+ return { preset: 'custom', line, question: { command, args }, task: { command, args }, cwd, timeoutMinutes: minutes ?? 0, timeoutSet: minutes !== undefined };
234
+ }
235
+ /** Splits a typed command line into words; "double quotes" keep spaces together. */
236
+ function splitLine(line) {
237
+ const words = [...line.matchAll(/"([^"]*)"|(\S+)/g)].map((m) => m[1] ?? m[2]);
238
+ if (!words.length)
239
+ throw new Error('Type the command that starts your agent.');
240
+ return words;
241
+ }
242
+ function str(v, name) {
243
+ if (typeof v !== 'string' || !v.trim())
244
+ throw new Error(`"${name}" is required.`);
245
+ return v;
246
+ }
247
+ function strs(v) { return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []; }
248
+ function rk(v) { if (RULE_KEYS.includes(v))
249
+ return v; throw new Error(`"key" must be one of ${RULE_KEYS.join(', ')}.`); }
250
+ function rule(v) { if (v === 'auto' || v === 'ask' || v === 'never')
251
+ return v; throw new Error('"rule" must be auto, ask or never.'); }