@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.
Files changed (78) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +10 -0
  3. package/README.md +413 -2
  4. package/dist/agent-target.d.ts +45 -0
  5. package/dist/agent-target.js +81 -0
  6. package/dist/api-client.d.ts +162 -0
  7. package/dist/api-client.js +517 -0
  8. package/dist/ask-context-attach.d.ts +67 -0
  9. package/dist/ask-context-attach.js +147 -0
  10. package/dist/ask-seal.d.ts +118 -0
  11. package/dist/ask-seal.js +194 -0
  12. package/dist/command-seal.d.ts +49 -0
  13. package/dist/command-seal.js +90 -0
  14. package/dist/command-seq.d.ts +30 -0
  15. package/dist/command-seq.js +147 -0
  16. package/dist/config.d.ts +93 -0
  17. package/dist/config.js +561 -0
  18. package/dist/crypto.d.ts +234 -0
  19. package/dist/crypto.js +606 -0
  20. package/dist/error-format.d.ts +7 -0
  21. package/dist/error-format.js +77 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +30 -0
  24. package/dist/key-changes.d.ts +50 -0
  25. package/dist/key-changes.js +163 -0
  26. package/dist/mime.d.ts +16 -0
  27. package/dist/mime.js +58 -0
  28. package/dist/peer-key.d.ts +36 -0
  29. package/dist/peer-key.js +35 -0
  30. package/dist/poll.d.ts +24 -0
  31. package/dist/poll.js +156 -0
  32. package/dist/push-audience.d.ts +35 -0
  33. package/dist/push-audience.js +64 -0
  34. package/dist/redirect.d.ts +8 -0
  35. package/dist/redirect.js +17 -0
  36. package/dist/remote-state.d.ts +38 -0
  37. package/dist/remote-state.js +81 -0
  38. package/dist/resources/channels.d.ts +4 -0
  39. package/dist/resources/channels.js +24 -0
  40. package/dist/resources/devices.d.ts +4 -0
  41. package/dist/resources/devices.js +24 -0
  42. package/dist/sanitize.d.ts +41 -0
  43. package/dist/sanitize.js +106 -0
  44. package/dist/self-register.d.ts +34 -0
  45. package/dist/self-register.js +51 -0
  46. package/dist/server.d.ts +4 -0
  47. package/dist/server.js +63 -0
  48. package/dist/tools/agent-local.d.ts +17 -0
  49. package/dist/tools/agent-local.js +139 -0
  50. package/dist/tools/agent-send.d.ts +6 -0
  51. package/dist/tools/agent-send.js +89 -0
  52. package/dist/tools/ask.d.ts +9 -0
  53. package/dist/tools/ask.js +275 -0
  54. package/dist/tools/broadcast.d.ts +5 -0
  55. package/dist/tools/broadcast.js +42 -0
  56. package/dist/tools/clipboard.d.ts +5 -0
  57. package/dist/tools/clipboard.js +47 -0
  58. package/dist/tools/dismiss.d.ts +5 -0
  59. package/dist/tools/dismiss.js +49 -0
  60. package/dist/tools/file.d.ts +5 -0
  61. package/dist/tools/file.js +169 -0
  62. package/dist/tools/list.d.ts +4 -0
  63. package/dist/tools/list.js +44 -0
  64. package/dist/tools/lookup.d.ts +10 -0
  65. package/dist/tools/lookup.js +45 -0
  66. package/dist/tools/notify.d.ts +5 -0
  67. package/dist/tools/notify.js +118 -0
  68. package/dist/tools/rename.d.ts +5 -0
  69. package/dist/tools/rename.js +45 -0
  70. package/dist/trust-pins.d.ts +70 -0
  71. package/dist/trust-pins.js +269 -0
  72. package/dist/types.d.ts +157 -0
  73. package/dist/types.js +2 -0
  74. package/dist/url-check.d.ts +11 -0
  75. package/dist/url-check.js +57 -0
  76. package/dist/ws-wait.d.ts +72 -0
  77. package/dist/ws-wait.js +200 -0
  78. package/package.json +70 -4
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * The attachment a sealed `hwip_ask` question carries (F3; twin of the cli's `src/ask-context-attach.ts`): the
4
+ * original of the asking session's context — its masked diff and screen — sealed for the devices the question
5
+ * is sealed for. The context itself comes from the `hwip` CLI (`hwip ask-context show`), which owns git, tmux,
6
+ * the masking and the per-project switch; nothing of that is copied here.
7
+ *
8
+ * What the server sees of it is the same for every sealed question: one attachment, named `file`, of type
9
+ * `application/octet-stream`, 64 KiB long. A question with nothing to carry — the switch is off, no change, an
10
+ * older CLI, no CLI at all — gets the same attachment with nothing in it. The real name, type and length are in
11
+ * the sealed body.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.liveAttach = exports.collectAskContext = exports.attachWithinWindow = exports.attachAskContext = exports.readShownContext = exports.padAskOriginal = exports.ASK_ATTACH_RESERVE_MS = exports.ASK_CONTEXT_MIN_WINDOW_MS = exports.ASK_ATTACHMENT_BYTES = void 0;
15
+ const crypto_js_1 = require("./crypto.js");
16
+ const agent_local_js_1 = require("./tools/agent-local.js");
17
+ /** Every attachment is this long before it is sealed: the longest original (the CLI's `ASK_CONTEXT_MAX_BYTES`), so none needs more. */
18
+ exports.ASK_ATTACHMENT_BYTES = 64 * 1024;
19
+ /** What the server is told the attachment is. */
20
+ const WIRE_NAME = 'file';
21
+ const WIRE_TYPE = 'application/octet-stream';
22
+ /** What the sealed body says it is; a reader finds the original by this type. */
23
+ const SEALED_NAME = 'ask-context.json';
24
+ const SEALED_TYPE = 'application/vnd.hwip.ask-context+json';
25
+ /** With less of the question's window left than this, the CLI is not asked: its git and tmux reads may take most of it. */
26
+ exports.ASK_CONTEXT_MIN_WINDOW_MS = 20_000;
27
+ /** What attaching leaves of the question's window, for the question itself to be sent and answered. */
28
+ exports.ASK_ATTACH_RESERVE_MS = 10_000;
29
+ /** A window this short is a choice, not a failure: it is not warned about. */
30
+ const NO_ROOM = 'too little of the window left to attach the context';
31
+ /**
32
+ * The original as JSON (`{ diff, screen }`, UTF-8) followed by zero bytes up to `ASK_ATTACHMENT_BYTES`; `size`
33
+ * is where the JSON ends. No context, or an original that does not fit, is all zeros and `size` 0 — and no summary.
34
+ */
35
+ const padAskOriginal = (ctx) => {
36
+ const bytes = Buffer.alloc(exports.ASK_ATTACHMENT_BYTES);
37
+ if (!ctx)
38
+ return { bytes, size: 0 };
39
+ const text = Buffer.from(JSON.stringify(ctx.original), 'utf-8');
40
+ if (text.length > exports.ASK_ATTACHMENT_BYTES)
41
+ return { bytes, size: 0 };
42
+ text.copy(bytes);
43
+ return { bytes, size: text.length, context: ctx.summary };
44
+ };
45
+ exports.padAskOriginal = padAskOriginal;
46
+ /**
47
+ * What `hwip ask-context show` printed, if it is a context this tool can carry: the summary must pass the same
48
+ * reader this server applies to a sealed question's `context` (`askContext`), and the original must be two
49
+ * strings. Anything else — an older CLI's status line included — is `null`.
50
+ */
51
+ const readShownContext = (stdout) => {
52
+ let shown;
53
+ try {
54
+ shown = JSON.parse(stdout);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ const context = shown?.context;
60
+ if (typeof context !== 'object' || context === null)
61
+ return null;
62
+ const { summary, original } = context;
63
+ const read = (0, crypto_js_1.askContext)(summary);
64
+ if (!read || typeof original !== 'object' || original === null)
65
+ return null;
66
+ const { diff, screen } = original;
67
+ if (typeof diff !== 'string' || typeof screen !== 'string')
68
+ return null;
69
+ return { summary: read, original: { diff, screen } };
70
+ };
71
+ exports.readShownContext = readShownContext;
72
+ /** Seals and uploads the attachment for `recipients`. Throws when sealing or the upload fails. */
73
+ const attachAskContext = async (recipients, deps) => {
74
+ // No context and a context that failed look the same to the server on purpose; `liveAttach` says which on stderr.
75
+ const padded = (0, exports.padAskOriginal)(await deps.context().catch(() => null));
76
+ const sealed = await deps.encrypt(padded.bytes, recipients);
77
+ const fileKey = await deps.upload(sealed.ciphertext);
78
+ return {
79
+ ...(padded.context ? { context: padded.context } : {}),
80
+ meta: { iv: sealed.iv, name: SEALED_NAME, type: SEALED_TYPE, size: padded.size },
81
+ file: { fileKey, fileName: WIRE_NAME, fileType: WIRE_TYPE, fileSize: exports.ASK_ATTACHMENT_BYTES, iv: sealed.iv, deviceKeyMap: sealed.deviceKeyMap },
82
+ };
83
+ };
84
+ exports.attachAskContext = attachAskContext;
85
+ /**
86
+ * `attachAskContext`, given up (it rejects, and the question goes without it) once it would eat into the last
87
+ * `ASK_ATTACH_RESERVE_MS` of a window of `windowMs`: storing the attachment must never hold the question back.
88
+ */
89
+ const attachWithinWindow = (recipients, deps, windowMs) => {
90
+ const room = windowMs - exports.ASK_ATTACH_RESERVE_MS;
91
+ if (room <= 0)
92
+ return Promise.reject(new Error(NO_ROOM));
93
+ let timer;
94
+ const late = new Promise((_, reject) => {
95
+ timer = setTimeout(() => reject(new Error(`attaching the context took over ${room} ms`)), room);
96
+ });
97
+ return Promise.race([(0, exports.attachAskContext)(recipients, deps), late]).finally(() => clearTimeout(timer));
98
+ };
99
+ exports.attachWithinWindow = attachWithinWindow;
100
+ /** The context of the session this server runs in, from the CLI; `null` with too little of the window left to ask it. */
101
+ const collectAskContext = async (exec, windowMs, signal) => {
102
+ if (windowMs < exports.ASK_CONTEXT_MIN_WINDOW_MS)
103
+ return null;
104
+ return (0, exports.readShownContext)((await exec(['ask-context', 'show'], signal)).stdout);
105
+ };
106
+ exports.collectAskContext = collectAskContext;
107
+ /**
108
+ * `AskSealDeps.attach` for real use: the context `hwip` reports for this server's directory and tmux pane,
109
+ * sealed with this machine's keys, stored with the account, within the question's `windowMs`. What goes wrong
110
+ * is said on stderr (stdout is the MCP transport): a context that could not be read goes as the empty
111
+ * attachment, one that could not be stored not at all.
112
+ */
113
+ const liveAttach = (client, windowMs, signal, exec = agent_local_js_1.execHwip, warn = (line) => console.error(`[hwip] ${line}`)) => (recipients) => {
114
+ // Stops the CLI and the upload once the attachment is given up on, as well as on the call's cancel.
115
+ const stop = new AbortController();
116
+ const cancelled = () => stop.abort();
117
+ signal?.addEventListener('abort', cancelled, { once: true });
118
+ const message = (err) => (err instanceof Error ? err.message : String(err));
119
+ return (0, exports.attachWithinWindow)(recipients, {
120
+ context: async () => {
121
+ try {
122
+ return await (0, exports.collectAskContext)(exec, windowMs, stop.signal);
123
+ }
124
+ catch (err) {
125
+ // No `hwip` installed is a setup this tool supports: it asks with the empty attachment, unremarked.
126
+ const absent = err?.code === 'ENOENT';
127
+ if (!absent && !stop.signal.aborted)
128
+ warn(`the context could not be read from \`hwip ask-context show\` (${message(err)}); the question carries none`);
129
+ return null;
130
+ }
131
+ },
132
+ encrypt: crypto_js_1.encryptFileForDevices,
133
+ upload: async (sealed) => {
134
+ const upload = await client.requestUpload({ fileName: WIRE_NAME, fileType: WIRE_TYPE, fileSize: sealed.length }, stop.signal);
135
+ await client.uploadToS3(upload.data.uploadUrl, sealed, WIRE_TYPE, stop.signal);
136
+ return upload.data.fileKey;
137
+ },
138
+ }, windowMs).catch((err) => {
139
+ if (message(err) !== NO_ROOM && !signal?.aborted)
140
+ warn(`the context was not attached (${message(err)}); the question goes without it`);
141
+ throw err;
142
+ }).finally(() => {
143
+ stop.abort();
144
+ signal?.removeEventListener('abort', cancelled);
145
+ });
146
+ };
147
+ exports.liveAttach = liveAttach;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `hwip_ask` sealed both ways (F1; twin of the cli's `src/ask-seal.ts`): the question goes
3
+ * to the phone inside a push-v1 envelope and the answer comes back in one, so the server
4
+ * relays both and reads neither. Every question is sealed (F16); one that cannot be is refused,
5
+ * never sent in the clear.
6
+ *
7
+ * What the server still sees of a sealed question: each choice's `id` and
8
+ * `style`, the `fallback`, the timeout, the session routing fields and which device answered,
9
+ * and that it has one attachment of a fixed name, type and size (`ask-context-attach.ts`) — or none, when
10
+ * that attachment could not be stored or would take the envelope over the server's limit.
11
+ * Not the title, the body, the wording of the choices, the answer, or whether the attachment holds anything.
12
+ *
13
+ * Nothing here logs or returns a decrypt error's message: the plaintext of an
14
+ * authenticated envelope can end up in one, and a refusal says only which step failed.
15
+ */
16
+ import { decryptPushBodyForDevice, type DeviceRecipient, type PushBodyInput, type SealedAskContext, type SealedFileMeta } from './crypto.js';
17
+ import { type Register, type RegisterWhy } from './self-register.js';
18
+ export interface AskQuestion {
19
+ title: string;
20
+ body?: string;
21
+ actions?: {
22
+ id: string;
23
+ label: string;
24
+ style?: string;
25
+ }[];
26
+ placeholder?: string;
27
+ }
28
+ /** A sealed envelope as the trigger and the respond route carry it. */
29
+ export interface SealedFields {
30
+ body: string;
31
+ senderPublicKey: string;
32
+ deviceKeyMap: Record<string, string>;
33
+ }
34
+ type DeviceEntry = {
35
+ deviceId: string;
36
+ publicKey?: string;
37
+ type?: string;
38
+ };
39
+ /** One sealed attachment as the trigger carries it: everything in it but the wrapped keys is in the server's view. */
40
+ export interface AskWireFile {
41
+ fileKey: string;
42
+ fileName: string;
43
+ fileType: string;
44
+ fileSize: number;
45
+ iv: string;
46
+ deviceKeyMap: Record<string, string>;
47
+ }
48
+ /** What a sealed question carries of its session (F3, `ask-context-attach.ts`). */
49
+ export interface AskAttachment {
50
+ /** The summary for the sealed body; left out when there is no context (the attachment is there all the same). */
51
+ context?: SealedAskContext;
52
+ /** How the sealed body names the attachment. */
53
+ meta: SealedFileMeta;
54
+ file: AskWireFile;
55
+ }
56
+ export interface AskSealDeps {
57
+ /** Load this machine's device keys; throws when they cannot be had. */
58
+ loadKeys: () => Promise<void>;
59
+ /** This machine as a device of the account, once its keys are loaded. */
60
+ self: () => {
61
+ deviceId: string;
62
+ publicKey: string;
63
+ } | null;
64
+ /** `GET /devices`; throws when the list cannot be fetched. */
65
+ listDevices: () => Promise<DeviceEntry[]>;
66
+ /** Puts this machine's key on the account's list when the list lacks it (`PUT /devices/{id}/public-key`). */
67
+ register: Register;
68
+ /** The listener's state file, where devices are pinned as they are seen. */
69
+ trustPath: string;
70
+ seal?: (content: PushBodyInput, recipients: DeviceRecipient[]) => Promise<SealedFields & {
71
+ isEncrypted: true;
72
+ }>;
73
+ open?: typeof decryptPushBodyForDevice;
74
+ /** A fresh 128-bit id, URL-safe. */
75
+ newAskId?: () => string;
76
+ /**
77
+ * Uploads the context attachment sealed for `recipients`. Called only for a question that is being sealed,
78
+ * once who may read it is known; when it throws, the question goes out without an attachment.
79
+ */
80
+ attach?: (recipients: DeviceRecipient[]) => Promise<AskAttachment>;
81
+ }
82
+ export type AskRefusal = 'no-app' | 'keystore' | 'answer-keystore' | 'devices' | 'self-unlisted' | 'no-recipients' | 'too-large' | 'encrypt' | 'not-sealed' | 'peer-unlisted' | 'peer-unusable' | 'sender-mismatch' | 'no-slot' | 'unreadable' | 'wrong-labels' | 'unknown-action' | 'empty-answer';
83
+ export declare const refusalMessage: (cause: AskRefusal, why?: RegisterWhy) => string;
84
+ export type PreparedAsk = {
85
+ kind: 'sealed';
86
+ askId: string;
87
+ fields: SealedFields;
88
+ offered: string[] | undefined;
89
+ files?: AskWireFile[];
90
+ } | {
91
+ kind: 'refused';
92
+ cause: AskRefusal;
93
+ why?: RegisterWhy;
94
+ };
95
+ export type OpenedAnswer = {
96
+ ok: true;
97
+ actionId?: string;
98
+ value?: string;
99
+ exit?: true;
100
+ } | {
101
+ ok: false;
102
+ cause: AskRefusal;
103
+ };
104
+ export declare const prepareAsk: (question: AskQuestion, deps: AskSealDeps) => Promise<PreparedAsk>;
105
+ /**
106
+ * Open the answer the phone sealed. It counts only when every step holds:
107
+ * the answering device's key is believed (`peerKeyOf`, asked without `self` so
108
+ * this machine's own key can never vouch for the answer), the envelope was
109
+ * sealed with exactly that key, it opens with this machine's slot, and it is
110
+ * an `answer` for this question's id — and not a question or command label,
111
+ * which would be the question envelope this machine sent, played back.
112
+ */
113
+ export declare const openAnswer: (response: {
114
+ sealed?: unknown;
115
+ respondedDeviceId?: unknown;
116
+ } | undefined, askId: string, offered: string[] | undefined, deps: AskSealDeps) => Promise<OpenedAnswer>;
117
+ export {};
118
+ //# sourceMappingURL=ask-seal.d.ts.map
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ /**
3
+ * `hwip_ask` sealed both ways (F1; twin of the cli's `src/ask-seal.ts`): the question goes
4
+ * to the phone inside a push-v1 envelope and the answer comes back in one, so the server
5
+ * relays both and reads neither. Every question is sealed (F16); one that cannot be is refused,
6
+ * never sent in the clear.
7
+ *
8
+ * What the server still sees of a sealed question: each choice's `id` and
9
+ * `style`, the `fallback`, the timeout, the session routing fields and which device answered,
10
+ * and that it has one attachment of a fixed name, type and size (`ask-context-attach.ts`) — or none, when
11
+ * that attachment could not be stored or would take the envelope over the server's limit.
12
+ * Not the title, the body, the wording of the choices, the answer, or whether the attachment holds anything.
13
+ *
14
+ * Nothing here logs or returns a decrypt error's message: the plaintext of an
15
+ * authenticated envelope can end up in one, and a refusal says only which step failed.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.openAnswer = exports.prepareAsk = exports.refusalMessage = void 0;
19
+ const node_crypto_1 = require("node:crypto");
20
+ const crypto_js_1 = require("./crypto.js");
21
+ const peer_key_js_1 = require("./peer-key.js");
22
+ const push_audience_js_1 = require("./push-audience.js");
23
+ const self_register_js_1 = require("./self-register.js");
24
+ const trust_pins_js_1 = require("./trust-pins.js");
25
+ /** The longest envelope the server takes as a sealed question's `body` (hwip `server/src/lib/push-input.ts` MAX_ASK_BODY_BYTES). */
26
+ const ASK_BODY_MAX_BYTES = 48 * 1024;
27
+ const REFUSAL_TEXT = {
28
+ 'no-app': `no app is signed in to this account: ${push_audience_js_1.APP_LOGIN_REQUIRED}, then send again; the question was not sent`,
29
+ 'keystore': 'this machine\'s encryption keys did not load; the question was not sent',
30
+ 'answer-keystore': 'an answer came, but this machine\'s encryption keys did not load, so it could not be opened (the question had been sent)',
31
+ 'devices': 'the device list could not be fetched; the question was not sent',
32
+ 'self-unlisted': (0, self_register_js_1.unregisteredText)('question'),
33
+ 'no-recipients': 'no other device lists a key to seal the question for; the question was not sent',
34
+ 'too-large': 'a choice id is over 64 characters, a choice label over 100, or there are more than 10 choices; shorten them (the question was not sent)',
35
+ 'encrypt': 'sealing the question failed; the question was not sent',
36
+ 'not-sealed': 'the answer to a sealed question was not sealed; ignored',
37
+ 'peer-unlisted': 'the answer came from a device the account does not list; ignored',
38
+ 'peer-unusable': 'the answering device could not be checked (the device list could not be fetched); ignored',
39
+ 'sender-mismatch': 'the answer was sealed with a key other than the answering device\'s; ignored',
40
+ 'no-slot': 'the answer was not sealed for this machine; ignored',
41
+ 'unreadable': 'the answer could not be opened; ignored',
42
+ 'wrong-labels': 'the answer does not belong to this question; ignored',
43
+ 'unknown-action': 'the answer names a choice that was not offered; ignored',
44
+ 'empty-answer': 'the answer holds no choice and no text; ignored',
45
+ };
46
+ const refusalMessage = (cause, why) => `sealed ask: ${cause === 'self-unlisted' ? (0, self_register_js_1.unregisteredText)('question', why) : REFUSAL_TEXT[cause]}`;
47
+ exports.refusalMessage = refusalMessage;
48
+ const defaultAskId = () => (0, node_crypto_1.randomBytes)(16).toString('base64url');
49
+ /**
50
+ * A device-list failure that is really the configuration: an address that is not one
51
+ * (`causeCode: ERR_INVALID_URL`), or a key the server refused (`code: UNAUTHORIZED`). Swallowed
52
+ * into `devices` it reads as a transient fetch problem ("could not be fetched"); thrown on, the
53
+ * tool reports the setting to fix (M41). Anything else stays a refusal — a 503 or a timeout may
54
+ * pass a moment later. Matched by fields, not `instanceof ApiError`: the shape is the contract,
55
+ * and one class object per module graph would make an identity check fragile.
56
+ */
57
+ const isConfigFailure = (err) => {
58
+ if (typeof err !== 'object' || err === null)
59
+ return false;
60
+ const { code, causeCode } = err;
61
+ return code === 'UNAUTHORIZED' || causeCode === 'ERR_INVALID_URL';
62
+ };
63
+ const prepareAsk = async (question, deps) => {
64
+ try {
65
+ await deps.loadKeys();
66
+ }
67
+ catch {
68
+ return { kind: 'refused', cause: 'keystore' };
69
+ }
70
+ const self = deps.self();
71
+ if (!self)
72
+ return { kind: 'refused', cause: 'keystore' };
73
+ let devices;
74
+ try {
75
+ devices = await deps.listDevices();
76
+ if (!Array.isArray(devices))
77
+ throw new Error('not a list');
78
+ }
79
+ catch (err) {
80
+ if (isConfigFailure(err))
81
+ throw err;
82
+ return { kind: 'refused', cause: 'devices' };
83
+ }
84
+ if (!(0, push_audience_js_1.hasAppDevice)(devices))
85
+ return { kind: 'refused', cause: 'no-app' };
86
+ // The phone seals its answer for the computer named in the question, found by id in the same list.
87
+ const listing = await (0, self_register_js_1.ensureSelfListed)(devices, self, deps.register);
88
+ if (!listing.ok)
89
+ return { kind: 'refused', cause: 'self-unlisted', why: listing.why };
90
+ devices = listing.devices;
91
+ // Who may read the question: every listed device with a key but this machine. Trust never stops it (F16).
92
+ (0, trust_pins_js_1.observeDevices)(deps.trustPath, devices, { selfId: self.deviceId });
93
+ const recipients = (0, crypto_js_1.selectRecipients)(devices).filter((d) => d.deviceId !== self.deviceId);
94
+ if (recipients.length === 0)
95
+ return { kind: 'refused', cause: 'no-recipients' };
96
+ // The context never stops a question: an ask that cannot attach still has to ask (the live `attach` says why on stderr).
97
+ const attachment = deps.attach ? await deps.attach(recipients).catch(() => null) : null;
98
+ const askId = (deps.newAskId ?? defaultAskId)();
99
+ const actions = question.actions;
100
+ const sealWith = (context, attached = attachment) => (deps.seal ?? crypto_js_1.encryptPushBodyForDevices)({
101
+ title: question.title,
102
+ body: question.body,
103
+ ask: {
104
+ id: askId,
105
+ device: self.deviceId,
106
+ ...(actions ? { actions } : {}),
107
+ ...(question.placeholder ? { placeholder: question.placeholder } : {}),
108
+ },
109
+ ...(attached ? { files: [attached.meta] } : {}),
110
+ ...(context ? { context } : {}),
111
+ }, recipients);
112
+ try {
113
+ let sealed = await sealWith(attachment?.context);
114
+ // The server refuses a longer envelope whole: the summary goes before the question does, then the attachment's name.
115
+ let attached = attachment;
116
+ const over = () => attached !== null && Buffer.byteLength(sealed.body) > ASK_BODY_MAX_BYTES;
117
+ if (over() && attached?.context)
118
+ sealed = await sealWith(undefined);
119
+ if (over()) {
120
+ attached = null;
121
+ sealed = await sealWith(undefined, null);
122
+ }
123
+ return {
124
+ kind: 'sealed',
125
+ askId,
126
+ fields: { body: sealed.body, senderPublicKey: sealed.senderPublicKey, deviceKeyMap: sealed.deviceKeyMap },
127
+ offered: actions?.map((a) => a.id),
128
+ ...(attached ? { files: [attached.file] } : {}),
129
+ };
130
+ }
131
+ catch (err) {
132
+ // Only crypto.ts `assertAskWithinLimits` carries this code, for the choices alone; a test seals an over-limit choice for real.
133
+ if (err instanceof Error && err.code === crypto_js_1.ASK_CHOICES_CODE)
134
+ return { kind: 'refused', cause: 'too-large' };
135
+ return { kind: 'refused', cause: 'encrypt' };
136
+ }
137
+ };
138
+ exports.prepareAsk = prepareAsk;
139
+ const PEER_REFUSALS = {
140
+ unlisted: 'peer-unlisted',
141
+ devices_unavailable: 'peer-unusable',
142
+ };
143
+ /**
144
+ * Open the answer the phone sealed. It counts only when every step holds:
145
+ * the answering device's key is believed (`peerKeyOf`, asked without `self` so
146
+ * this machine's own key can never vouch for the answer), the envelope was
147
+ * sealed with exactly that key, it opens with this machine's slot, and it is
148
+ * an `answer` for this question's id — and not a question or command label,
149
+ * which would be the question envelope this machine sent, played back.
150
+ */
151
+ const openAnswer = async (response, askId, offered, deps) => {
152
+ const sealed = response?.sealed;
153
+ const from = response?.respondedDeviceId;
154
+ if (!isSealedFields(sealed) || typeof from !== 'string')
155
+ return { ok: false, cause: 'not-sealed' };
156
+ const peer = await (0, peer_key_js_1.peerKeyOf)(from, {
157
+ registeredKey: async (id) => (await deps.listDevices()).find((d) => d.deviceId === id)?.publicKey ?? null,
158
+ trustPath: deps.trustPath,
159
+ selfId: deps.self()?.deviceId,
160
+ });
161
+ if ('refused' in peer)
162
+ return { ok: false, cause: PEER_REFUSALS[peer.refused] };
163
+ if (!(0, crypto_js_1.samePublicKey)(peer.key, sealed.senderPublicKey))
164
+ return { ok: false, cause: 'sender-mismatch' };
165
+ const self = deps.self();
166
+ const slot = self ? sealed.deviceKeyMap[self.deviceId] : undefined;
167
+ if (typeof slot !== 'string')
168
+ return { ok: false, cause: 'no-slot' };
169
+ let opened;
170
+ try {
171
+ opened = await (deps.open ?? crypto_js_1.decryptPushBodyForDevice)(sealed.body, slot, sealed.senderPublicKey);
172
+ }
173
+ catch {
174
+ return { ok: false, cause: 'unreadable' };
175
+ }
176
+ if (!opened.answer || opened.answer.ask !== askId || opened.ask || opened.cmd)
177
+ return { ok: false, cause: 'wrong-labels' };
178
+ const { actionId, exit } = opened.answer;
179
+ if (actionId !== undefined && (offered === undefined || !offered.includes(actionId)))
180
+ return { ok: false, cause: 'unknown-action' };
181
+ if (actionId === undefined && !opened.body && exit !== true)
182
+ return { ok: false, cause: 'empty-answer' };
183
+ return {
184
+ ok: true,
185
+ ...(actionId === undefined ? {} : { actionId }),
186
+ ...(opened.body ? { value: opened.body } : {}),
187
+ ...(exit === true ? { exit } : {}),
188
+ };
189
+ };
190
+ exports.openAnswer = openAnswer;
191
+ const isSealedFields = (value) => typeof value === 'object' && value !== null
192
+ && 'body' in value && typeof value.body === 'string'
193
+ && 'senderPublicKey' in value && typeof value.senderPublicKey === 'string'
194
+ && 'deviceKeyMap' in value && typeof value.deviceKeyMap === 'object' && value.deviceKeyMap !== null;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `hwip agent send` sealed (F2): the message goes to the target listener inside a
3
+ * push-v1 envelope, so the server relays it and reads nothing of what is typed into
4
+ * the other session. Every command is sealed (F16); one that cannot be is refused,
5
+ * never sent in the clear.
6
+ *
7
+ * The receiving listener takes a command only from a device whose key it can check, and the server stamps a send made with
8
+ * an API key `api_<keyId>`, which names no machine. So the sealed label carries `from`, this machine's device id; the listener
9
+ * admits it by the key the account lists for that id and the one it pinned.
10
+ *
11
+ * The target's key is the one the account lists for it (this machine's own when the target is here). Trust never stops a
12
+ * send (F16): every listed device is pinned when first seen, and a changed key replaces its pin with one warning.
13
+ *
14
+ * Nothing here logs or returns a decrypt or encrypt error's message.
15
+ */
16
+ import { type DeviceRecipient, type PushBodyInput } from './crypto.js';
17
+ import { type AskSealDeps, type SealedFields } from './ask-seal.js';
18
+ import { type CommandSeqRefusal } from './command-seq.js';
19
+ import { type RegisterWhy } from './self-register.js';
20
+ export type CommandSealDeps = Pick<AskSealDeps, 'loadKeys' | 'self' | 'listDevices' | 'register' | 'trustPath'> & {
21
+ /** The next command number from the counter every sender on this machine shares. */
22
+ nextNumber?: () => Promise<number | {
23
+ refused: CommandSeqRefusal;
24
+ }>;
25
+ seal?: (content: PushBodyInput, recipients: DeviceRecipient[]) => Promise<SealedFields & {
26
+ isEncrypted: true;
27
+ }>;
28
+ };
29
+ export type CommandRefusal = 'keystore' | 'devices' | 'self-unlisted' | 'target-unlisted' | 'number-locked' | 'number-unusable' | 'encrypt';
30
+ export declare const COMMAND_REFUSAL_TEXT: Record<CommandRefusal, string>;
31
+ export declare const commandRefusalMessage: (cause: CommandRefusal, why?: RegisterWhy) => string;
32
+ export type PreparedCommand = {
33
+ kind: 'sealed';
34
+ fields: SealedFields;
35
+ } | {
36
+ kind: 'refused';
37
+ cause: CommandRefusal;
38
+ why?: RegisterWhy;
39
+ };
40
+ /**
41
+ * Decide how `message` reaches `session` on `targetDeviceId`, and seal it when it must be sealed.
42
+ * The caller sends `sealed` with the returned fields (body, key, slots), and tells the user of `refused` without sending.
43
+ */
44
+ export declare const sealedCommand: (command: {
45
+ targetDeviceId: string;
46
+ session: string;
47
+ message: string;
48
+ }, deps: CommandSealDeps) => Promise<PreparedCommand>;
49
+ //# sourceMappingURL=command-seal.d.ts.map
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ /**
3
+ * `hwip agent send` sealed (F2): the message goes to the target listener inside a
4
+ * push-v1 envelope, so the server relays it and reads nothing of what is typed into
5
+ * the other session. Every command is sealed (F16); one that cannot be is refused,
6
+ * never sent in the clear.
7
+ *
8
+ * The receiving listener takes a command only from a device whose key it can check, and the server stamps a send made with
9
+ * an API key `api_<keyId>`, which names no machine. So the sealed label carries `from`, this machine's device id; the listener
10
+ * admits it by the key the account lists for that id and the one it pinned.
11
+ *
12
+ * The target's key is the one the account lists for it (this machine's own when the target is here). Trust never stops a
13
+ * send (F16): every listed device is pinned when first seen, and a changed key replaces its pin with one warning.
14
+ *
15
+ * Nothing here logs or returns a decrypt or encrypt error's message.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.sealedCommand = exports.commandRefusalMessage = exports.COMMAND_REFUSAL_TEXT = void 0;
19
+ const crypto_js_1 = require("./crypto.js");
20
+ const command_seq_js_1 = require("./command-seq.js");
21
+ const peer_key_js_1 = require("./peer-key.js");
22
+ const self_register_js_1 = require("./self-register.js");
23
+ const trust_pins_js_1 = require("./trust-pins.js");
24
+ exports.COMMAND_REFUSAL_TEXT = {
25
+ 'keystore': 'this machine\'s encryption keys did not load; the message was not sent',
26
+ 'devices': 'the device list could not be fetched; the message was not sent',
27
+ 'self-unlisted': (0, self_register_js_1.unregisteredText)('message'),
28
+ 'target-unlisted': 'the target computer is not in the account\'s device list with a key (run `hwip listener` on it once); the message was not sent',
29
+ 'number-locked': 'another hwip process holds ~/.hwip/command-seq.lock; try again in a moment (the message was not sent)',
30
+ 'number-unusable': 'the command counter ~/.hwip/command-seq cannot be read or written; the message was not sent',
31
+ 'encrypt': 'sealing the message failed; the message was not sent',
32
+ };
33
+ const commandRefusalMessage = (cause, why) => `sealed send: ${cause === 'self-unlisted' ? (0, self_register_js_1.unregisteredText)('message', why) : exports.COMMAND_REFUSAL_TEXT[cause]}`;
34
+ exports.commandRefusalMessage = commandRefusalMessage;
35
+ /**
36
+ * Decide how `message` reaches `session` on `targetDeviceId`, and seal it when it must be sealed.
37
+ * The caller sends `sealed` with the returned fields (body, key, slots), and tells the user of `refused` without sending.
38
+ */
39
+ const sealedCommand = async (command, deps) => {
40
+ try {
41
+ await deps.loadKeys();
42
+ }
43
+ catch {
44
+ return { kind: 'refused', cause: 'keystore' };
45
+ }
46
+ const self = deps.self();
47
+ if (!self)
48
+ return { kind: 'refused', cause: 'keystore' };
49
+ let devices;
50
+ try {
51
+ devices = await deps.listDevices();
52
+ if (!Array.isArray(devices))
53
+ throw new Error('not a list');
54
+ }
55
+ catch {
56
+ return { kind: 'refused', cause: 'devices' };
57
+ }
58
+ // The listener admits this machine by the key the list holds for it: an unlisted one would be refused there.
59
+ const listing = await (0, self_register_js_1.ensureSelfListed)(devices, self, deps.register);
60
+ if (!listing.ok)
61
+ return { kind: 'refused', cause: 'self-unlisted', why: listing.why };
62
+ devices = listing.devices;
63
+ (0, trust_pins_js_1.observeDevices)(deps.trustPath, devices, { selfId: self.deviceId });
64
+ const target = await (0, peer_key_js_1.peerKeyOf)(command.targetDeviceId, {
65
+ // The list was read once above: the target is looked up in it, not fetched again.
66
+ registeredKey: async (id) => devices.find((d) => d.deviceId === id)?.publicKey ?? null,
67
+ trustPath: deps.trustPath,
68
+ self,
69
+ });
70
+ if ('refused' in target)
71
+ return { kind: 'refused', cause: 'target-unlisted' };
72
+ // The target's slot is the listener's own. Besides it, every other listed device with a key can read the message
73
+ // (a phone's bubble opens).
74
+ const readers = (0, crypto_js_1.selectRecipients)(devices).filter((d) => d.deviceId !== self.deviceId && d.deviceId !== command.targetDeviceId);
75
+ const recipients = [{ deviceId: command.targetDeviceId, publicKey: target.key }, ...readers];
76
+ const n = await (deps.nextNumber ?? (() => (0, command_seq_js_1.nextCommandNumber)()))();
77
+ if (typeof n !== 'number')
78
+ return { kind: 'refused', cause: n.refused === 'lock' ? 'number-locked' : 'number-unusable' };
79
+ try {
80
+ const sealed = await (deps.seal ?? crypto_js_1.encryptPushBodyForDevices)({ body: command.message, cmd: { n, session: command.session, device: command.targetDeviceId, from: self.deviceId } }, recipients);
81
+ // A recipient whose key would not import is skipped by the sealer: the send must not go without the target's slot.
82
+ if (!sealed.deviceKeyMap[command.targetDeviceId])
83
+ return { kind: 'refused', cause: 'encrypt' };
84
+ return { kind: 'sealed', fields: { body: sealed.body, senderPublicKey: sealed.senderPublicKey, deviceKeyMap: sealed.deviceKeyMap } };
85
+ }
86
+ catch {
87
+ return { kind: 'refused', cause: 'encrypt' };
88
+ }
89
+ };
90
+ exports.sealedCommand = sealedCommand;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The number a sealed command carries (`cmd.n`): the listener takes only a number greater than the
3
+ * last it saw from that sender, so every process on this machine that sends as this machine — the
4
+ * CLI and any number of MCP servers — has to draw from one counter. It is a file with a single
5
+ * line, `~/.hwip/command-seq`, read and rewritten under an exclusive lock file beside it.
6
+ *
7
+ * `n = max(last + 1, now)`: the clock keeps numbers moving forward when the file is lost, which
8
+ * would otherwise start over at 1 and have every listener drop the new commands as replays. This is
9
+ * the formula the phone uses for its own counter (`PeerPins.nextCommand`).
10
+ *
11
+ * This is a copy of cli/src/command-seq.ts (the CLI sends as this machine too); the file's name, its
12
+ * one-line format and the lock's name are the contract between them.
13
+ */
14
+ export type CommandSeqRefusal =
15
+ /** The lock could not be taken: another sender holds it. */
16
+ 'lock'
17
+ /** The counter could not be read, or written, or no longer fits a number. */
18
+ | 'io';
19
+ export interface CommandSeqDeps {
20
+ /** Where the counter lives. Default `~/.hwip`. */
21
+ dir?: string;
22
+ /** The clock, in ms since the epoch. */
23
+ now?: () => number;
24
+ /** Wait between tries for the lock. */
25
+ sleep?: (ms: number) => Promise<void>;
26
+ }
27
+ export declare const nextCommandNumber: (deps?: CommandSeqDeps) => Promise<number | {
28
+ refused: CommandSeqRefusal;
29
+ }>;
30
+ //# sourceMappingURL=command-seq.d.ts.map