@hwip/mcp-server 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.
- package/LICENSE +190 -0
- package/NOTICE +10 -0
- package/README.md +413 -2
- package/dist/agent-target.d.ts +45 -0
- package/dist/agent-target.js +81 -0
- package/dist/api-client.d.ts +162 -0
- package/dist/api-client.js +517 -0
- package/dist/ask-context-attach.d.ts +67 -0
- package/dist/ask-context-attach.js +147 -0
- package/dist/ask-seal.d.ts +118 -0
- package/dist/ask-seal.js +194 -0
- package/dist/command-seal.d.ts +49 -0
- package/dist/command-seal.js +90 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +147 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.js +561 -0
- package/dist/crypto.d.ts +234 -0
- package/dist/crypto.js +606 -0
- package/dist/error-format.d.ts +7 -0
- package/dist/error-format.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +30 -0
- package/dist/key-changes.d.ts +50 -0
- package/dist/key-changes.js +163 -0
- package/dist/mime.d.ts +16 -0
- package/dist/mime.js +58 -0
- package/dist/peer-key.d.ts +36 -0
- package/dist/peer-key.js +35 -0
- package/dist/poll.d.ts +24 -0
- package/dist/poll.js +156 -0
- package/dist/push-audience.d.ts +35 -0
- package/dist/push-audience.js +64 -0
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +17 -0
- package/dist/remote-state.d.ts +38 -0
- package/dist/remote-state.js +81 -0
- package/dist/resources/channels.d.ts +4 -0
- package/dist/resources/channels.js +24 -0
- package/dist/resources/devices.d.ts +4 -0
- package/dist/resources/devices.js +24 -0
- package/dist/sanitize.d.ts +41 -0
- package/dist/sanitize.js +106 -0
- package/dist/self-register.d.ts +34 -0
- package/dist/self-register.js +51 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +63 -0
- package/dist/tools/agent-local.d.ts +17 -0
- package/dist/tools/agent-local.js +139 -0
- package/dist/tools/agent-send.d.ts +6 -0
- package/dist/tools/agent-send.js +89 -0
- package/dist/tools/ask.d.ts +9 -0
- package/dist/tools/ask.js +275 -0
- package/dist/tools/broadcast.d.ts +5 -0
- package/dist/tools/broadcast.js +42 -0
- package/dist/tools/clipboard.d.ts +5 -0
- package/dist/tools/clipboard.js +47 -0
- package/dist/tools/dismiss.d.ts +5 -0
- package/dist/tools/dismiss.js +49 -0
- package/dist/tools/file.d.ts +5 -0
- package/dist/tools/file.js +169 -0
- package/dist/tools/list.d.ts +4 -0
- package/dist/tools/list.js +44 -0
- package/dist/tools/lookup.d.ts +10 -0
- package/dist/tools/lookup.js +45 -0
- package/dist/tools/notify.d.ts +5 -0
- package/dist/tools/notify.js +118 -0
- package/dist/tools/rename.d.ts +5 -0
- package/dist/tools/rename.js +45 -0
- package/dist/trust-pins.d.ts +70 -0
- package/dist/trust-pins.js +269 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +2 -0
- package/dist/url-check.d.ts +11 -0
- package/dist/url-check.js +57 -0
- package/dist/ws-wait.d.ts +72 -0
- package/dist/ws-wait.js +200 -0
- package/package.json +70 -4
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pinning the account's devices as a sender sees them (twin of the cli's `src/trust-pins.ts` and `src/trust-lock.ts`).
|
|
4
|
+
*
|
|
5
|
+
* `hwip_notify`, `hwip_file`, `hwip_ask` and `hwip_agent_send` read `GET /devices` before they seal. Every device that
|
|
6
|
+
* lists a key is pinned the first time it is seen, and a pin whose device now lists another key is replaced — with one
|
|
7
|
+
* warning, because a key that changed is also what a relay swapping the list looks like, and a record (`key-changes.ts`)
|
|
8
|
+
* the next tool result shows, since this server's stderr reaches nobody. Nothing here decides who is
|
|
9
|
+
* sealed for: the list does. The pins are what the cli listener holds a request against and what a person compares
|
|
10
|
+
* with `hwip trust`; trust never stops a send.
|
|
11
|
+
*
|
|
12
|
+
* The file is shared with the cli, which also keeps the listener's sealed-command numbers in it (`commands`,
|
|
13
|
+
* `commandKeys`). Every writer re-reads it under `trusted-devices.lock` and rewrites only its own part: here that is
|
|
14
|
+
* `devices` (and, for a replaced pin, the old key's command number carried into `commandKeys`, as the cli does), with
|
|
15
|
+
* every other field kept as it was. `sealedOnce` is read past and not written back.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.observeDevices = exports.withTrustLock = exports.readTrustFile = exports.MAX_PINNED_DEVICES = exports.DEVICE_ID = exports.TRUST_PATH = void 0;
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_os_1 = require("node:os");
|
|
22
|
+
const node_path_1 = require("node:path");
|
|
23
|
+
const crypto_js_1 = require("./crypto.js");
|
|
24
|
+
const key_changes_js_1 = require("./key-changes.js");
|
|
25
|
+
/** The cli listener's state file. */
|
|
26
|
+
exports.TRUST_PATH = (0, node_path_1.join)((0, node_os_1.homedir)(), '.hwip', 'trusted-devices.json');
|
|
27
|
+
/** A device id as the app, the cli and the server mint them. */
|
|
28
|
+
exports.DEVICE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
|
+
/** Devices pinned at most: a list padded with made-up devices must not grow the file one rewrite at a time. */
|
|
30
|
+
exports.MAX_PINNED_DEVICES = 64;
|
|
31
|
+
const TRUST_VERSION = 1;
|
|
32
|
+
/** A lock older than this was left by a process that died. */
|
|
33
|
+
const STALE_MS = 10_000;
|
|
34
|
+
const WAIT_MS = 1_000;
|
|
35
|
+
const POLL_MS = 20;
|
|
36
|
+
const isCommandNumber = (n) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
|
|
37
|
+
/** Judged as the cli's `readTrustState` judges it, so the two never disagree about the same file. */
|
|
38
|
+
const parseTrust = (text) => {
|
|
39
|
+
const raw = JSON.parse(text);
|
|
40
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
41
|
+
return null;
|
|
42
|
+
if (!('version' in raw) || raw.version !== TRUST_VERSION)
|
|
43
|
+
return null;
|
|
44
|
+
if (!('devices' in raw) || typeof raw.devices !== 'object' || raw.devices === null)
|
|
45
|
+
return null;
|
|
46
|
+
for (const pin of Object.values(raw.devices)) {
|
|
47
|
+
const isPin = typeof pin === 'object' && pin !== null
|
|
48
|
+
&& 'publicKey' in pin && typeof pin.publicKey === 'string'
|
|
49
|
+
&& 'pinnedAt' in pin && typeof pin.pinnedAt === 'string';
|
|
50
|
+
if (!isPin)
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// Absent from files written before sealed commands; when there, as the cli reads it.
|
|
54
|
+
if ('commands' in raw) {
|
|
55
|
+
if (typeof raw.commands !== 'object' || raw.commands === null)
|
|
56
|
+
return null;
|
|
57
|
+
if (!Object.values(raw.commands).every(isCommandNumber))
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return raw;
|
|
61
|
+
};
|
|
62
|
+
/** The trust file: a missing one is empty, one that will not read or parse is `null`, never a guess. */
|
|
63
|
+
const readTrustFile = (path) => {
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return err.code === 'ENOENT' ? { version: TRUST_VERSION, devices: {} } : null; // err.code is how node names a missing file
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return parseTrust(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
exports.readTrustFile = readTrustFile;
|
|
79
|
+
const writeTrustFile = (path, file) => {
|
|
80
|
+
const { sealedOnce: _retired, ...kept } = file;
|
|
81
|
+
const temp = `${path}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
|
|
82
|
+
// Synced before the rename: a crash leaves the old file or the new one, not an empty one that reads as unusable.
|
|
83
|
+
const fd = (0, node_fs_1.openSync)(temp, 'wx', 0o600);
|
|
84
|
+
try {
|
|
85
|
+
try {
|
|
86
|
+
(0, node_fs_1.writeSync)(fd, `${JSON.stringify({ ...kept, version: TRUST_VERSION }, null, 2)}\n`);
|
|
87
|
+
(0, node_fs_1.fsyncSync)(fd);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
(0, node_fs_1.closeSync)(fd);
|
|
91
|
+
}
|
|
92
|
+
(0, node_fs_1.renameSync)(temp, path);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
(0, node_fs_1.rmSync)(temp, { force: true });
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const ageOf = (lock) => {
|
|
100
|
+
try {
|
|
101
|
+
return Date.now() - (0, node_fs_1.statSync)(lock).mtimeMs;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const sleepSync = (ms) => {
|
|
108
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Run `fn` holding `trusted-devices.lock` beside the trust file, created with `wx` as the cli creates it. `locked: false`:
|
|
112
|
+
* the lock was held past `waitMs` or cannot be made, and `fn` did not run.
|
|
113
|
+
*/
|
|
114
|
+
const withTrustLock = (trustPath, fn, { waitMs = WAIT_MS } = {}) => {
|
|
115
|
+
const lock = `${trustPath.replace(/\.json$/, '')}.lock`;
|
|
116
|
+
const deadline = Date.now() + waitMs;
|
|
117
|
+
let brokeStale = false;
|
|
118
|
+
try {
|
|
119
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(lock), { recursive: true, mode: 0o700 });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { locked: false };
|
|
123
|
+
}
|
|
124
|
+
for (;;) {
|
|
125
|
+
try {
|
|
126
|
+
const fd = (0, node_fs_1.openSync)(lock, 'wx', 0o600);
|
|
127
|
+
try {
|
|
128
|
+
(0, node_fs_1.writeSync)(fd, `${process.pid} ${Date.now()}\n`);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// An empty lock nobody owns would hold every writer off until it goes stale.
|
|
132
|
+
(0, node_fs_1.rmSync)(lock, { force: true });
|
|
133
|
+
return { locked: false };
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
(0, node_fs_1.closeSync)(fd);
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err.code !== 'EEXIST')
|
|
142
|
+
return { locked: false }; // err.code is how node names a lock that is there
|
|
143
|
+
// Looked at again right before the unlink: another waiter may have broken it and taken a fresh lock meanwhile.
|
|
144
|
+
const age = ageOf(lock);
|
|
145
|
+
if (!brokeStale && age !== null && age > STALE_MS && (ageOf(lock) ?? 0) > STALE_MS) {
|
|
146
|
+
brokeStale = true;
|
|
147
|
+
try {
|
|
148
|
+
(0, node_fs_1.unlinkSync)(lock);
|
|
149
|
+
}
|
|
150
|
+
catch { /* someone else broke it first */ }
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (Date.now() >= deadline)
|
|
154
|
+
return { locked: false };
|
|
155
|
+
sleepSync(POLL_MS);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return { locked: true, value: fn() };
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
(0, node_fs_1.rmSync)(lock, { force: true });
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
exports.withTrustLock = withTrustLock;
|
|
166
|
+
/** An id is read from the file's own entries: `__proto__` or `constructor` passes `DEVICE_ID` and would read an inherited member. */
|
|
167
|
+
const ownEntry = (record, id) => record !== undefined && Object.hasOwn(record, id) ? record[id] : undefined;
|
|
168
|
+
/** One key is one device: a key pinned for another id is the same device under a stamp the relay picked. */
|
|
169
|
+
const pinnedElsewhere = (file, deviceId, sender) => Object.entries(file.devices).some(([id, pin]) => id !== deviceId && (0, crypto_js_1.publicKeySender)(pin.publicKey) === sender);
|
|
170
|
+
/** The listener's number for a device moves to its old key before the pin goes: a new id for that key must not start from zero. */
|
|
171
|
+
const foldCommandNumber = (file, deviceId, oldKey) => {
|
|
172
|
+
const commands = file.commands;
|
|
173
|
+
const n = ownEntry(commands, deviceId);
|
|
174
|
+
const sender = (0, crypto_js_1.publicKeySender)(oldKey);
|
|
175
|
+
if (!isCommandNumber(n) || sender === null)
|
|
176
|
+
return;
|
|
177
|
+
const keys = typeof file.commandKeys === 'object' && file.commandKeys !== null && !Array.isArray(file.commandKeys)
|
|
178
|
+
? file.commandKeys
|
|
179
|
+
: {};
|
|
180
|
+
const held = keys[sender];
|
|
181
|
+
file.commandKeys = { ...keys, [sender]: isCommandNumber(held) ? Math.max(held, n) : n };
|
|
182
|
+
};
|
|
183
|
+
const apply = (file, devices, selfId, at) => {
|
|
184
|
+
const changes = { pinned: [], replaced: [], skipped: [] };
|
|
185
|
+
for (const { deviceId, publicKey } of devices) {
|
|
186
|
+
if (!publicKey || deviceId === selfId || !exports.DEVICE_ID.test(deviceId))
|
|
187
|
+
continue;
|
|
188
|
+
const sender = (0, crypto_js_1.publicKeySender)(publicKey);
|
|
189
|
+
if (sender === null)
|
|
190
|
+
continue;
|
|
191
|
+
const pin = ownEntry(file.devices, deviceId);
|
|
192
|
+
if (pin && (0, crypto_js_1.samePublicKey)(pin.publicKey, publicKey))
|
|
193
|
+
continue;
|
|
194
|
+
const tooMany = !pin && Object.keys(file.devices).length >= exports.MAX_PINNED_DEVICES;
|
|
195
|
+
if (pinnedElsewhere(file, deviceId, sender) || tooMany) {
|
|
196
|
+
changes.skipped.push(deviceId);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (pin)
|
|
200
|
+
foldCommandNumber(file, deviceId, pin.publicKey);
|
|
201
|
+
file.devices = { ...file.devices, [deviceId]: { publicKey, pinnedAt: at.toISOString() } };
|
|
202
|
+
(pin ? changes.replaced : changes.pinned).push(deviceId);
|
|
203
|
+
}
|
|
204
|
+
return changes;
|
|
205
|
+
};
|
|
206
|
+
const unchanged = (c) => c.pinned.length === 0 && c.replaced.length === 0;
|
|
207
|
+
/** A trust line on stderr, as the server writes its other lines. */
|
|
208
|
+
const warnOnStderr = (line) => console.error(`[hwip] ${line}`);
|
|
209
|
+
/** Told the file is unreadable: it stays so for every send after, so each warner hears it once per process. */
|
|
210
|
+
const toldUnreadable = new WeakSet();
|
|
211
|
+
/**
|
|
212
|
+
* Pin what `devices` lists: a new device is pinned, a changed key replaces its pin with one warning. Never refuses and
|
|
213
|
+
* never throws — a file that cannot be read or written leaves the pins as they were and the send goes on.
|
|
214
|
+
*/
|
|
215
|
+
const observeDevices = (path, devices, { selfId, now = () => new Date(), warn = warnOnStderr } = {}) => {
|
|
216
|
+
const quick = (0, exports.readTrustFile)(path);
|
|
217
|
+
if (quick === null) {
|
|
218
|
+
if (!toldUnreadable.has(warn)) {
|
|
219
|
+
toldUnreadable.add(warn);
|
|
220
|
+
warn('trust: the trust file is unreadable — pins not checked');
|
|
221
|
+
}
|
|
222
|
+
return { pinned: [], replaced: [], skipped: [], status: 'unreadable' };
|
|
223
|
+
}
|
|
224
|
+
const at = now();
|
|
225
|
+
const first = apply(quick, devices, selfId, at);
|
|
226
|
+
// The usual send changes nothing: no lock, no write.
|
|
227
|
+
if (unchanged(first))
|
|
228
|
+
return { ...first, status: 'ok' };
|
|
229
|
+
const names = new Map(devices.map((d) => [d.deviceId, d.nickname]));
|
|
230
|
+
const changesOf = (replaced, when) => replaced.map((deviceId) => ({ deviceId, at: when.toISOString(), name: names.get(deviceId) }));
|
|
231
|
+
// Not written: the send goes on with the key the list holds, and a changed key is still said — on stderr, and by
|
|
232
|
+
// this process's next tool result, since no record will.
|
|
233
|
+
const notWritten = (status) => {
|
|
234
|
+
for (const deviceId of first.replaced)
|
|
235
|
+
warn(`trust: ${deviceId} changed its key — could not record the new pin`);
|
|
236
|
+
(0, key_changes_js_1.holdKeyChanges)(changesOf(first.replaced, at));
|
|
237
|
+
return { pinned: [], replaced: first.replaced, skipped: [], status };
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
// Read again under the lock: the listener or another send may have pinned meanwhile.
|
|
241
|
+
const done = (0, exports.withTrustLock)(path, () => {
|
|
242
|
+
const latest = (0, exports.readTrustFile)(path);
|
|
243
|
+
if (latest === null)
|
|
244
|
+
return null;
|
|
245
|
+
const when = now();
|
|
246
|
+
const changes = apply(latest, devices, selfId, when);
|
|
247
|
+
if (unchanged(changes))
|
|
248
|
+
return changes;
|
|
249
|
+
writeTrustFile(path, latest);
|
|
250
|
+
// In the same section as the pin: a process that sees the new key as `same` next has nothing left to say it.
|
|
251
|
+
const keyChanges = changesOf(changes.replaced, when);
|
|
252
|
+
if (!(0, key_changes_js_1.recordKeyChanges)(path, keyChanges)) {
|
|
253
|
+
warn('trust: could not save that key change for later (key-changes.json) — the next tool result notes it');
|
|
254
|
+
(0, key_changes_js_1.holdKeyChanges)(keyChanges);
|
|
255
|
+
}
|
|
256
|
+
return changes;
|
|
257
|
+
});
|
|
258
|
+
if (!done.locked || done.value === null)
|
|
259
|
+
return notWritten(done.locked ? 'unreadable' : 'unlocked');
|
|
260
|
+
for (const deviceId of done.value.replaced)
|
|
261
|
+
warn(`trust: ${deviceId} changed its key — now pinned to the new one`);
|
|
262
|
+
return { ...done.value, status: 'ok' };
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// A full disk, a folder that is not ours: the send does not depend on the pin.
|
|
266
|
+
return notWritten('unlocked');
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
exports.observeDevices = observeDevices;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export interface ApiErrorBody {
|
|
2
|
+
error: {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
status: number;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export interface PushResponse {
|
|
9
|
+
data: {
|
|
10
|
+
pushId: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface HookTriggerResponse {
|
|
14
|
+
data: {
|
|
15
|
+
pushId: string;
|
|
16
|
+
eventId: string;
|
|
17
|
+
/** Whether the native push was queued. Absent from the answer to a repeat the server had already taken. */
|
|
18
|
+
notified?: boolean;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface HookEventResponse {
|
|
22
|
+
data: {
|
|
23
|
+
eventId: string;
|
|
24
|
+
status: 'pending' | 'responded' | 'timed_out' | 'cancelled';
|
|
25
|
+
response?: {
|
|
26
|
+
actionId?: string;
|
|
27
|
+
value?: string;
|
|
28
|
+
/** Screenshots and files the user attached to the answer — plaintext,
|
|
29
|
+
* since the hook route carries no key this end could decrypt with. */
|
|
30
|
+
files?: AttachedFile[];
|
|
31
|
+
/** Sent with the phone's "send and exit" button: the text is the user's
|
|
32
|
+
* last instruction and ends sticky REMOTE (remote-state.ts). */
|
|
33
|
+
exitRemote?: boolean;
|
|
34
|
+
respondedDeviceId?: string;
|
|
35
|
+
/** The answer to a sealed question: an envelope only the asking computer opens (ask-seal.ts). */
|
|
36
|
+
sealed?: unknown;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface DeviceRecord {
|
|
41
|
+
deviceId: string;
|
|
42
|
+
nickname?: string;
|
|
43
|
+
type?: string;
|
|
44
|
+
model?: string;
|
|
45
|
+
isOnline?: boolean;
|
|
46
|
+
lastSeenAt?: string;
|
|
47
|
+
publicKey?: string;
|
|
48
|
+
/** Where the device's listener takes local transfers (ADR-0013), or absent. */
|
|
49
|
+
lan?: {
|
|
50
|
+
host: string;
|
|
51
|
+
port: number;
|
|
52
|
+
} | null;
|
|
53
|
+
/** The tmux agent sessions this device's listener reports (a subset of hwip `AgentSession`). */
|
|
54
|
+
agentSessions?: {
|
|
55
|
+
name: string;
|
|
56
|
+
project?: string;
|
|
57
|
+
label?: string | null;
|
|
58
|
+
/** The name the agent calls the session (what the app shows). */
|
|
59
|
+
providerSessionName?: string | null;
|
|
60
|
+
/** Set on a view-only subagent pane. */
|
|
61
|
+
parentName?: string;
|
|
62
|
+
/** What runs in the session: an agent kind (`claude`, `pi`, …) or `shell` for a `hwip sh` session. */
|
|
63
|
+
agentKind?: string;
|
|
64
|
+
}[];
|
|
65
|
+
/** User renames, keyed by tmux name — a sibling of `agentSessions` so a listener re-report keeps them. */
|
|
66
|
+
agentSessionAliases?: Record<string, string>;
|
|
67
|
+
}
|
|
68
|
+
export interface DevicesResponse {
|
|
69
|
+
data: DeviceRecord[];
|
|
70
|
+
}
|
|
71
|
+
export interface DeviceKeyResponse {
|
|
72
|
+
data: DeviceRecord;
|
|
73
|
+
}
|
|
74
|
+
export interface AgentSessionRenameResponse {
|
|
75
|
+
data: DeviceRecord;
|
|
76
|
+
}
|
|
77
|
+
export interface PushRecord {
|
|
78
|
+
pushId: string;
|
|
79
|
+
type: string;
|
|
80
|
+
title?: string;
|
|
81
|
+
body?: string;
|
|
82
|
+
url?: string;
|
|
83
|
+
priority?: string;
|
|
84
|
+
senderDeviceId?: string;
|
|
85
|
+
targetDeviceId?: string;
|
|
86
|
+
channelId?: string;
|
|
87
|
+
fileKey?: string;
|
|
88
|
+
fileName?: string;
|
|
89
|
+
fileSize?: number;
|
|
90
|
+
/** End-to-end encrypted: `body` is the sealed envelope, not text (hwip `server/SERVER-API.md` Push). */
|
|
91
|
+
isEncrypted?: boolean;
|
|
92
|
+
createdAt: string;
|
|
93
|
+
}
|
|
94
|
+
export interface PushListResponse {
|
|
95
|
+
data: PushRecord[];
|
|
96
|
+
pagination: {
|
|
97
|
+
cursor?: string;
|
|
98
|
+
hasMore: boolean;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export interface DismissResponse {
|
|
102
|
+
data: {
|
|
103
|
+
dismissed: boolean | number;
|
|
104
|
+
badge?: number;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export interface ChannelRecord {
|
|
108
|
+
channelId: string;
|
|
109
|
+
tag: string;
|
|
110
|
+
name: string;
|
|
111
|
+
description?: string;
|
|
112
|
+
ownerId: string;
|
|
113
|
+
subscriberCount: number;
|
|
114
|
+
isPublic: boolean;
|
|
115
|
+
}
|
|
116
|
+
export interface ChannelsResponse {
|
|
117
|
+
data: ChannelRecord[];
|
|
118
|
+
}
|
|
119
|
+
export interface UploadRequestResponse {
|
|
120
|
+
data: {
|
|
121
|
+
fileId: string;
|
|
122
|
+
fileKey: string;
|
|
123
|
+
uploadUrl: string;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export interface AttachedFile {
|
|
127
|
+
fileKey: string;
|
|
128
|
+
fileName: string;
|
|
129
|
+
fileSize: number;
|
|
130
|
+
fileType: string;
|
|
131
|
+
iv?: string;
|
|
132
|
+
/**
|
|
133
|
+
* E2E: the file AES key wrapped once per recipient device, keyed by
|
|
134
|
+
* `deviceId`. Each value is a JSON string `{ encryptedKey, keyIv }`.
|
|
135
|
+
* (The superseded account-wide `encryptedKey` field is not written here —
|
|
136
|
+
* nothing can unwrap it since key escrow was removed.)
|
|
137
|
+
*/
|
|
138
|
+
deviceKeyMap?: Record<string, string>;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A file handed straight to one device over the LAN (ADR-0013): no bytes in
|
|
142
|
+
* S3, so no `fileKey`. The push record keeps the feed honest and tells that
|
|
143
|
+
* device's listener which landed transfer to claim.
|
|
144
|
+
*/
|
|
145
|
+
export interface LanDeliveredFile extends Omit<AttachedFile, 'fileKey'> {
|
|
146
|
+
lanDeliveredTo: string;
|
|
147
|
+
transferId: string;
|
|
148
|
+
}
|
|
149
|
+
export interface ToolError {
|
|
150
|
+
error: string;
|
|
151
|
+
message: string;
|
|
152
|
+
/** The ask this error is about — set when the question reached the phone. */
|
|
153
|
+
eventId?: string;
|
|
154
|
+
retryAfter?: number;
|
|
155
|
+
suggestion?: string;
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the API key may be sent. The key travels in the `X-API-Key` header to
|
|
3
|
+
* `baseUrl` and in the query of the `wsUrl` socket, so both URLs decide who can
|
|
4
|
+
* read it. Same rules as the cli's `wsUrlProblem` (src/ws-url.ts)
|
|
5
|
+
* and `insecureUrlWarning` (src/insecure-url.ts) — keep them in step.
|
|
6
|
+
*/
|
|
7
|
+
/** Why `url` cannot be a socket URL, or null. Never quotes userinfo, query or fragment: the log may be shared. */
|
|
8
|
+
export declare const wsUrlProblem: (url: unknown) => string | null;
|
|
9
|
+
/** One line of warning for a URL that sends the key in the clear to a non-loopback host, else null. */
|
|
10
|
+
export declare const insecureUrlWarning: (url: string) => string | null;
|
|
11
|
+
//# sourceMappingURL=url-check.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where the API key may be sent. The key travels in the `X-API-Key` header to
|
|
4
|
+
* `baseUrl` and in the query of the `wsUrl` socket, so both URLs decide who can
|
|
5
|
+
* read it. Same rules as the cli's `wsUrlProblem` (src/ws-url.ts)
|
|
6
|
+
* and `insecureUrlWarning` (src/insecure-url.ts) — keep them in step.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.insecureUrlWarning = exports.wsUrlProblem = void 0;
|
|
10
|
+
/** Why `url` cannot be a socket URL, or null. Never quotes userinfo, query or fragment: the log may be shared. */
|
|
11
|
+
const wsUrlProblem = (url) => {
|
|
12
|
+
// Whatever the config file holds: a number or a list is not a URL either.
|
|
13
|
+
if (typeof url !== 'string')
|
|
14
|
+
return 'the WebSocket URL has to be a string, like "wss://ws.hwip.app"';
|
|
15
|
+
const shown = url.replace(/\/\/[^/@]*@/, '//').replace(/[?#].*$/, '');
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(url);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return `the WebSocket URL "${shown}" is not a URL — it needs a scheme, like wss://${shown.replace(/^[a-z][a-z0-9+.-]*:\/*/i, '')}`;
|
|
22
|
+
}
|
|
23
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
24
|
+
return `the WebSocket URL "${shown}" must start with ws:// or wss://`;
|
|
25
|
+
}
|
|
26
|
+
// `?apiKey=…` is appended to it: a space or a fragment would break the connection (or put the key in the fragment).
|
|
27
|
+
if (/\s/.test(url))
|
|
28
|
+
return `the WebSocket URL "${shown}" has a space in it`;
|
|
29
|
+
if (parsed.hash)
|
|
30
|
+
return `the WebSocket URL "${shown}" has a #fragment, which a socket URL cannot carry`;
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
exports.wsUrlProblem = wsUrlProblem;
|
|
34
|
+
// `localhost.` (a trailing dot), the unspecified address and its IPv4-mapped loopback form are this machine too.
|
|
35
|
+
const LOOPBACK = /^(localhost|.*\.localhost|127(\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\]|\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\])$/i;
|
|
36
|
+
/** Whether `hostname` (from `URL.hostname`) is this machine. */
|
|
37
|
+
const isLoopbackHost = (hostname) => LOOPBACK.test(hostname.replace(/\.$/, ''));
|
|
38
|
+
/** One line of warning for a URL that sends the key in the clear to a non-loopback host, else null. */
|
|
39
|
+
const insecureUrlWarning = (url) => {
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = new URL(url);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (isLoopbackHost(parsed.hostname))
|
|
48
|
+
return null;
|
|
49
|
+
if (parsed.protocol === 'http:') {
|
|
50
|
+
return `[hwip] ${parsed.host} is reached over plain http:// — your API key travels in the clear. Use an https:// base URL.`;
|
|
51
|
+
}
|
|
52
|
+
if (parsed.protocol === 'ws:') {
|
|
53
|
+
return `[hwip] the WebSocket at ${parsed.host} is reached over plain ws:// — your API key travels in the clear. Use a wss:// URL.`;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
exports.insecureUrlWarning = insecureUrlWarning;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket fast path for hook-response waits (SPEC-AGENT-AWARENESS §S3).
|
|
3
|
+
*
|
|
4
|
+
* The server broadcasts `hook.responded` / `hook.cancelled` the moment
|
|
5
|
+
* the user answers. This module subscribes to that feed so a wait can
|
|
6
|
+
* wake instantly instead of sitting out its poll interval.
|
|
7
|
+
*
|
|
8
|
+
* Trust model: the WS message is a WAKE-UP SIGNAL ONLY. The poll loop
|
|
9
|
+
* remains the single source of truth — on wake it immediately re-reads
|
|
10
|
+
* the event over REST and acts on that. A spoofed/garbled/duplicated
|
|
11
|
+
* frame can therefore cost at most one extra GET, never a wrong answer.
|
|
12
|
+
* The same property makes degradation trivial: no WebSocket global
|
|
13
|
+
* (Node < 22), no wsUrl configured, or a dropped socket all just mean
|
|
14
|
+
* "nobody wakes us early" and the existing poll cadence carries on.
|
|
15
|
+
*
|
|
16
|
+
* Connection lifecycle: lazy connect on first subscribe, shared across
|
|
17
|
+
* concurrent waits, app-level ping every 25 s (API GW $default route
|
|
18
|
+
* answers 'pong'; native WebSocket has no protocol-ping API), closed
|
|
19
|
+
* 30 s after the last pending wait ends. A socket that drops while a wait is
|
|
20
|
+
* pending is opened again after 1 s, doubling to 30 s.
|
|
21
|
+
*/
|
|
22
|
+
export interface WsLike {
|
|
23
|
+
readyState: number;
|
|
24
|
+
send(data: string): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
addEventListener(type: string, listener: (event: {
|
|
27
|
+
data?: unknown;
|
|
28
|
+
}) => void): void;
|
|
29
|
+
}
|
|
30
|
+
export type WsFactory = (url: string) => WsLike;
|
|
31
|
+
export interface WaitSubscription {
|
|
32
|
+
/** Resolves when the server says this event settled (answer or cancel). */
|
|
33
|
+
settled: Promise<void>;
|
|
34
|
+
/** Live socket right now? Drives the caller's poll cadence. */
|
|
35
|
+
isConnected(): boolean;
|
|
36
|
+
dispose(): void;
|
|
37
|
+
}
|
|
38
|
+
export interface HookResponseWaiterOptions {
|
|
39
|
+
wsUrl?: string;
|
|
40
|
+
apiKey: string;
|
|
41
|
+
/** Test seam. Defaults to globalThis.WebSocket when present. */
|
|
42
|
+
factory?: WsFactory;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A start-up line when hwip_ask cannot be woken over the WebSocket, or null. Without it the
|
|
46
|
+
* fast path is off in silence and every answer waits for the next poll (`poll.ts`).
|
|
47
|
+
*/
|
|
48
|
+
export declare const wsFastPathNotice: (wsUrl: string | undefined, hasWebSocket?: boolean) => string | null;
|
|
49
|
+
export declare class HookResponseWaiter {
|
|
50
|
+
private readonly wsUrl?;
|
|
51
|
+
private readonly apiKey;
|
|
52
|
+
private readonly factory?;
|
|
53
|
+
private sock;
|
|
54
|
+
private connected;
|
|
55
|
+
private readonly pending;
|
|
56
|
+
private pingTimer;
|
|
57
|
+
private idleTimer;
|
|
58
|
+
private reconnectTimer;
|
|
59
|
+
/** Reconnects since the socket was last open: the next one waits 2^n s, up to 30 s. */
|
|
60
|
+
private reconnects;
|
|
61
|
+
constructor(opts: HookResponseWaiterOptions);
|
|
62
|
+
/** False when the fast path can't exist — caller skips subscribe(). */
|
|
63
|
+
canWait(): boolean;
|
|
64
|
+
subscribe(eventId: string): WaitSubscription;
|
|
65
|
+
private ensureSocket;
|
|
66
|
+
private handleMessage;
|
|
67
|
+
/** A wait still pending gets its fast path back; the poll loop covers the gap. */
|
|
68
|
+
private scheduleReconnect;
|
|
69
|
+
private scheduleIdleClose;
|
|
70
|
+
private teardownSocket;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=ws-wait.d.ts.map
|