secure-agents 0.0.0-stage → 0.7.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.
- package/AGENTS.md +63 -0
- package/README.md +206 -2
- package/dist/agents.js +31 -0
- package/dist/brain.js +70 -0
- package/dist/cli.js +310 -0
- package/dist/config.js +54 -0
- package/dist/control.js +251 -0
- package/dist/guard.js +98 -0
- package/dist/identity.js +38 -0
- package/dist/invite.js +52 -0
- package/dist/link.js +964 -0
- package/dist/mcp.js +190 -0
- package/dist/net.js +103 -0
- package/dist/notify.js +61 -0
- package/dist/passkey.js +73 -0
- package/dist/prompt.js +46 -0
- package/dist/protocol.js +47 -0
- package/dist/relay.js +222 -0
- package/dist/scan.js +41 -0
- package/dist/setup.js +35 -0
- package/dist/store.js +125 -0
- package/dist/ui.js +349 -0
- package/dist/update.js +68 -0
- package/dist/vault.js +87 -0
- package/dist/waker.js +280 -0
- package/package.json +45 -4
package/dist/link.js
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
// The daemon's rules: who may pair, what reaches the agent, and what needs a human's yes before it leaves this computer.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Brain, search } from './brain.js';
|
|
8
|
+
import { file, readJson, writeJson } from './config.js';
|
|
9
|
+
import { checkFile, checkOwnFile, hashFile } from './guard.js';
|
|
10
|
+
import { loadOrCreateIdentity, safetyNumber, sign, verify } from './identity.js';
|
|
11
|
+
import { decodeInvite, encodeInvite, newSecret, sameSecret } from './invite.js';
|
|
12
|
+
import { Net } from './net.js';
|
|
13
|
+
import { clean, desktopNotify, Notifier } from './notify.js';
|
|
14
|
+
import { mb } from './guard.js';
|
|
15
|
+
import { blockSecrets, secretsIn, steeringIn, warningsIn } from './scan.js';
|
|
16
|
+
import { CHUNK_BYTES } from './protocol.js';
|
|
17
|
+
import { audit, newId, Store } from './store.js';
|
|
18
|
+
import { describeWake, readLog, readWake, saveWake, Waker } from './waker.js';
|
|
19
|
+
import { checkUpdate, installVersion, RUNNING_VERSION, runningAs } from './update.js';
|
|
20
|
+
const INVITE_HOURS = 24;
|
|
21
|
+
const LABEL = { pair: 'pairing', ask: 'question', reply: 'answer', file_request: 'file request', file: 'file', task: 'task', task_update: 'task result', brain_query: 'brain search', brain_result: 'brain results', task_reply: 'answer to task question', notice: 'notice' };
|
|
22
|
+
const receiptPayload = (task, state, note, artifacts, at) => JSON.stringify({ task, state, note, artifacts, at });
|
|
23
|
+
const isMac = process.platform === 'darwin', isWin = process.platform === 'win32';
|
|
24
|
+
export class Link extends EventEmitter {
|
|
25
|
+
settings;
|
|
26
|
+
store;
|
|
27
|
+
brain = new Brain();
|
|
28
|
+
keys = loadOrCreateIdentity();
|
|
29
|
+
me = this.keys.publicKey.toString('hex');
|
|
30
|
+
net;
|
|
31
|
+
waker;
|
|
32
|
+
incoming = new Map();
|
|
33
|
+
streaming = new Set();
|
|
34
|
+
rate = new Map();
|
|
35
|
+
sweeper = null;
|
|
36
|
+
updater = null;
|
|
37
|
+
update = null;
|
|
38
|
+
lastUpdateCheck = 0;
|
|
39
|
+
notifier;
|
|
40
|
+
alerted = new Set();
|
|
41
|
+
constructor(settings) {
|
|
42
|
+
super();
|
|
43
|
+
this.settings = settings;
|
|
44
|
+
this.store = new Store(settings.limits.maxItems);
|
|
45
|
+
this.waker = new Waker(readWake(), (it) => this.access(it.peer), (it) => this.reported(it), (it, slice, session) => {
|
|
46
|
+
const cur = this.store.item(it.id);
|
|
47
|
+
if (cur)
|
|
48
|
+
this.store.setStatus(cur.id, cur.status, undefined, { agentLog: [...(cur.data?.agentLog ?? []), slice] });
|
|
49
|
+
const t = this.store.item(it.kind === 'task' ? it.id : String(it.data?.thread ?? ''));
|
|
50
|
+
if (session && t?.kind === 'task')
|
|
51
|
+
this.store.setStatus(t.id, t.status, undefined, { session });
|
|
52
|
+
}, () => this.settings.limits.wakeMinutes);
|
|
53
|
+
this.net = new Net(this.keys, (pk) => this.mayConnect(pk), { relay: settings.relay, mustDial: (pk) => this.store.joins.some((j) => j.publicKey === pk && j.approved) });
|
|
54
|
+
this.net.on('message', (peer, msg) => this.onMessage(peer, msg));
|
|
55
|
+
this.net.on('online', (peer) => this.onOnline(peer));
|
|
56
|
+
this.net.on('offline', (peer) => this.dropIncoming(peer));
|
|
57
|
+
this.notifier = new Notifier(() => this.settings.notify, settings.limits.notifyGroupSeconds, settings.port, (id) => this.store.item(id)?.status === 'awaiting_human');
|
|
58
|
+
}
|
|
59
|
+
async start() {
|
|
60
|
+
this.expireWaiting();
|
|
61
|
+
this.sweeper = setInterval(() => this.expireWaiting(), 60_000);
|
|
62
|
+
this.sweeper.unref();
|
|
63
|
+
void this.lookForUpdate();
|
|
64
|
+
this.updater = setInterval(() => void this.lookForUpdate(), this.settings.limits.updateCheckHours * 3600_000);
|
|
65
|
+
this.updater.unref();
|
|
66
|
+
await this.net.start([...this.store.peers.map((p) => p.publicKey), ...this.store.joins.map((j) => j.publicKey)]);
|
|
67
|
+
}
|
|
68
|
+
stop() { if (this.sweeper)
|
|
69
|
+
clearInterval(this.sweeper); if (this.updater)
|
|
70
|
+
clearInterval(this.updater); return this.net.stop(); }
|
|
71
|
+
/** Only the pinned app copy updates itself; a source folder updates with git pull. */
|
|
72
|
+
async lookForUpdate() {
|
|
73
|
+
if (runningAs() !== 'app')
|
|
74
|
+
return null;
|
|
75
|
+
this.lastUpdateCheck = Date.now();
|
|
76
|
+
this.update = await checkUpdate();
|
|
77
|
+
if (this.update)
|
|
78
|
+
audit('update_available', { ...this.update });
|
|
79
|
+
return this.update;
|
|
80
|
+
}
|
|
81
|
+
/** The page asks on every refresh; this looks on npm at most once per updateCheckOnOpenMinutes. */
|
|
82
|
+
lookForUpdateSoon() {
|
|
83
|
+
if (Date.now() - this.lastUpdateCheck > this.settings.limits.updateCheckOnOpenMinutes * 60_000)
|
|
84
|
+
void this.lookForUpdate();
|
|
85
|
+
}
|
|
86
|
+
/** Called only through a passkey-approved action on the page. */
|
|
87
|
+
installUpdate(version) {
|
|
88
|
+
if (runningAs() !== 'app')
|
|
89
|
+
throw new Error('This Secure runs from a source folder: update it with git pull and npm run build.');
|
|
90
|
+
if (!this.update || this.update.latest !== version)
|
|
91
|
+
throw new Error('That version is not the update on offer. Reload the page.');
|
|
92
|
+
installVersion(version);
|
|
93
|
+
audit('update_installed', { from: this.update.current, to: version });
|
|
94
|
+
this.update = null;
|
|
95
|
+
setTimeout(() => this.emit('restart'), 300);
|
|
96
|
+
return { installed: version, restarting: true, next: 'Secure is restarting with the new version in the same window. This page reconnects by itself.' };
|
|
97
|
+
}
|
|
98
|
+
/** Declines anything that waited for the person longer than the limit, so old requests don't pile up. */
|
|
99
|
+
expireWaiting() {
|
|
100
|
+
const cutoff = Date.now() - this.settings.limits.pendingHours * 3600_000;
|
|
101
|
+
for (const it of this.pending().filter((i) => Date.parse(i.createdAt) < cutoff)) {
|
|
102
|
+
this.deny(it.id);
|
|
103
|
+
this.store.setStatus(it.id, 'denied', `Expired: nobody approved it within ${this.settings.limits.pendingHours} hours.`);
|
|
104
|
+
audit('expired', { id: it.id, kind: it.kind, direction: it.direction, peer: it.peer });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
status() {
|
|
108
|
+
return {
|
|
109
|
+
name: this.settings.name,
|
|
110
|
+
publicKey: this.me,
|
|
111
|
+
version: RUNNING_VERSION,
|
|
112
|
+
update_available: this.update ? { ...this.update, how: 'Your user installs it on the Secure page (it needs their passkey), then restarts the Secure window.' } : null,
|
|
113
|
+
relay: this.settings.relay ? `${this.settings.relay.slice(0, 16)}… (peers can't see this computer's IP)` : 'none: direct connection, paired peers can see this computer\'s IP',
|
|
114
|
+
wake: describeWake(readWake()),
|
|
115
|
+
peers: this.store.peers.map((p) => ({ name: p.name, publicKey: p.publicKey, online: this.net.isOnline(p.publicKey), permissions: p.permissions, shared: p.shared, reach: p.reach, tools: p.tools, fileLimit: this.fileLimitWith(p), pairedAt: p.pairedAt, brainCopy: this.brain.copyOf(p.publicKey)?.syncedAt ?? null })),
|
|
116
|
+
pendingApprovals: this.pending().length,
|
|
117
|
+
decisions: this.brain.mine.length,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// ── pairing ──────────────────────────────────────────────────────────────
|
|
121
|
+
createInvite() {
|
|
122
|
+
this.store.pruneInvites();
|
|
123
|
+
const secret = newSecret();
|
|
124
|
+
const expiresAt = new Date(Date.now() + INVITE_HOURS * 3600_000).toISOString();
|
|
125
|
+
this.store.invites.push({ secret, createdAt: new Date().toISOString(), expiresAt });
|
|
126
|
+
this.store.save();
|
|
127
|
+
audit('invite_created', { expiresAt });
|
|
128
|
+
return { code: encodeInvite(this.me, secret, this.settings.relay), expiresAt };
|
|
129
|
+
}
|
|
130
|
+
join(code) {
|
|
131
|
+
const { publicKey, secret, relay } = decodeInvite(code);
|
|
132
|
+
if (relay !== this.settings.relay)
|
|
133
|
+
throw new Error(relay
|
|
134
|
+
? `This invite connects through relay ${relay}. Ask your user to run "secure use-relay ${relay}" and restart the daemon, then join again.`
|
|
135
|
+
: 'This invite is for a direct connection, but this computer uses a relay. Both computers must use the same relay (or both none).');
|
|
136
|
+
if (publicKey === this.me)
|
|
137
|
+
throw new Error('That invite was created on this computer. Use it on the other one.');
|
|
138
|
+
if (this.store.peer(publicKey))
|
|
139
|
+
throw new Error('You are already paired with that agent.');
|
|
140
|
+
this.store.joins = this.store.joins.filter((j) => j.publicKey !== publicKey);
|
|
141
|
+
this.store.joins.push({ publicKey, secret, createdAt: new Date().toISOString(), approved: false });
|
|
142
|
+
const number = safetyNumber(this.me, publicKey);
|
|
143
|
+
const it = this.store.addItem({ id: newId(), kind: 'pair', direction: 'out', peer: publicKey, peerName: 'invite from another computer', text: `Safety number ${number}`, status: 'awaiting_human' });
|
|
144
|
+
audit('join_started', { id: it.id, peer: publicKey });
|
|
145
|
+
this.changed(it);
|
|
146
|
+
return { peer: publicKey, safetyNumber: number, status: 'Waiting for your user to approve joining on the Secure page, then for the other person to approve.' };
|
|
147
|
+
}
|
|
148
|
+
unpair(name) {
|
|
149
|
+
const p = this.requirePeer(name);
|
|
150
|
+
this.store.removePeer(p.publicKey);
|
|
151
|
+
this.brain.dropCopy(p.publicKey);
|
|
152
|
+
this.net.forget(p.publicKey);
|
|
153
|
+
audit('unpaired', { peer: p.publicKey, name: p.name });
|
|
154
|
+
}
|
|
155
|
+
setWake(w) {
|
|
156
|
+
saveWake(w);
|
|
157
|
+
this.waker.set(w);
|
|
158
|
+
audit('wake_changed', { preset: w?.preset ?? null });
|
|
159
|
+
return describeWake(w);
|
|
160
|
+
}
|
|
161
|
+
setRule(name, key, rule) {
|
|
162
|
+
const p = this.requirePeer(name);
|
|
163
|
+
if (key === 'tasks' && rule === 'auto')
|
|
164
|
+
throw new Error('Tasks can change files, so they always wait for your review. Choose "Ask me" or "Never".');
|
|
165
|
+
p.permissions[key] = rule;
|
|
166
|
+
this.store.save();
|
|
167
|
+
audit('rule_changed', { peer: p.publicKey, key, rule });
|
|
168
|
+
if (key === 'brain')
|
|
169
|
+
this.syncBrain(p.publicKey);
|
|
170
|
+
}
|
|
171
|
+
/** How far my agent may reach for this peer: the shared folders or the whole computer, and file reading only or all its tools. */
|
|
172
|
+
setAccess(name, reach, tools) {
|
|
173
|
+
const p = this.requirePeer(name);
|
|
174
|
+
p.reach = reach;
|
|
175
|
+
p.tools = tools;
|
|
176
|
+
this.store.save();
|
|
177
|
+
audit('access_changed', { peer: p.publicKey, reach, tools });
|
|
178
|
+
return { reach, tools };
|
|
179
|
+
}
|
|
180
|
+
/** What my agent may read and use when it works for this peer. */
|
|
181
|
+
access(peer) {
|
|
182
|
+
const p = this.store.peer(peer);
|
|
183
|
+
return { reach: p?.reach ?? 'folders', tools: p?.tools ?? 'files', folders: p?.shared ?? [] };
|
|
184
|
+
}
|
|
185
|
+
/** Where files for this peer may come from. */
|
|
186
|
+
fileRoots(p) { return p.reach === 'computer' ? [...new Set([os.homedir(), ...p.shared])] : p.shared; }
|
|
187
|
+
/** Everything sent and received, newest first, optionally for one peer. */
|
|
188
|
+
history(peer = '') {
|
|
189
|
+
const p = peer ? this.store.peerByName(peer) : undefined;
|
|
190
|
+
return this.store.items.filter((i) => !p || i.peer === p.publicKey).slice(-500).reverse()
|
|
191
|
+
.map((i) => ({ ...i, data: i.data ? { ...i.data, heldAt: undefined, agentLog: undefined } : i.data, hasLog: Array.isArray(i.data?.agentLog) && i.data.agentLog.length > 0 }));
|
|
192
|
+
}
|
|
193
|
+
/** What my agent printed while it checked or worked on this item. */
|
|
194
|
+
agentLog(id) {
|
|
195
|
+
const it = this.store.item(id);
|
|
196
|
+
const slices = it?.data?.agentLog ?? [];
|
|
197
|
+
if (!slices.length)
|
|
198
|
+
return 'Your agent was not woken for this.';
|
|
199
|
+
try {
|
|
200
|
+
return slices.map((s) => readLog(s, this.settings.limits.historyLogBytes)).join('\n');
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return 'The log for this was removed.';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
shareFolder(name, folder, remove = false) {
|
|
207
|
+
const p = this.requirePeer(name);
|
|
208
|
+
const abs = path.resolve(folder);
|
|
209
|
+
if (!remove && !fs.statSync(abs).isDirectory())
|
|
210
|
+
throw new Error('That is not a folder.');
|
|
211
|
+
p.shared = remove ? p.shared.filter((f) => f !== abs) : [...new Set([...p.shared, abs])];
|
|
212
|
+
this.store.save();
|
|
213
|
+
audit(remove ? 'folder_unshared' : 'folder_shared', { peer: p.publicKey, folder: abs });
|
|
214
|
+
return p.shared;
|
|
215
|
+
}
|
|
216
|
+
// ── questions and replies ────────────────────────────────────────────────
|
|
217
|
+
/** A question; with thread, a follow-up about a task that computer did for me, answered in the same agent session. */
|
|
218
|
+
ask(peerName, text, thread) {
|
|
219
|
+
if (thread) {
|
|
220
|
+
const t = this.store.item(thread);
|
|
221
|
+
if (!t || t.kind !== 'task' || t.direction !== 'out')
|
|
222
|
+
throw new Error('about_task must be the id of a task you sent.');
|
|
223
|
+
}
|
|
224
|
+
return this.outgoing(peerName, 'ask', text, thread ? { data: { thread } } : {});
|
|
225
|
+
}
|
|
226
|
+
/** Answers the question a task's agent asked while working; its session carries on with the answer. */
|
|
227
|
+
replyToTask(id, text) {
|
|
228
|
+
const t = this.store.item(id);
|
|
229
|
+
if (!t || t.kind !== 'task' || t.direction !== 'out')
|
|
230
|
+
throw new Error('There is no task you sent with that id.');
|
|
231
|
+
if (t.data?.state !== 'waiting')
|
|
232
|
+
throw new Error('That task is not waiting for an answer.');
|
|
233
|
+
const r = this.outgoing(t.peer, 'task_reply', text, { inReplyTo: t.id });
|
|
234
|
+
this.store.setStatus(t.id, t.status, undefined, { state: 'running' });
|
|
235
|
+
return r;
|
|
236
|
+
}
|
|
237
|
+
/** Whether my agent answered or reported on this, after it was woken for it. */
|
|
238
|
+
reported(it) {
|
|
239
|
+
if (it.kind !== 'task_reply')
|
|
240
|
+
return this.store.items.some((i) => i.direction === 'out' && i.inReplyTo === it.id);
|
|
241
|
+
return this.store.items.some((i) => i.direction === 'out' && i.kind === 'task_update' && i.inReplyTo === it.inReplyTo && i.createdAt >= it.createdAt);
|
|
242
|
+
}
|
|
243
|
+
// ── file size limit ──────────────────────────────────────────────────────
|
|
244
|
+
/** The largest file this computer sends or accepts; paired computers are told, since a transfer works only up to the smaller limit. */
|
|
245
|
+
setFileLimit(mbs) {
|
|
246
|
+
if (!Number.isInteger(mbs) || mbs < 1 || mbs > this.settings.limits.fileLimitMaxMB)
|
|
247
|
+
throw new Error(`Choose a whole number of MB from 1 to ${this.settings.limits.fileLimitMaxMB}.`);
|
|
248
|
+
const bytes = mbs * 1048576;
|
|
249
|
+
writeJson('settings.json', { ...readJson('settings.json', {}), maxFileBytes: bytes });
|
|
250
|
+
this.settings.maxFileBytes = bytes;
|
|
251
|
+
audit('file_limit_changed', { maxFileBytes: bytes });
|
|
252
|
+
for (const p of this.store.peers)
|
|
253
|
+
if (this.net.isOnline(p.publicKey))
|
|
254
|
+
this.net.send(p.publicKey, { type: 'limits', maxFileBytes: bytes });
|
|
255
|
+
return { maxFileBytes: bytes };
|
|
256
|
+
}
|
|
257
|
+
/** What can move between me and this computer: the smaller of the two limits. */
|
|
258
|
+
fileLimitWith(p) {
|
|
259
|
+
const theirs = p.theirMaxFileBytes ?? null, mine = this.settings.maxFileBytes;
|
|
260
|
+
return { mine, theirs, between: theirs === null ? mine : Math.min(mine, theirs), setBy: theirs !== null && theirs < mine ? p.name : 'this computer' };
|
|
261
|
+
}
|
|
262
|
+
notices() { return this.store.items.filter((i) => i.kind === 'notice' && !i.data?.dismissed).slice(-20); }
|
|
263
|
+
dismiss(id) {
|
|
264
|
+
const it = this.store.item(id);
|
|
265
|
+
if (!it || it.kind !== 'notice')
|
|
266
|
+
throw new Error('No notice with that id.');
|
|
267
|
+
return this.store.setStatus(id, it.status, undefined, { dismissed: true });
|
|
268
|
+
}
|
|
269
|
+
wakeInternet() { return this.waker.current?.internet === true; }
|
|
270
|
+
wakeCommands() { return !!this.waker.current && (this.waker.current.allowCommands ?? false); }
|
|
271
|
+
reply(id, text, sources = []) {
|
|
272
|
+
const q = this.requireInbox(id, 'ask');
|
|
273
|
+
const r = this.outgoing(q.peer, 'reply', text, { inReplyTo: q.id, data: { sources } });
|
|
274
|
+
this.store.setStatus(q.id, 'answered');
|
|
275
|
+
return r;
|
|
276
|
+
}
|
|
277
|
+
/** Questions, approved tasks and received files my agent can act on now; questions carry related decisions from my brain. */
|
|
278
|
+
inbox() {
|
|
279
|
+
const open = this.store.items.filter((i) => i.direction === 'in' && i.status === 'delivered' && (i.kind === 'ask' || i.kind === 'task' || i.kind === 'task_reply' || i.kind === 'file'));
|
|
280
|
+
return open.map((i) => i.kind === 'ask' ? { ...i, related: search(this.brain.mine, i.text, 3) } : i);
|
|
281
|
+
}
|
|
282
|
+
/** Waits until something I sent is resolved: an answer, a file, a finished task, brain results, or a refusal. */
|
|
283
|
+
waitFor(id, timeoutMs) {
|
|
284
|
+
const mine = this.store.item(id);
|
|
285
|
+
if (!mine || mine.direction !== 'out')
|
|
286
|
+
return Promise.reject(new Error('Nothing sent from here with that id.'));
|
|
287
|
+
const held = () => this.store.items.some((i) => i.direction === 'in' && i.inReplyTo === id && i.status === 'awaiting_human');
|
|
288
|
+
const result = () => {
|
|
289
|
+
const r = this.store.items.filter((i) => i.direction === 'in' && i.inReplyTo === id && i.status !== 'awaiting_human' && i.status !== 'denied');
|
|
290
|
+
if (mine.kind === 'task')
|
|
291
|
+
return r.filter((i) => i.kind === 'task_update' && ['done', 'failed', ...(this.store.item(id)?.data?.state === 'waiting' ? ['waiting'] : [])].includes(String(i.data?.state))).at(-1) ?? null;
|
|
292
|
+
if (mine.kind === 'file_request')
|
|
293
|
+
return r.find((i) => i.kind === 'file' && i.status === 'delivered') ?? null;
|
|
294
|
+
return r.at(-1) ?? null;
|
|
295
|
+
};
|
|
296
|
+
const settled = () => { const m = this.store.item(id); return result() ?? (m.status === 'failed' || m.status === 'denied' ? null : undefined); };
|
|
297
|
+
const now = settled();
|
|
298
|
+
if (now !== undefined || timeoutMs <= 0)
|
|
299
|
+
return Promise.resolve({ item: this.store.item(id), result: now ?? null, held: held() });
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
const done = () => { clearTimeout(t); this.off('item', on); resolve({ item: this.store.item(id), result: settled() ?? null, held: held() }); };
|
|
302
|
+
const t = setTimeout(done, timeoutMs);
|
|
303
|
+
const heldThere = () => this.store.item(id)?.data?.remote === 'held' && mine.data?.remote !== 'held';
|
|
304
|
+
const on = () => { if (settled() !== undefined || heldThere())
|
|
305
|
+
done(); };
|
|
306
|
+
this.on('item', on);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// ── files ────────────────────────────────────────────────────────────────
|
|
310
|
+
requestFile(peerName, filePath, reason) {
|
|
311
|
+
return this.outgoing(peerName, 'file_request', `${filePath}${reason ? ` (${reason})` : ''}`, { data: { path: filePath, reason } });
|
|
312
|
+
}
|
|
313
|
+
offerFile(peerName, filePath, note) {
|
|
314
|
+
const p = this.requirePeer(peerName);
|
|
315
|
+
if (p.permissions.files === 'never')
|
|
316
|
+
throw new Error(`Sending files to ${p.name} is turned off on this computer.`);
|
|
317
|
+
const c = checkOwnFile(filePath, this.settings.maxFileBytes);
|
|
318
|
+
if (!c.ok)
|
|
319
|
+
throw new Error(c.reason);
|
|
320
|
+
const lim = this.fileLimitWith(p);
|
|
321
|
+
if (c.bytes > lim.between)
|
|
322
|
+
throw new Error(`That file is ${mb(c.bytes)}; files between you and ${p.name} can be at most ${mb(lim.between)} (set by ${lim.setBy}). Ask the person there to raise "Largest file" in Secure's Settings.`);
|
|
323
|
+
return this.addOutgoingFile(p, c.abs, c.bytes, c.sha256, note, undefined, c.flags);
|
|
324
|
+
}
|
|
325
|
+
received() { return this.store.items.filter((i) => i.kind === 'file' && i.direction === 'in' && i.status !== 'awaiting_human'); }
|
|
326
|
+
/** A secret-looking file always waits for the person, even when files are Automatic. */
|
|
327
|
+
addOutgoingFile(p, abs, bytes, sha256, note, requestId, warnings = []) {
|
|
328
|
+
const it = this.store.addItem({ id: newId(), kind: 'file', direction: 'out', peer: p.publicKey, peerName: p.name, text: `${abs} (${(bytes / 1024).toFixed(1)} KB)${note ? ` · ${note}` : ''}`,
|
|
329
|
+
status: p.permissions.files === 'auto' && !warnings.length ? 'queued' : 'awaiting_human', data: { abs, name: path.basename(abs), bytes, sha256, note, requestId, ...(warnings.length ? { warnings, sensitive: true } : {}) } });
|
|
330
|
+
audit('file_prepared', { id: it.id, peer: p.publicKey, file: abs, bytes, sha256, needsApproval: it.status === 'awaiting_human' });
|
|
331
|
+
this.flush(p.publicKey);
|
|
332
|
+
this.changed(it);
|
|
333
|
+
return it;
|
|
334
|
+
}
|
|
335
|
+
async streamFile(it) {
|
|
336
|
+
if (this.streaming.has(it.id))
|
|
337
|
+
return;
|
|
338
|
+
this.streaming.add(it.id);
|
|
339
|
+
const d = it.data;
|
|
340
|
+
try {
|
|
341
|
+
if (hashFile(d.abs) !== d.sha256)
|
|
342
|
+
throw new Error('The file changed after it was approved; approve it again.');
|
|
343
|
+
const ok = await this.net.sendDrained(it.peer, { type: 'file_start', id: it.id, inReplyTo: d.requestId, name: d.name, bytes: d.bytes, sha256: d.sha256, note: d.note ?? '' });
|
|
344
|
+
if (!ok)
|
|
345
|
+
throw new Error('offline');
|
|
346
|
+
const fd = fs.openSync(d.abs, 'r');
|
|
347
|
+
try {
|
|
348
|
+
const buf = Buffer.alloc(CHUNK_BYTES);
|
|
349
|
+
for (let seq = 0, n; (n = fs.readSync(fd, buf, 0, CHUNK_BYTES, null)) > 0; seq++) {
|
|
350
|
+
if (!(await this.net.sendDrained(it.peer, { type: 'file_chunk', id: it.id, seq, data: buf.subarray(0, n).toString('base64') })))
|
|
351
|
+
throw new Error('offline');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
fs.closeSync(fd);
|
|
356
|
+
}
|
|
357
|
+
await this.net.sendDrained(it.peer, { type: 'file_end', id: it.id });
|
|
358
|
+
this.store.setStatus(it.id, 'sent');
|
|
359
|
+
audit('file_sent', { id: it.id, peer: it.peer, file: d.abs, bytes: d.bytes, sha256: d.sha256 });
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
363
|
+
if (msg === 'offline')
|
|
364
|
+
this.store.setStatus(it.id, 'queued', 'Connection dropped; it resends when the peer is back.');
|
|
365
|
+
else {
|
|
366
|
+
this.store.setStatus(it.id, 'failed', msg);
|
|
367
|
+
if (d.requestId)
|
|
368
|
+
this.net.send(it.peer, { type: 'refused', inReplyTo: d.requestId, reason: msg });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
this.streaming.delete(it.id);
|
|
373
|
+
this.changed(this.store.item(it.id));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// ── tasks ────────────────────────────────────────────────────────────────
|
|
377
|
+
requestTask(peerName, text) { return this.outgoing(peerName, 'task', text); }
|
|
378
|
+
updateTask(id, note) {
|
|
379
|
+
const t = this.requireInbox(id, 'task');
|
|
380
|
+
return this.outgoing(t.peer, 'task_update', note, { inReplyTo: t.id, data: { state: 'running' } });
|
|
381
|
+
}
|
|
382
|
+
completeTask(id, note, artifactPaths, failed = false, needsInput = false) {
|
|
383
|
+
const t = this.requireInbox(id, 'task');
|
|
384
|
+
const replies = () => { for (const r of this.store.items.filter((i) => i.kind === 'task_reply' && i.inReplyTo === t.id && i.status === 'delivered'))
|
|
385
|
+
this.store.setStatus(r.id, 'answered'); };
|
|
386
|
+
if (needsInput) {
|
|
387
|
+
const q = this.outgoing(t.peer, 'task_update', note, { inReplyTo: t.id, data: { state: 'waiting' } });
|
|
388
|
+
this.store.setStatus(t.id, 'delivered', undefined, { state: 'waiting' });
|
|
389
|
+
replies();
|
|
390
|
+
audit('task_waiting', { id: t.id });
|
|
391
|
+
return q;
|
|
392
|
+
}
|
|
393
|
+
replies();
|
|
394
|
+
const artifacts = artifactPaths.map((p) => {
|
|
395
|
+
const abs = path.resolve(p);
|
|
396
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
|
|
397
|
+
throw new Error(`Listed result file does not exist: ${abs}. A task is only done when its files exist.`);
|
|
398
|
+
return { path: abs, bytes: fs.statSync(abs).size, sha256: hashFile(abs) };
|
|
399
|
+
});
|
|
400
|
+
const state = failed ? 'failed' : 'done';
|
|
401
|
+
const at = new Date().toISOString();
|
|
402
|
+
const signature = sign(this.keys, receiptPayload(t.id, state, note, artifacts, at));
|
|
403
|
+
const u = this.outgoing(t.peer, 'task_update', note, { inReplyTo: t.id, data: { state, receipt: { artifacts, at, signature } } });
|
|
404
|
+
this.store.setStatus(t.id, 'answered');
|
|
405
|
+
audit('task_completed', { id: t.id, state, artifacts: artifacts.length });
|
|
406
|
+
return u;
|
|
407
|
+
}
|
|
408
|
+
// ── brain ────────────────────────────────────────────────────────────────
|
|
409
|
+
recordDecision(input) {
|
|
410
|
+
if (input.shared !== false)
|
|
411
|
+
blockSecrets(JSON.stringify(input), 'shared decision (save it with shared=false to keep it on this computer only)');
|
|
412
|
+
const d = this.brain.record(input);
|
|
413
|
+
audit('decision_recorded', { id: d.id, shared: d.shared });
|
|
414
|
+
if (d.shared)
|
|
415
|
+
this.syncAll();
|
|
416
|
+
return d;
|
|
417
|
+
}
|
|
418
|
+
setDecisionShared(id, shared) {
|
|
419
|
+
const d = this.brain.setShared(id, shared);
|
|
420
|
+
audit('decision_sharing', { id, shared });
|
|
421
|
+
this.syncAll();
|
|
422
|
+
return d;
|
|
423
|
+
}
|
|
424
|
+
searchMine(query) { return search(this.brain.mine, query, 10); }
|
|
425
|
+
/** Searches a peer's shared decisions: live when it's online, otherwise from the copy it last synced here. */
|
|
426
|
+
askBrain(peerName, query) {
|
|
427
|
+
const p = this.requirePeer(peerName);
|
|
428
|
+
if (!this.net.isOnline(p.publicKey))
|
|
429
|
+
return { item: null, offline: this.searchCopy(p.publicKey, query) };
|
|
430
|
+
return { item: this.outgoing(p.publicKey, 'brain_query', query), offline: null };
|
|
431
|
+
}
|
|
432
|
+
searchCopy(peer, query) {
|
|
433
|
+
const c = this.brain.copyOf(peer);
|
|
434
|
+
return c ? { syncedAt: c.syncedAt, results: search(c.decisions, query, 10) } : null;
|
|
435
|
+
}
|
|
436
|
+
syncBrain(peer) {
|
|
437
|
+
const p = this.store.peer(peer);
|
|
438
|
+
if (!p || !this.net.isOnline(peer))
|
|
439
|
+
return;
|
|
440
|
+
const decisions = p.permissions.brain === 'auto' ? this.brain.syncPayload() : [];
|
|
441
|
+
this.net.send(peer, { type: 'brain_sync', decisions, at: new Date().toISOString() });
|
|
442
|
+
}
|
|
443
|
+
syncAll() { for (const p of this.store.peers)
|
|
444
|
+
this.syncBrain(p.publicKey); }
|
|
445
|
+
answerBrain(peer, queryId, query) {
|
|
446
|
+
const results = search(this.brain.shared(), query, 10);
|
|
447
|
+
this.net.send(peer, { type: 'brain_result', id: newId(), inReplyTo: queryId, results });
|
|
448
|
+
audit('brain_answered', { peer, queryId, results: results.length });
|
|
449
|
+
}
|
|
450
|
+
// ── human approvals (never exposed to the agent) ─────────────────────────
|
|
451
|
+
pending() { return this.store.items.filter((i) => i.status === 'awaiting_human'); }
|
|
452
|
+
approve(id) {
|
|
453
|
+
const it = this.requirePending(id);
|
|
454
|
+
audit('approved', { id, kind: it.kind, direction: it.direction, peer: it.peer });
|
|
455
|
+
if (it.kind === 'pair' && it.direction === 'out') {
|
|
456
|
+
const j = this.store.joins.find((x) => x.publicKey === it.peer);
|
|
457
|
+
if (j)
|
|
458
|
+
j.approved = true;
|
|
459
|
+
this.store.setStatus(id, 'sent');
|
|
460
|
+
if (this.net.isOnline(it.peer))
|
|
461
|
+
this.sendPairRequest(it.peer);
|
|
462
|
+
else
|
|
463
|
+
this.net.dial(it.peer);
|
|
464
|
+
}
|
|
465
|
+
else if (it.kind === 'pair') {
|
|
466
|
+
this.store.addPeer(it.peer, it.peerName);
|
|
467
|
+
this.store.setStatus(id, 'sent');
|
|
468
|
+
this.net.send(it.peer, { type: 'pair_accepted', name: this.settings.name });
|
|
469
|
+
this.net.send(it.peer, { type: 'limits', maxFileBytes: this.settings.maxFileBytes });
|
|
470
|
+
this.net.dial(it.peer);
|
|
471
|
+
}
|
|
472
|
+
else if (it.direction === 'in' && it.kind === 'brain_query') {
|
|
473
|
+
this.store.setStatus(id, 'answered');
|
|
474
|
+
this.answerBrain(it.peer, it.id, it.text);
|
|
475
|
+
}
|
|
476
|
+
else if (it.direction === 'in') {
|
|
477
|
+
this.deliverIn(it);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
this.store.setStatus(id, 'queued');
|
|
481
|
+
this.flush(it.peer);
|
|
482
|
+
}
|
|
483
|
+
this.notice(it, 'approved');
|
|
484
|
+
const out = this.store.item(id);
|
|
485
|
+
this.changed(out);
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
deliverIn(it) {
|
|
489
|
+
const t = this.store.item(String(it.kind === 'task_reply' ? it.inReplyTo : it.data?.thread ?? ''));
|
|
490
|
+
this.store.setStatus(it.id, 'delivered', undefined, t?.kind === 'task' && t.data?.session ? { resume: t.data.session } : undefined);
|
|
491
|
+
if (it.kind === 'task_reply' && t)
|
|
492
|
+
this.store.setStatus(t.id, t.status, undefined, { state: 'running' });
|
|
493
|
+
it = this.store.item(it.id);
|
|
494
|
+
if (it.kind === 'file')
|
|
495
|
+
this.releaseFile(it);
|
|
496
|
+
if (it.kind === 'task')
|
|
497
|
+
this.net.send(it.peer, { type: 'task_update', id: newId(), inReplyTo: it.id, state: 'accepted', note: 'Approved; the agent will start it.' });
|
|
498
|
+
if (it.kind === 'task' || it.kind === 'ask' || it.kind === 'task_reply')
|
|
499
|
+
this.waker.ring(it);
|
|
500
|
+
}
|
|
501
|
+
/** Automatic questions: my own agent first checks, in any language, whether it asks for a change or a secret. Those wait for the person. */
|
|
502
|
+
async precheckThenDeliver(it) {
|
|
503
|
+
const v = await this.waker.precheck(it, this.settings.limits.precheckSeconds);
|
|
504
|
+
const cur = this.store.item(it.id);
|
|
505
|
+
if (!cur || cur.status !== 'awaiting_human')
|
|
506
|
+
return;
|
|
507
|
+
const warnings = !v ? ['could not be checked by your agent, so it waits for you']
|
|
508
|
+
: [...(v.changes ? [`asks to change something${v.reason ? ` (${v.reason})` : ''}`] : []), ...(v.secrets ? [`asks for secrets${v.reason && !v.changes ? ` (${v.reason})` : ''}`] : [])];
|
|
509
|
+
audit('precheck', { id: it.id, changes: v?.changes ?? null, secrets: v?.secrets ?? null, reason: v?.reason });
|
|
510
|
+
if (!warnings.length) {
|
|
511
|
+
this.store.setStatus(it.id, 'awaiting_human', undefined, { checking: undefined, checked: v?.reason || 'no change or secret asked' });
|
|
512
|
+
return this.autoAccept(this.store.item(it.id));
|
|
513
|
+
}
|
|
514
|
+
this.store.setStatus(it.id, 'awaiting_human', undefined, { checking: undefined, warnings, sensitive: true });
|
|
515
|
+
this.changed(this.store.item(it.id));
|
|
516
|
+
}
|
|
517
|
+
/** The person set this kind to "Automatic" and nothing in it was flagged. */
|
|
518
|
+
autoAccept(it) {
|
|
519
|
+
audit('auto_approved', { id: it.id, kind: it.kind, peer: it.peer });
|
|
520
|
+
this.deliverIn(it);
|
|
521
|
+
this.changed(this.store.item(it.id));
|
|
522
|
+
}
|
|
523
|
+
deny(id) {
|
|
524
|
+
const it = this.requirePending(id);
|
|
525
|
+
audit('denied', { id, kind: it.kind, direction: it.direction, peer: it.peer });
|
|
526
|
+
this.store.setStatus(id, 'denied');
|
|
527
|
+
if (it.kind === 'pair' && it.direction === 'out') {
|
|
528
|
+
this.store.joins = this.store.joins.filter((j) => j.publicKey !== it.peer);
|
|
529
|
+
this.store.save();
|
|
530
|
+
this.changed(it);
|
|
531
|
+
return it;
|
|
532
|
+
}
|
|
533
|
+
if (it.kind === 'file' && it.direction === 'in')
|
|
534
|
+
fs.rmSync(String(it.data?.heldAt ?? ''), { force: true });
|
|
535
|
+
const requestId = it.data?.requestId;
|
|
536
|
+
if (it.kind === 'pair')
|
|
537
|
+
this.net.send(it.peer, { type: 'pair_denied' });
|
|
538
|
+
else if (it.direction === 'in' && (it.kind === 'reply' || it.kind === 'task_update' || it.kind === 'brain_result' || it.kind === 'file')) { /* their answer stays here, unread */ }
|
|
539
|
+
else if (it.direction === 'in')
|
|
540
|
+
this.net.send(it.peer, { type: 'refused', inReplyTo: it.id, reason: 'The person on that computer declined it.' });
|
|
541
|
+
else if (requestId)
|
|
542
|
+
this.net.send(it.peer, { type: 'refused', inReplyTo: requestId, reason: 'The person on that computer declined to send the file.' });
|
|
543
|
+
const out = this.store.item(id);
|
|
544
|
+
this.changed(out);
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
// ── network ──────────────────────────────────────────────────────────────
|
|
548
|
+
mayConnect(pk) {
|
|
549
|
+
this.store.pruneInvites();
|
|
550
|
+
return !!this.store.peer(pk) || this.store.joins.some((j) => j.publicKey === pk) || this.store.invites.length > 0;
|
|
551
|
+
}
|
|
552
|
+
onOnline(peer) {
|
|
553
|
+
if (this.store.joins.some((j) => j.publicKey === peer))
|
|
554
|
+
this.sendPairRequest(peer);
|
|
555
|
+
this.flush(peer);
|
|
556
|
+
this.syncBrain(peer);
|
|
557
|
+
this.net.send(peer, { type: 'limits', maxFileBytes: this.settings.maxFileBytes });
|
|
558
|
+
for (const it of this.pending().filter((i) => i.peer === peer && !i.data?.checking))
|
|
559
|
+
this.notice(it, 'held');
|
|
560
|
+
}
|
|
561
|
+
/** The other computer's request that this item makes wait for my person: their question, task or search, or my answer to it. */
|
|
562
|
+
requestOf(it) {
|
|
563
|
+
if (it.direction === 'in')
|
|
564
|
+
return ['ask', 'task', 'task_reply', 'brain_query'].includes(it.kind) ? it.id : undefined;
|
|
565
|
+
if (it.kind === 'reply' || it.kind === 'task_update')
|
|
566
|
+
return it.inReplyTo;
|
|
567
|
+
return it.kind === 'file' ? it.data?.requestId : undefined;
|
|
568
|
+
}
|
|
569
|
+
/** Tells the asking computer where its request stands here, so its agent can tell its person. */
|
|
570
|
+
notice(it, state) {
|
|
571
|
+
const r = this.requestOf(it);
|
|
572
|
+
if (r && this.net.isOnline(it.peer))
|
|
573
|
+
this.net.send(it.peer, { type: 'status', inReplyTo: r, state });
|
|
574
|
+
}
|
|
575
|
+
sendPairRequest(peer) {
|
|
576
|
+
const j = this.store.joins.find((x) => x.publicKey === peer);
|
|
577
|
+
if (j?.approved)
|
|
578
|
+
this.net.send(peer, { type: 'pair_request', secret: j.secret, name: this.settings.name });
|
|
579
|
+
}
|
|
580
|
+
/** Any text my agent sends goes through my "send" rule; anything that looks like a secret always waits for my review. */
|
|
581
|
+
outgoing(peerName, kind, text, extra = {}) {
|
|
582
|
+
const p = this.requirePeer(peerName);
|
|
583
|
+
if (p.permissions.send === 'never')
|
|
584
|
+
throw new Error(`Sending to ${p.name} is turned off on this computer.`);
|
|
585
|
+
const secrets = secretsIn(`${text}\n${JSON.stringify(extra.data ?? {})}`);
|
|
586
|
+
const data = secrets.length ? { ...extra.data, warnings: [`contains what looks like a secret (${secrets.join(', ')})`], sensitive: true } : extra.data;
|
|
587
|
+
const it = this.store.addItem({ id: newId(), kind, direction: 'out', peer: p.publicKey, peerName: p.name, text, inReplyTo: extra.inReplyTo, data, status: p.permissions.send === 'auto' && !secrets.length ? 'queued' : 'awaiting_human' });
|
|
588
|
+
audit(`${kind}_created`, { id: it.id, peer: p.publicKey, bytes: text.length, needsApproval: it.status === 'awaiting_human' });
|
|
589
|
+
this.flush(p.publicKey);
|
|
590
|
+
this.changed(it);
|
|
591
|
+
return it;
|
|
592
|
+
}
|
|
593
|
+
wire(it) {
|
|
594
|
+
const d = (it.data ?? {});
|
|
595
|
+
switch (it.kind) {
|
|
596
|
+
case 'ask': return { type: 'ask', id: it.id, text: it.text, ...(d.thread ? { thread: d.thread } : {}) };
|
|
597
|
+
case 'task_reply': return { type: 'task_reply', id: it.id, inReplyTo: it.inReplyTo, text: it.text };
|
|
598
|
+
case 'reply': return { type: 'reply', id: it.id, inReplyTo: it.inReplyTo, text: it.text, sources: d.sources ?? [] };
|
|
599
|
+
case 'file_request': return { type: 'file_request', id: it.id, path: d.path, reason: d.reason ?? '' };
|
|
600
|
+
case 'task': return { type: 'task', id: it.id, text: it.text };
|
|
601
|
+
case 'task_update': return { type: 'task_update', id: it.id, inReplyTo: it.inReplyTo, state: d.state, note: it.text, receipt: d.receipt };
|
|
602
|
+
case 'brain_query': return { type: 'brain_query', id: it.id, query: it.text };
|
|
603
|
+
default: return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/** Sends everything approved that was waiting for this peer to come online. */
|
|
607
|
+
flush(peer) {
|
|
608
|
+
if (!this.net.isOnline(peer))
|
|
609
|
+
return;
|
|
610
|
+
for (const it of this.store.items.filter((i) => i.peer === peer && i.direction === 'out' && i.status === 'queued')) {
|
|
611
|
+
if (it.kind === 'file') {
|
|
612
|
+
void this.streamFile(it);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const msg = this.wire(it);
|
|
616
|
+
if (msg && this.net.send(peer, msg)) {
|
|
617
|
+
this.store.setStatus(it.id, 'sent');
|
|
618
|
+
audit('sent', { id: it.id, kind: it.kind, peer, bytes: it.text.length });
|
|
619
|
+
this.changed(it);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
onMessage(peer, msg) {
|
|
624
|
+
const known = this.store.peer(peer);
|
|
625
|
+
if (msg.type === 'pair_request' || msg.type === 'pair_accepted' || msg.type === 'pair_denied')
|
|
626
|
+
return this.onPairing(peer, msg, known);
|
|
627
|
+
if (!known)
|
|
628
|
+
return;
|
|
629
|
+
if (msg.type !== 'file_chunk' && !this.withinRate(peer)) {
|
|
630
|
+
audit('rate_limited', { peer, type: msg.type });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const exists = (id) => !!this.store.item(id);
|
|
634
|
+
const full = () => this.store.pendingFrom(peer) >= this.settings.limits.maxPendingPerPeer;
|
|
635
|
+
const hold = (text) => { const w = steeringIn(text); return w.length ? { warnings: w } : undefined; };
|
|
636
|
+
const refuse = (inReplyTo, reason) => { this.net.send(peer, { type: "refused", inReplyTo, reason: reason.slice(0, 500) }); };
|
|
637
|
+
const mineOut = (id, kind) => { const m = this.store.item(id); return m && m.direction === 'out' && m.peer === peer && (!kind || m.kind === kind) ? m : undefined; };
|
|
638
|
+
switch (msg.type) {
|
|
639
|
+
case 'ask': {
|
|
640
|
+
if (exists(msg.id))
|
|
641
|
+
return;
|
|
642
|
+
if (known.permissions.ask === 'never')
|
|
643
|
+
return refuse(msg.id, 'Questions from you are turned off on that computer.');
|
|
644
|
+
const warnings = warningsIn(msg.text);
|
|
645
|
+
if (full())
|
|
646
|
+
return refuse(msg.id, 'Too many of your requests are waiting on that computer; try later.');
|
|
647
|
+
const auto = known.permissions.ask === 'auto' && !warnings.length;
|
|
648
|
+
const checking = auto && this.waker.preset !== null && this.waker.preset !== 'custom';
|
|
649
|
+
const t = msg.thread ? this.store.item(msg.thread) : undefined;
|
|
650
|
+
const thread = t && t.kind === 'task' && t.direction === 'in' && t.peer === peer ? { thread: t.id } : {};
|
|
651
|
+
const it = this.store.addItem({ id: msg.id, kind: 'ask', direction: 'in', peer, peerName: known.name, text: msg.text, status: 'awaiting_human', data: { ...thread, ...(warnings.length ? { warnings, sensitive: true } : checking ? { checking: true } : {}) } });
|
|
652
|
+
audit('ask_received', { id: it.id, peer, bytes: msg.text.length, warnings: warnings.length ? warnings : undefined });
|
|
653
|
+
if (checking) {
|
|
654
|
+
this.changed(it);
|
|
655
|
+
return void this.precheckThenDeliver(it);
|
|
656
|
+
}
|
|
657
|
+
if (auto)
|
|
658
|
+
return this.autoAccept(it);
|
|
659
|
+
return this.changed(it);
|
|
660
|
+
}
|
|
661
|
+
case 'reply': {
|
|
662
|
+
const mine = mineOut(msg.inReplyTo);
|
|
663
|
+
if (!mine || exists(msg.id))
|
|
664
|
+
return;
|
|
665
|
+
const flagged = hold(`${msg.text}\n${(msg.sources ?? []).join('\n')}`);
|
|
666
|
+
const it = this.store.addItem({ id: msg.id, kind: 'reply', direction: 'in', peer, peerName: known.name, text: msg.text, inReplyTo: msg.inReplyTo, status: flagged ? 'awaiting_human' : 'delivered', data: { sources: msg.sources ?? [], ...flagged } });
|
|
667
|
+
this.store.setStatus(mine.id, 'answered');
|
|
668
|
+
audit('reply_received', { id: it.id, inReplyTo: msg.inReplyTo, peer, bytes: msg.text.length, heldForYou: !!flagged, warnings: flagged?.warnings });
|
|
669
|
+
return this.changed(it);
|
|
670
|
+
}
|
|
671
|
+
case 'status': {
|
|
672
|
+
const mine = mineOut(msg.inReplyTo);
|
|
673
|
+
if (!mine || ['answered', 'failed', 'denied'].includes(mine.status))
|
|
674
|
+
return;
|
|
675
|
+
this.store.setStatus(mine.id, mine.status, undefined, { remote: msg.state, remoteAt: new Date().toISOString() });
|
|
676
|
+
return this.changed(this.store.item(mine.id));
|
|
677
|
+
}
|
|
678
|
+
case 'refused': {
|
|
679
|
+
const mine = mineOut(msg.inReplyTo);
|
|
680
|
+
if (!mine)
|
|
681
|
+
return;
|
|
682
|
+
this.store.setStatus(mine.id, 'failed', msg.reason);
|
|
683
|
+
return this.changed(this.store.item(mine.id));
|
|
684
|
+
}
|
|
685
|
+
case 'file_request': {
|
|
686
|
+
if (exists(msg.id))
|
|
687
|
+
return;
|
|
688
|
+
const req = this.store.addItem({ id: msg.id, kind: 'file_request', direction: 'in', peer, peerName: known.name, text: `${msg.path}${msg.reason ? ` (${msg.reason})` : ''}`, status: 'answered', data: { path: msg.path, reason: msg.reason } });
|
|
689
|
+
if (known.permissions.files === 'never') {
|
|
690
|
+
this.store.setStatus(req.id, 'failed', 'files off');
|
|
691
|
+
return refuse(msg.id, 'Sending files to you is turned off on that computer.');
|
|
692
|
+
}
|
|
693
|
+
const c = checkFile(msg.path, this.fileRoots(known), this.settings.maxFileBytes, known.reach === 'computer');
|
|
694
|
+
audit('file_requested', { id: msg.id, peer, path: msg.path, allowed: c.ok, reason: c.reason, warnings: c.flags.length ? c.flags : undefined });
|
|
695
|
+
if (!c.ok) {
|
|
696
|
+
this.store.setStatus(req.id, 'failed', c.reason);
|
|
697
|
+
return refuse(msg.id, c.reason);
|
|
698
|
+
}
|
|
699
|
+
if (known.theirMaxFileBytes && c.bytes > known.theirMaxFileBytes) {
|
|
700
|
+
const why = `The file is ${mb(c.bytes)}; your computer accepts files up to ${mb(known.theirMaxFileBytes)}. Raise "Largest file" in your Secure Settings, then ask again.`;
|
|
701
|
+
this.store.setStatus(req.id, 'failed', why);
|
|
702
|
+
return refuse(msg.id, why);
|
|
703
|
+
}
|
|
704
|
+
this.addOutgoingFile(known, c.abs, c.bytes, c.sha256, msg.reason, msg.id, c.flags);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
case 'file_start': {
|
|
708
|
+
if (exists(msg.id))
|
|
709
|
+
return;
|
|
710
|
+
if (msg.bytes > this.settings.maxFileBytes)
|
|
711
|
+
return refuse(msg.id, `The file is bigger than that computer accepts (${Math.round(this.settings.maxFileBytes / 1048576)} MB).`);
|
|
712
|
+
if (!msg.inReplyTo && full())
|
|
713
|
+
return;
|
|
714
|
+
if (msg.inReplyTo && !mineOut(msg.inReplyTo, 'file_request'))
|
|
715
|
+
return;
|
|
716
|
+
const dir = path.join(file('received'), known.name.replace(/[^a-z0-9-]/gi, '_'));
|
|
717
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
718
|
+
const disk = fs.statfsSync(dir);
|
|
719
|
+
if (disk.bavail * disk.bsize - msg.bytes < this.settings.limits.minFreeDiskMB * 1048576) {
|
|
720
|
+
audit('file_refused_disk', { id: msg.id, peer, bytes: msg.bytes });
|
|
721
|
+
return refuse(msg.id, 'That computer does not have enough free disk space for this file.');
|
|
722
|
+
}
|
|
723
|
+
const dest = uniquePath(dir, path.basename(msg.name).replace(/[^\w.\- ]/g, '_') || 'file');
|
|
724
|
+
const tmp = `${dest}.part`;
|
|
725
|
+
const it = this.store.addItem({ id: msg.id, kind: 'file', direction: 'in', peer, peerName: known.name, text: `${path.basename(dest)} (${(msg.bytes / 1024).toFixed(1)} KB)${msg.note ? ` · ${msg.note}` : ''}`, inReplyTo: msg.inReplyTo, status: 'queued', note: 'receiving', data: { name: msg.name, bytes: msg.bytes, sha256: msg.sha256, ...hold(msg.note) } });
|
|
726
|
+
this.incoming.set(msg.id, { fd: fs.openSync(tmp, 'w', 0o600), tmp, dest, item: it.id, bytes: 0, expected: msg.bytes, sha256: msg.sha256 });
|
|
727
|
+
return this.changed(it);
|
|
728
|
+
}
|
|
729
|
+
case 'file_chunk': {
|
|
730
|
+
const f = this.incoming.get(msg.id);
|
|
731
|
+
if (!f || this.store.item(f.item)?.peer !== peer)
|
|
732
|
+
return;
|
|
733
|
+
const data = Buffer.from(msg.data, 'base64');
|
|
734
|
+
f.bytes += data.length;
|
|
735
|
+
if (f.bytes > f.expected)
|
|
736
|
+
return this.failIncoming(msg.id, 'The file was bigger than announced.');
|
|
737
|
+
fs.writeSync(f.fd, data);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
case 'file_end': {
|
|
741
|
+
const f = this.incoming.get(msg.id);
|
|
742
|
+
if (!f || this.store.item(f.item)?.peer !== peer)
|
|
743
|
+
return;
|
|
744
|
+
fs.closeSync(f.fd);
|
|
745
|
+
this.incoming.delete(msg.id);
|
|
746
|
+
if (f.bytes !== f.expected || hashFile(f.tmp) !== f.sha256) {
|
|
747
|
+
fs.rmSync(f.tmp, { force: true });
|
|
748
|
+
this.store.setStatus(f.item, 'failed', 'The file arrived damaged (checksum mismatch), so it was discarded.');
|
|
749
|
+
return this.changed(this.store.item(f.item));
|
|
750
|
+
}
|
|
751
|
+
const asked = !!this.store.item(f.item)?.inReplyTo;
|
|
752
|
+
if (asked) {
|
|
753
|
+
this.releaseFile(this.store.item(f.item), f.tmp, f.dest);
|
|
754
|
+
this.store.setStatus(f.item, 'delivered', 'verified');
|
|
755
|
+
}
|
|
756
|
+
else
|
|
757
|
+
this.store.setStatus(f.item, 'awaiting_human', 'verified; your agent sees it after you accept it', { heldAt: f.tmp, dest: f.dest, verified: true });
|
|
758
|
+
const it = this.store.item(f.item);
|
|
759
|
+
if (it.inReplyTo)
|
|
760
|
+
this.store.setStatus(it.inReplyTo, 'answered');
|
|
761
|
+
audit('file_received', { id: it.id, peer, savedTo: f.dest, bytes: f.bytes, sha256: f.sha256, heldForYou: !asked });
|
|
762
|
+
return this.changed(it);
|
|
763
|
+
}
|
|
764
|
+
case 'task': {
|
|
765
|
+
if (exists(msg.id))
|
|
766
|
+
return;
|
|
767
|
+
if (known.permissions.tasks === 'never')
|
|
768
|
+
return refuse(msg.id, 'Tasks from you are turned off on that computer.');
|
|
769
|
+
if (full())
|
|
770
|
+
return refuse(msg.id, 'Too many of your requests are waiting on that computer; try later.');
|
|
771
|
+
// Tasks can change things, so they always wait for the person's review and passkey.
|
|
772
|
+
const warnings = warningsIn(msg.text);
|
|
773
|
+
const it = this.store.addItem({ id: msg.id, kind: 'task', direction: 'in', peer, peerName: known.name, text: msg.text, status: 'awaiting_human', data: warnings.length ? { warnings, sensitive: true } : undefined });
|
|
774
|
+
audit('task_received', { id: it.id, peer, bytes: msg.text.length, warnings: warnings.length ? warnings : undefined });
|
|
775
|
+
return this.changed(it);
|
|
776
|
+
}
|
|
777
|
+
case 'task_update': {
|
|
778
|
+
const mine = mineOut(msg.inReplyTo, 'task');
|
|
779
|
+
if (!mine || exists(msg.id))
|
|
780
|
+
return;
|
|
781
|
+
const verified = msg.receipt ? verify(peer, receiptPayload(msg.inReplyTo, msg.state, msg.note, msg.receipt.artifacts, msg.receipt.at), msg.receipt.signature) : null;
|
|
782
|
+
const flagged = hold(msg.note);
|
|
783
|
+
const it = this.store.addItem({ id: msg.id, kind: 'task_update', direction: 'in', peer, peerName: known.name, text: msg.note, inReplyTo: msg.inReplyTo, status: flagged ? 'awaiting_human' : 'delivered', data: { state: msg.state, receipt: msg.receipt, receiptVerified: verified, ...flagged } });
|
|
784
|
+
this.store.setStatus(mine.id, msg.state === 'done' || msg.state === 'failed' ? 'answered' : 'sent', undefined, { state: msg.state });
|
|
785
|
+
audit('task_update_received', { id: it.id, task: msg.inReplyTo, state: msg.state, receiptVerified: verified });
|
|
786
|
+
return this.changed(it);
|
|
787
|
+
}
|
|
788
|
+
case 'task_reply': {
|
|
789
|
+
if (exists(msg.id))
|
|
790
|
+
return;
|
|
791
|
+
const t = this.store.item(msg.inReplyTo);
|
|
792
|
+
if (!t || t.kind !== 'task' || t.direction !== 'in' || t.peer !== peer || t.data?.state !== 'waiting')
|
|
793
|
+
return refuse(msg.id, 'That task is not waiting for an answer on that computer.');
|
|
794
|
+
if (known.permissions.tasks === 'never')
|
|
795
|
+
return refuse(msg.id, 'Tasks from you are turned off on that computer.');
|
|
796
|
+
// Part of a task the person already approved: it goes on unless it tries to take over the agent.
|
|
797
|
+
const warnings = steeringIn(msg.text);
|
|
798
|
+
const it = this.store.addItem({ id: msg.id, kind: 'task_reply', direction: 'in', peer, peerName: known.name, text: msg.text, inReplyTo: t.id, status: 'awaiting_human', data: { thread: t.id, ...(warnings.length ? { warnings, sensitive: true } : {}) } });
|
|
799
|
+
audit('task_reply_received', { id: it.id, task: t.id, peer, bytes: msg.text.length, warnings: warnings.length ? warnings : undefined });
|
|
800
|
+
if (warnings.length)
|
|
801
|
+
return this.changed(it);
|
|
802
|
+
return this.autoAccept(it);
|
|
803
|
+
}
|
|
804
|
+
case 'limits': {
|
|
805
|
+
const before = known.theirMaxFileBytes;
|
|
806
|
+
if (before === msg.maxFileBytes)
|
|
807
|
+
return;
|
|
808
|
+
known.theirMaxFileBytes = msg.maxFileBytes;
|
|
809
|
+
this.store.save();
|
|
810
|
+
audit('peer_file_limit', { peer, maxFileBytes: msg.maxFileBytes });
|
|
811
|
+
if (msg.maxFileBytes > this.settings.maxFileBytes && before !== undefined) {
|
|
812
|
+
const text = `${clean(known.name)} now accepts files up to ${mb(msg.maxFileBytes)}. Yours is ${mb(this.settings.maxFileBytes)}, so files between you stay at ${mb(this.settings.maxFileBytes)} until you raise "Largest file" in Settings.`;
|
|
813
|
+
const n = this.store.addItem({ id: newId(), kind: 'notice', direction: 'in', peer, peerName: known.name, text, status: 'delivered' });
|
|
814
|
+
if (this.settings.notify)
|
|
815
|
+
desktopNotify(text);
|
|
816
|
+
return this.changed(n);
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
case 'brain_query': {
|
|
821
|
+
if (exists(msg.id))
|
|
822
|
+
return;
|
|
823
|
+
if (known.permissions.brain === 'never')
|
|
824
|
+
return refuse(msg.id, 'That computer does not share its decisions with you.');
|
|
825
|
+
if (known.permissions.brain === 'ask') {
|
|
826
|
+
if (full())
|
|
827
|
+
return refuse(msg.id, 'Too many of your requests are waiting on that computer; try later.');
|
|
828
|
+
const it = this.store.addItem({ id: msg.id, kind: 'brain_query', direction: 'in', peer, peerName: known.name, text: msg.query, status: 'awaiting_human' });
|
|
829
|
+
return this.changed(it);
|
|
830
|
+
}
|
|
831
|
+
this.store.addItem({ id: msg.id, kind: 'brain_query', direction: 'in', peer, peerName: known.name, text: msg.query, status: 'answered' });
|
|
832
|
+
return this.answerBrain(peer, msg.id, msg.query);
|
|
833
|
+
}
|
|
834
|
+
case 'brain_result': {
|
|
835
|
+
const mine = mineOut(msg.inReplyTo, 'brain_query');
|
|
836
|
+
if (!mine || exists(msg.id))
|
|
837
|
+
return;
|
|
838
|
+
const flagged = hold(JSON.stringify(msg.results));
|
|
839
|
+
const it = this.store.addItem({ id: msg.id, kind: 'brain_result', direction: 'in', peer, peerName: known.name, text: `${msg.results.length} decision(s)`, inReplyTo: msg.inReplyTo, status: flagged ? 'awaiting_human' : 'delivered', data: { results: msg.results, ...flagged } });
|
|
840
|
+
this.store.setStatus(mine.id, 'answered');
|
|
841
|
+
return this.changed(it);
|
|
842
|
+
}
|
|
843
|
+
case 'brain_sync': {
|
|
844
|
+
const clean = msg.decisions.filter((d) => !steeringIn(JSON.stringify(d)).length);
|
|
845
|
+
this.brain.storeCopy(peer, known.name, clean);
|
|
846
|
+
audit('brain_copy_synced', { peer, decisions: clean.length, droppedAsInstructions: msg.decisions.length - clean.length });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
onPairing(peer, msg, known) {
|
|
852
|
+
if (msg.type === 'pair_request') {
|
|
853
|
+
if (known) {
|
|
854
|
+
this.net.send(peer, { type: 'pair_accepted', name: this.settings.name });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
this.store.pruneInvites();
|
|
858
|
+
const inv = this.store.invites.find((i) => sameSecret(i.secret, msg.secret));
|
|
859
|
+
if (!inv) {
|
|
860
|
+
audit('pair_rejected', { peer, reason: 'unknown or expired invite' });
|
|
861
|
+
this.net.send(peer, { type: 'pair_denied' });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
this.store.invites = this.store.invites.filter((i) => i !== inv);
|
|
865
|
+
const it = this.store.addItem({ id: newId(), kind: 'pair', direction: 'in', peer, peerName: msg.name, text: `Safety number ${safetyNumber(this.me, peer)}`, status: 'awaiting_human' });
|
|
866
|
+
audit('pair_requested', { id: it.id, peer, name: msg.name });
|
|
867
|
+
this.changed(it);
|
|
868
|
+
}
|
|
869
|
+
else if (msg.type === 'pair_accepted') {
|
|
870
|
+
if (!this.store.joins.some((j) => j.publicKey === peer) && !known)
|
|
871
|
+
return;
|
|
872
|
+
this.store.joins = this.store.joins.filter((j) => j.publicKey !== peer);
|
|
873
|
+
const p = this.store.addPeer(peer, msg.name);
|
|
874
|
+
audit('paired', { peer, name: p.name });
|
|
875
|
+
this.flush(peer);
|
|
876
|
+
this.syncBrain(peer);
|
|
877
|
+
this.net.send(peer, { type: 'limits', maxFileBytes: this.settings.maxFileBytes });
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
if (!this.store.joins.some((j) => j.publicKey === peer))
|
|
881
|
+
return;
|
|
882
|
+
this.store.joins = this.store.joins.filter((j) => j.publicKey !== peer);
|
|
883
|
+
this.store.save();
|
|
884
|
+
audit('pair_denied_by_peer', { peer });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/** Moves a received file into place: never executable, marked as downloaded from elsewhere so the OS warns before opening it. */
|
|
888
|
+
releaseFile(it, tmp = String(it.data?.heldAt ?? ''), dest = String(it.data?.dest ?? '')) {
|
|
889
|
+
fs.renameSync(tmp, dest);
|
|
890
|
+
fs.chmodSync(dest, 0o600);
|
|
891
|
+
if (isMac)
|
|
892
|
+
spawnSync('xattr', ['-w', 'com.apple.quarantine', `0081;${Math.floor(Date.now() / 1000).toString(16)};Secure;`, dest]);
|
|
893
|
+
if (isWin) {
|
|
894
|
+
try {
|
|
895
|
+
fs.writeFileSync(`${dest}:Zone.Identifier`, '[ZoneTransfer]\r\nZoneId=3\r\n');
|
|
896
|
+
}
|
|
897
|
+
catch { /* not NTFS */ }
|
|
898
|
+
}
|
|
899
|
+
this.store.setStatus(it.id, it.status, undefined, { savedTo: dest, verified: true, untrusted: true, heldAt: undefined });
|
|
900
|
+
}
|
|
901
|
+
withinRate(peer) {
|
|
902
|
+
const t = Date.now(), r = this.rate.get(peer);
|
|
903
|
+
if (!r || t - r.start > 60_000) {
|
|
904
|
+
this.rate.set(peer, { start: t, count: 1 });
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
return ++r.count <= this.settings.limits.messagesPerMinute;
|
|
908
|
+
}
|
|
909
|
+
failIncoming(id, reason) {
|
|
910
|
+
const f = this.incoming.get(id);
|
|
911
|
+
if (!f)
|
|
912
|
+
return;
|
|
913
|
+
try {
|
|
914
|
+
fs.closeSync(f.fd);
|
|
915
|
+
}
|
|
916
|
+
catch { /* already closed */ }
|
|
917
|
+
fs.rmSync(f.tmp, { force: true });
|
|
918
|
+
this.incoming.delete(id);
|
|
919
|
+
this.store.setStatus(f.item, 'failed', reason);
|
|
920
|
+
this.changed(this.store.item(f.item));
|
|
921
|
+
}
|
|
922
|
+
dropIncoming(peer) {
|
|
923
|
+
for (const [id, f] of this.incoming)
|
|
924
|
+
if (this.store.item(f.item)?.peer === peer)
|
|
925
|
+
this.failIncoming(id, 'The connection dropped during the transfer; ask for the file again.');
|
|
926
|
+
}
|
|
927
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
928
|
+
requirePeer(nameOrKey) {
|
|
929
|
+
const p = this.store.peerByName(nameOrKey);
|
|
930
|
+
if (!p)
|
|
931
|
+
throw new Error(`No paired agent called "${nameOrKey}". Paired: ${this.store.peers.map((x) => x.name).join(', ') || 'none yet'}.`);
|
|
932
|
+
return p;
|
|
933
|
+
}
|
|
934
|
+
requireInbox(id, kind) {
|
|
935
|
+
const it = this.store.item(id);
|
|
936
|
+
if (!it || it.direction !== 'in' || it.kind !== kind)
|
|
937
|
+
throw new Error(`There is no ${kind === 'ask' ? 'question' : kind} with that id in the inbox.`);
|
|
938
|
+
if (it.status !== 'delivered')
|
|
939
|
+
throw new Error(`That ${kind === 'ask' ? 'question' : kind} is ${it.status.replace('_', ' ')}; it can't be answered now.`);
|
|
940
|
+
return it;
|
|
941
|
+
}
|
|
942
|
+
requirePending(id) {
|
|
943
|
+
const it = this.store.item(id);
|
|
944
|
+
if (!it || it.status !== 'awaiting_human')
|
|
945
|
+
throw new Error('Nothing waiting for approval with that id.');
|
|
946
|
+
return it;
|
|
947
|
+
}
|
|
948
|
+
changed(it) {
|
|
949
|
+
if (it.status === 'awaiting_human' && !it.data?.checking && !this.alerted.has(it.id)) {
|
|
950
|
+
this.alerted.add(it.id);
|
|
951
|
+
this.notifier.add(it);
|
|
952
|
+
this.notice(it, 'held');
|
|
953
|
+
}
|
|
954
|
+
this.emit('item', it);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function uniquePath(dir, name) {
|
|
958
|
+
const ext = path.extname(name), base = path.basename(name, ext);
|
|
959
|
+
for (let i = 0;; i++) {
|
|
960
|
+
const p = path.join(dir, i ? `${base} (${i})${ext}` : name);
|
|
961
|
+
if (!fs.existsSync(p) && !fs.existsSync(`${p}.part`))
|
|
962
|
+
return p;
|
|
963
|
+
}
|
|
964
|
+
}
|