@hwip/cli 0.0.1 → 0.1.1
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 +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HwipHook = exports.uploadContentType = void 0;
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const agent_session_js_1 = require("./agent-session.js");
|
|
7
|
+
const channel_js_1 = require("./channel.js");
|
|
8
|
+
const errors_js_1 = require("./errors.js");
|
|
9
|
+
const ws_url_js_1 = require("./ws-url.js");
|
|
10
|
+
const http_failure_js_1 = require("./http-failure.js");
|
|
11
|
+
const insecure_url_js_1 = require("./insecure-url.js");
|
|
12
|
+
const crypto_js_1 = require("./crypto.js");
|
|
13
|
+
const listener_device_id_js_1 = require("./listener-device-id.js");
|
|
14
|
+
const trust_pins_js_1 = require("./trust-pins.js");
|
|
15
|
+
const self_register_js_1 = require("./self-register.js");
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const BODY_FILE_THRESHOLD = 512;
|
|
18
|
+
const PREVIEW_LENGTH = 200;
|
|
19
|
+
/**
|
|
20
|
+
* The Content-Type header an upload PUT has to carry, whatever its body: the address is signed for it,
|
|
21
|
+
* and S3 answers 403 to any other. It is the type as sent to `POST /files/upload-request`, with
|
|
22
|
+
* `; charset=utf-8` added to `text/*` that has no charset (the hwip server's `uploadContentType`).
|
|
23
|
+
* It used to follow the body's shape (a string got a charset, a Buffer none), which only held while
|
|
24
|
+
* the only uploads were `response.md` text and sealed bytes.
|
|
25
|
+
*/
|
|
26
|
+
const uploadContentType = (contentType) => contentType.startsWith('text/') && !contentType.includes('charset') ? `${contentType}; charset=utf-8` : contentType;
|
|
27
|
+
exports.uploadContentType = uploadContentType;
|
|
28
|
+
const inferMimeType = (fileName) => {
|
|
29
|
+
const ext = fileName.split('.').pop()?.toLowerCase();
|
|
30
|
+
const map = { md: 'text/markdown', txt: 'text/plain', json: 'application/json' };
|
|
31
|
+
return map[ext ?? ''] ?? 'text/plain';
|
|
32
|
+
};
|
|
33
|
+
/** A push this computer could not seal is not sent: never in the clear. */
|
|
34
|
+
const notSealed = (why) => new errors_js_1.HwipError(`${why}; the push was not sent`, 'E2E_REQUIRED', 400);
|
|
35
|
+
class HwipHook {
|
|
36
|
+
apiKey;
|
|
37
|
+
baseUrl;
|
|
38
|
+
timeoutMs;
|
|
39
|
+
/** The account this hook talks as: what `hwip ask` and `hwip agent send` need to read the device list. */
|
|
40
|
+
get account() {
|
|
41
|
+
return { apiKey: this.apiKey, baseUrl: this.baseUrl };
|
|
42
|
+
}
|
|
43
|
+
constructor(options) {
|
|
44
|
+
if (!options.apiKey) {
|
|
45
|
+
throw new errors_js_1.HwipError('apiKey is required', 'INVALID_OPTIONS', 400);
|
|
46
|
+
}
|
|
47
|
+
this.apiKey = options.apiKey;
|
|
48
|
+
this.baseUrl = (options.baseUrl ?? ws_url_js_1.DEFAULT_API_BASE).replace(/\/$/, '');
|
|
49
|
+
(0, insecure_url_js_1.warnInsecureUrl)(this.baseUrl);
|
|
50
|
+
this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Who this push is sealed for. Every push is sealed: when this computer's keys do not load, the device list
|
|
54
|
+
* cannot be read, no phone app is signed in or no device lists a key, the push is not sent — never sent in the
|
|
55
|
+
* clear. The device list is fetched per send rather than cached: a phone that registered its key a minute ago
|
|
56
|
+
* must be able to read the next push. Every device it lists is pinned on the way (`observeDevices`); a pin whose
|
|
57
|
+
* device now lists another key is replaced and recorded for a person (`key-changes.ts`). Returned rather than kept
|
|
58
|
+
* on the instance: two concurrent sends on one SDK hook must not overwrite each other's.
|
|
59
|
+
*/
|
|
60
|
+
async audience() {
|
|
61
|
+
try {
|
|
62
|
+
await (0, crypto_js_1.initDeviceCrypto)();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw notSealed("this computer's keys did not load");
|
|
66
|
+
}
|
|
67
|
+
let listed;
|
|
68
|
+
try {
|
|
69
|
+
listed = (await this.request('GET', '/devices')).data;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// A rejected key, a used-up quota, a server that is down or limiting, a connection that failed: the record
|
|
73
|
+
// names those kinds, and they are not this push's to explain. What is left is the server saying no to the list.
|
|
74
|
+
if (!(err instanceof errors_js_1.HwipError) || err instanceof errors_js_1.AuthenticationError || err instanceof errors_js_1.QuotaExceededError
|
|
75
|
+
|| err.code === 'TIMEOUT' || err.code === 'NETWORK_ERROR' || err.status === 429 || err.status >= 500) {
|
|
76
|
+
// A failure from the configuration (bad key, bad base URL) is said as it is, not rethrown raw.
|
|
77
|
+
if (err instanceof errors_js_1.HwipError && (0, http_failure_js_1.isConfigFailure)(err))
|
|
78
|
+
throw notSealed(err.message);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
throw notSealed('the device list could not be fetched');
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(listed))
|
|
84
|
+
throw notSealed('the device list could not be read');
|
|
85
|
+
if (!listed.some(crypto_js_1.isAppDevice)) {
|
|
86
|
+
throw new errors_js_1.HwipError('no app is signed in to this account: sign in to the Hwip app on an iPhone or Android phone, then send again', 'NO_APP_DEVICE', 400);
|
|
87
|
+
}
|
|
88
|
+
// A computer that runs no listener puts its own key on the list here, so the app can tell who sent the push. The
|
|
89
|
+
// push does not need it — the app reads it either way, and it is not sealed for this computer — so one that cannot
|
|
90
|
+
// be registered still goes, with one line.
|
|
91
|
+
const publicKey = (0, crypto_js_1.getDevicePublicKey)();
|
|
92
|
+
if (publicKey) {
|
|
93
|
+
const listing = await (0, self_register_js_1.ensureSelfListed)(listed, { deviceId: (0, listener_device_id_js_1.listenerDeviceId)(), publicKey }, (0, self_register_js_1.registerOverApi)(this.account));
|
|
94
|
+
if (!listing.ok)
|
|
95
|
+
console.error(`hwip: could not register this computer's key (${(0, self_register_js_1.registerWhyText)(listing.why)}) — the app cannot verify who sent this push`);
|
|
96
|
+
}
|
|
97
|
+
// At the HOME of the moment, not config's TRUST_FILE (fixed at import): `hwip trust` and the listener change it while this runs.
|
|
98
|
+
(0, trust_pins_js_1.observeDevices)((0, node_path_1.join)((0, node_os_1.homedir)(), '.hwip', 'trusted-devices.json'), listed, {
|
|
99
|
+
selfId: (0, listener_device_id_js_1.listenerDeviceId)(),
|
|
100
|
+
warn: (line) => console.error(`hwip: ${line}`),
|
|
101
|
+
});
|
|
102
|
+
const recipients = (0, crypto_js_1.selectRecipients)(listed);
|
|
103
|
+
if (recipients.length === 0)
|
|
104
|
+
throw notSealed('no device has a key to encrypt for');
|
|
105
|
+
return recipients;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A push to a channel's subscribers. Always plain text, whatever the account
|
|
109
|
+
* encrypts: the server fans a channel push out after dropping the sender's
|
|
110
|
+
* device keys, so a sealed one could not be opened by any subscriber (m7.md §3).
|
|
111
|
+
* It is also not an agent-session push, so no tmux session context is attached,
|
|
112
|
+
* and a long body goes inline for the server's own limit to judge — the file
|
|
113
|
+
* path would hand subscribers an attachment this CLI has no evidence they can open.
|
|
114
|
+
*/
|
|
115
|
+
async notifyChannel(payload) {
|
|
116
|
+
const json = await this.request('POST', '/pushes/send', payload);
|
|
117
|
+
const pushId = json.data?.pushId;
|
|
118
|
+
if (!pushId)
|
|
119
|
+
throw new errors_js_1.HwipError('Server returned no pushId', 'INVALID_RESPONSE', 500);
|
|
120
|
+
return { pushId };
|
|
121
|
+
}
|
|
122
|
+
/** The channels this account owns and follows (`GET /channels`, scope `channel:read`). */
|
|
123
|
+
async listChannels() {
|
|
124
|
+
const json = await this.request('GET', '/channels');
|
|
125
|
+
if (!Array.isArray(json.data))
|
|
126
|
+
throw new errors_js_1.HwipError('Server returned no channel list', 'INVALID_RESPONSE', 500);
|
|
127
|
+
return (0, channel_js_1.parseChannels)(json.data);
|
|
128
|
+
}
|
|
129
|
+
async notify(payload) {
|
|
130
|
+
if (payload.channelId)
|
|
131
|
+
return this.notifyChannel(payload);
|
|
132
|
+
// Attach the stable session key when in a tmux agent session so the push
|
|
133
|
+
// joins the agent chat. Merged into `payload` here so both the inline and
|
|
134
|
+
// file-upload (notifyWithFile) paths — which each spread `...payload` —
|
|
135
|
+
// carry it. Explicit caller values win.
|
|
136
|
+
const agentCtx = (0, agent_session_js_1.agentSessionContext)();
|
|
137
|
+
if (agentCtx)
|
|
138
|
+
payload = { ...agentCtx, ...payload };
|
|
139
|
+
const recipients = await this.audience();
|
|
140
|
+
const body = payload.body;
|
|
141
|
+
const bodyBytes = body ? new TextEncoder().encode(body).byteLength : 0;
|
|
142
|
+
const isLongBody = bodyBytes > BODY_FILE_THRESHOLD;
|
|
143
|
+
if (isLongBody && body) {
|
|
144
|
+
return this.notifyWithFile(payload, body, bodyBytes, recipients);
|
|
145
|
+
}
|
|
146
|
+
let sendPayload;
|
|
147
|
+
try {
|
|
148
|
+
const enc = await (0, crypto_js_1.encryptPushBodyForDevices)({ title: payload.title, body: payload.body, url: payload.url }, recipients);
|
|
149
|
+
// `url: undefined` alongside the title: it is already sealed inside the
|
|
150
|
+
// ciphertext, and on a link push it IS the payload. Spreading `payload`
|
|
151
|
+
// would leave the plaintext copy at the top level, handing the server the
|
|
152
|
+
// one thing `isEncrypted` promises it cannot see.
|
|
153
|
+
sendPayload = { ...payload, title: undefined, url: undefined, body: enc.body, isEncrypted: true, deviceKeyMap: enc.deviceKeyMap, senderPublicKey: enc.senderPublicKey };
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw notSealed('sealing the push failed');
|
|
157
|
+
}
|
|
158
|
+
const json = await this.request('POST', '/pushes/send', sendPayload);
|
|
159
|
+
const pushId = json.data?.pushId;
|
|
160
|
+
if (!pushId) {
|
|
161
|
+
throw new errors_js_1.HwipError('Server returned no pushId', 'INVALID_RESPONSE', 500);
|
|
162
|
+
}
|
|
163
|
+
return { pushId };
|
|
164
|
+
}
|
|
165
|
+
async notifyWithFile(payload, body, fileSize, recipients) {
|
|
166
|
+
const fileName = 'response.md';
|
|
167
|
+
const preview = body.length > PREVIEW_LENGTH ? body.slice(0, PREVIEW_LENGTH) + '...' : body;
|
|
168
|
+
// Encrypt the attachment and the push body together, before anything is
|
|
169
|
+
// uploaded. Doing them one at a time around the upload let a failure land
|
|
170
|
+
// in between and ship ciphertext under a push with no `isEncrypted` — an
|
|
171
|
+
// attachment no client would even try to open.
|
|
172
|
+
let encrypted;
|
|
173
|
+
try {
|
|
174
|
+
encrypted = {
|
|
175
|
+
file: await (0, crypto_js_1.encryptFileForDevices)(body, recipients),
|
|
176
|
+
push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: payload.title, body: preview, url: payload.url }, recipients),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw notSealed('sealing the push failed');
|
|
181
|
+
}
|
|
182
|
+
const upload = await this.requestUpload({ fileName, fileType: 'application/octet-stream', fileSize: encrypted.file.ciphertext.length });
|
|
183
|
+
await this.uploadToS3(upload.uploadUrl, encrypted.file.ciphertext, 'application/octet-stream');
|
|
184
|
+
const sendPayload = {
|
|
185
|
+
...payload,
|
|
186
|
+
title: undefined,
|
|
187
|
+
body: encrypted.push.body,
|
|
188
|
+
type: payload.type ?? 'file',
|
|
189
|
+
files: [{
|
|
190
|
+
fileKey: upload.fileKey,
|
|
191
|
+
fileName,
|
|
192
|
+
fileSize,
|
|
193
|
+
fileType: inferMimeType(fileName),
|
|
194
|
+
iv: encrypted.file.iv,
|
|
195
|
+
deviceKeyMap: encrypted.file.deviceKeyMap,
|
|
196
|
+
}],
|
|
197
|
+
// See notify: the url must not survive in the clear either.
|
|
198
|
+
url: undefined,
|
|
199
|
+
isEncrypted: true,
|
|
200
|
+
deviceKeyMap: encrypted.push.deviceKeyMap,
|
|
201
|
+
senderPublicKey: encrypted.push.senderPublicKey,
|
|
202
|
+
};
|
|
203
|
+
const json = await this.request('POST', '/pushes/send', sendPayload);
|
|
204
|
+
const pushId = json.data?.pushId;
|
|
205
|
+
if (!pushId) {
|
|
206
|
+
throw new errors_js_1.HwipError('Server returned no pushId', 'INVALID_RESPONSE', 500);
|
|
207
|
+
}
|
|
208
|
+
return { pushId, fileKey: upload.fileKey, autoFile: true };
|
|
209
|
+
}
|
|
210
|
+
async requestUpload(params) {
|
|
211
|
+
const json = await this.request('POST', '/files/upload-request', params);
|
|
212
|
+
return json.data;
|
|
213
|
+
}
|
|
214
|
+
async uploadToS3(url, content, contentType) {
|
|
215
|
+
const body = typeof content === 'string' ? content : new Uint8Array(content);
|
|
216
|
+
const response = await fetch(url, {
|
|
217
|
+
method: 'PUT',
|
|
218
|
+
headers: { 'Content-Type': (0, exports.uploadContentType)(contentType) },
|
|
219
|
+
body,
|
|
220
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
221
|
+
});
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
throw new errors_js_1.HwipError(`S3 upload failed with status ${response.status}`, 'UPLOAD_FAILED', response.status);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async list(params) {
|
|
227
|
+
const query = new URLSearchParams();
|
|
228
|
+
if (params?.limit)
|
|
229
|
+
query.set('limit', String(params.limit));
|
|
230
|
+
if (params?.type)
|
|
231
|
+
query.set('type', params.type);
|
|
232
|
+
const qs = query.toString();
|
|
233
|
+
const json = await this.request('GET', `/pushes${qs ? `?${qs}` : ''}`);
|
|
234
|
+
const pushes = json.data.map((p) => ({
|
|
235
|
+
pushId: p.pushId,
|
|
236
|
+
type: p.type,
|
|
237
|
+
// An encrypted push carries no plaintext title and a JSON envelope in
|
|
238
|
+
// `body`; slicing that renders `{"ciphertext":"AAAB...` as if it were the
|
|
239
|
+
// message. This host cannot decrypt (send-only), so say so instead.
|
|
240
|
+
title: p.isEncrypted ? undefined : p.title,
|
|
241
|
+
body: p.isEncrypted ? '[encrypted]' : p.body?.slice(0, 100),
|
|
242
|
+
isEncrypted: p.isEncrypted,
|
|
243
|
+
createdAt: p.createdAt,
|
|
244
|
+
}));
|
|
245
|
+
return { pushes, count: pushes.length, hasMore: json.pagination?.hasMore ?? false };
|
|
246
|
+
}
|
|
247
|
+
async dismiss(pushId) {
|
|
248
|
+
await this.request('POST', `/pushes/${encodeURIComponent(pushId)}/dismiss`);
|
|
249
|
+
return { dismissed: true };
|
|
250
|
+
}
|
|
251
|
+
async dismissAll() {
|
|
252
|
+
const json = await this.request('POST', '/pushes/dismiss-all');
|
|
253
|
+
return { dismissed: json.data?.dismissed ?? 0 };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Set (or clear, with an empty alias) the display name for an agent session,
|
|
257
|
+
* keyed by its tmux `name` on `deviceId`. The alias overrides the app's
|
|
258
|
+
* computed session label; it survives listener re-reports (server-side).
|
|
259
|
+
*/
|
|
260
|
+
async renameAgentSession(deviceId, name, alias) {
|
|
261
|
+
const res = await this.request('PATCH', `/devices/${encodeURIComponent(deviceId)}/agent-sessions/${encodeURIComponent(name)}`, { alias });
|
|
262
|
+
return { deviceId: res.data.deviceId };
|
|
263
|
+
}
|
|
264
|
+
/** The account's devices, with the agent sessions each listener reports. */
|
|
265
|
+
async listDevices() {
|
|
266
|
+
const json = await this.request('GET', '/devices');
|
|
267
|
+
// A missing list is a bad response, not an account with no devices — the
|
|
268
|
+
// latter would read as "unknown target" and hide the real failure.
|
|
269
|
+
if (!Array.isArray(json.data))
|
|
270
|
+
throw new errors_js_1.HwipError('Server returned no device list', 'INVALID_RESPONSE', 500);
|
|
271
|
+
return json.data;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Type `body` into the tmux session `agentSessionName` on `targetDeviceId`.
|
|
275
|
+
*
|
|
276
|
+
* Never through `notify`'s encryption: a sealed command is made by `sealedCommand` (command-seal.ts), which
|
|
277
|
+
* decides whether to seal at all, and arrives here as its fields. No `agentDeviceId` — the server groups by
|
|
278
|
+
* `agentDeviceId ?? targetDeviceId`, so it would file the push under a chat that is not the target's.
|
|
279
|
+
*/
|
|
280
|
+
async sendAgentCommand(payload) {
|
|
281
|
+
const json = await this.request('POST', '/pushes/send', { type: 'agent.command', ...payload });
|
|
282
|
+
const pushId = json.data?.pushId;
|
|
283
|
+
if (!pushId)
|
|
284
|
+
throw new errors_js_1.HwipError('Server returned no pushId', 'INVALID_RESPONSE', 500);
|
|
285
|
+
return { pushId };
|
|
286
|
+
}
|
|
287
|
+
async request(method, path, body) {
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
290
|
+
const headers = { 'X-API-Key': this.apiKey };
|
|
291
|
+
if (body)
|
|
292
|
+
headers['Content-Type'] = 'application/json';
|
|
293
|
+
const url = `${this.baseUrl}${path}`;
|
|
294
|
+
let response;
|
|
295
|
+
let json;
|
|
296
|
+
try {
|
|
297
|
+
response = await fetch(url, {
|
|
298
|
+
method,
|
|
299
|
+
headers,
|
|
300
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
301
|
+
signal: controller.signal,
|
|
302
|
+
// A redirect would carry the X-API-Key header to another host.
|
|
303
|
+
redirect: 'error',
|
|
304
|
+
});
|
|
305
|
+
json = await (0, http_failure_js_1.readJsonBody)(response);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
309
|
+
throw new errors_js_1.HwipError(`Request timed out after ${this.timeoutMs}ms`, 'TIMEOUT', 408);
|
|
310
|
+
}
|
|
311
|
+
// fetch reports every network failure as a TypeError; anything else is
|
|
312
|
+
// not "could not reach".
|
|
313
|
+
if (err instanceof TypeError)
|
|
314
|
+
throw new errors_js_1.HwipError((0, http_failure_js_1.networkFailureMessage)(err, url), 'NETWORK_ERROR', 0);
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
if (!response.ok) {
|
|
321
|
+
throw this.parseError(response.status, json);
|
|
322
|
+
}
|
|
323
|
+
// A 2xx that is not our JSON (a captive portal, a cut-off body) is a
|
|
324
|
+
// failed call, not an empty answer.
|
|
325
|
+
if (json === null) {
|
|
326
|
+
throw new errors_js_1.HwipError(`the server answered ${response.status} with a page that is not the API's (a captive portal, or a proxy in the way)`, 'INVALID_RESPONSE', response.status);
|
|
327
|
+
}
|
|
328
|
+
return json;
|
|
329
|
+
}
|
|
330
|
+
parseError(status, body) {
|
|
331
|
+
const message = (0, http_failure_js_1.httpFailureMessage)(status, body);
|
|
332
|
+
const code = body?.error?.code ?? 'UNKNOWN_ERROR';
|
|
333
|
+
if (status === 401)
|
|
334
|
+
return new errors_js_1.AuthenticationError(`${message} — ${http_failure_js_1.AUTH_HINT}`);
|
|
335
|
+
if (status === 403 && code === 'QUOTA_EXCEEDED')
|
|
336
|
+
return new errors_js_1.QuotaExceededError(message);
|
|
337
|
+
return new errors_js_1.HwipError(message, code, status);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
exports.HwipHook = HwipHook;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { HwipHook } from './hwip-hook.js';
|
|
2
|
+
export type { AgentTargetDevice } from './agent-target.js';
|
|
3
|
+
export { HwipError, AuthenticationError, QuotaExceededError } from './errors.js';
|
|
4
|
+
export type { HwipOptions, NotifyPayload, NotifyResult, ListParams, ListResult, DismissOneResult, DismissAllResult, PushItem } from './types.js';
|
|
5
|
+
export { decidePush, GATE_DEFAULTS, normalizeMarker, normalizePushMode } from './gate.js';
|
|
6
|
+
export type { GateInput, GateVerdict, GateMarker, GatePushMode } from './gate.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = exports.decidePush = exports.QuotaExceededError = exports.AuthenticationError = exports.HwipError = exports.HwipHook = void 0;
|
|
4
|
+
var hwip_hook_js_1 = require("./hwip-hook.js");
|
|
5
|
+
Object.defineProperty(exports, "HwipHook", { enumerable: true, get: function () { return hwip_hook_js_1.HwipHook; } });
|
|
6
|
+
var errors_js_1 = require("./errors.js");
|
|
7
|
+
Object.defineProperty(exports, "HwipError", { enumerable: true, get: function () { return errors_js_1.HwipError; } });
|
|
8
|
+
Object.defineProperty(exports, "AuthenticationError", { enumerable: true, get: function () { return errors_js_1.AuthenticationError; } });
|
|
9
|
+
Object.defineProperty(exports, "QuotaExceededError", { enumerable: true, get: function () { return errors_js_1.QuotaExceededError; } });
|
|
10
|
+
var gate_js_1 = require("./gate.js");
|
|
11
|
+
Object.defineProperty(exports, "decidePush", { enumerable: true, get: function () { return gate_js_1.decidePush; } });
|
|
12
|
+
Object.defineProperty(exports, "GATE_DEFAULTS", { enumerable: true, get: function () { return gate_js_1.GATE_DEFAULTS; } });
|
|
13
|
+
Object.defineProperty(exports, "normalizeMarker", { enumerable: true, get: function () { return gate_js_1.normalizeMarker; } });
|
|
14
|
+
Object.defineProperty(exports, "normalizePushMode", { enumerable: true, get: function () { return gate_js_1.normalizePushMode; } });
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound ordering guard for keys/text arriving over the ephemeral relay.
|
|
3
|
+
*
|
|
4
|
+
* The outbound stream stamps every frame with `seq` + `epoch` so the viewer
|
|
5
|
+
* can drop what it has already painted (see listener.ts buildStreamFrame's
|
|
6
|
+
* send site); this is the inverse for the inbound direction. A dropped or
|
|
7
|
+
* reordered keystroke is not a cosmetic glitch the way a stale frame is —
|
|
8
|
+
* it lands in a real editor buffer — so the rules differ:
|
|
9
|
+
*
|
|
10
|
+
* - in-order seq delivers immediately (typing latency is the whole point of
|
|
11
|
+
* this path; nothing is buffered on the happy path)
|
|
12
|
+
* - a gap holds later keys for INPUT_HOLD_MS, then delivers what arrived anyway.
|
|
13
|
+
* Approximate order beats dropped keys: the relay is at-most-once, so a
|
|
14
|
+
* never-arriving seq must not wedge everything behind it forever.
|
|
15
|
+
* - a new epoch (sender restart) resets the high-water mark, and the old
|
|
16
|
+
* epoch's held keys are discarded rather than typed into the new run
|
|
17
|
+
* - a seq at or below the high-water mark is a straggler the hold already
|
|
18
|
+
* flushed past — typing it now would put it out of order. The transport is
|
|
19
|
+
* at-most-once, so this is never a transport duplicate; the sender is told
|
|
20
|
+
* (`stale`) so it can decide whether the key still matters.
|
|
21
|
+
*
|
|
22
|
+
* Every outcome that drops a message is reported — as a non-'ok' AcceptResult
|
|
23
|
+
* for the message being accepted, or through `onDiscard` for held messages
|
|
24
|
+
* swept out by an epoch change or reset. The caller's contract ("every
|
|
25
|
+
* refusal reaches the sender") is only as honest as this accounting.
|
|
26
|
+
*/
|
|
27
|
+
/** How long a key waits for the gap in front of it to fill. */
|
|
28
|
+
export declare const INPUT_HOLD_MS = 500;
|
|
29
|
+
/** Ceiling on the reorder buffer. A gap that never fills lets every later key
|
|
30
|
+
* pile up for the whole hold, and the flush then delivers them back to back —
|
|
31
|
+
* one blocking tmux inject each. 32 is far past any real burst at
|
|
32
|
+
* INPUT_HOLD_MS, so this only bites on a stuck or hostile sender. */
|
|
33
|
+
export declare const MAX_PENDING_INPUTS = 32;
|
|
34
|
+
/** Non-'ok' means this message was dropped and the caller owes its sender a
|
|
35
|
+
* refusal: `overflow` (buffer full), `superseded` (older epoch than the
|
|
36
|
+
* current run), `stale` (at or below the high-water mark — the hold already
|
|
37
|
+
* flushed past it). 'ok' covers delivered AND held; held messages that are
|
|
38
|
+
* later swept out surface through `onDiscard` instead. */
|
|
39
|
+
export type AcceptResult = 'ok' | 'overflow' | 'superseded' | 'stale';
|
|
40
|
+
/** Cancels a pending flush. */
|
|
41
|
+
type CancelFlush = () => void;
|
|
42
|
+
/** Injectable timer so tests drive the hold deadline instead of sleeping. */
|
|
43
|
+
export type ScheduleFlush = (fn: () => void, ms: number) => CancelFlush;
|
|
44
|
+
export type SequencedInput = {
|
|
45
|
+
/** Sender-side monotonic counter within `epoch`. */
|
|
46
|
+
seq: number;
|
|
47
|
+
/** Sender incarnation — a restart bumps it and restarts `seq`. */
|
|
48
|
+
epoch: number;
|
|
49
|
+
};
|
|
50
|
+
export type InputSequencerOptions<T> = {
|
|
51
|
+
schedule?: ScheduleFlush;
|
|
52
|
+
/** Called for each HELD message dropped without delivery — an epoch change
|
|
53
|
+
* or reset() sweeping the buffer. Accept-time drops are reported via the
|
|
54
|
+
* AcceptResult instead, so no message can vanish through both cracks. */
|
|
55
|
+
onDiscard?: (msg: T) => void;
|
|
56
|
+
};
|
|
57
|
+
export type InputSequencer<T extends SequencedInput> = {
|
|
58
|
+
accept: (msg: T) => AcceptResult;
|
|
59
|
+
/** Drop everything held (reported via onDiscard) and forget the epoch —
|
|
60
|
+
* the run is over. */
|
|
61
|
+
reset: () => void;
|
|
62
|
+
};
|
|
63
|
+
export declare const createInputSequencer: <T extends SequencedInput>(deliver: (msg: T) => void, opts?: InputSequencerOptions<T>) => InputSequencer<T>;
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=input-sequencer.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Inbound ordering guard for keys/text arriving over the ephemeral relay.
|
|
4
|
+
*
|
|
5
|
+
* The outbound stream stamps every frame with `seq` + `epoch` so the viewer
|
|
6
|
+
* can drop what it has already painted (see listener.ts buildStreamFrame's
|
|
7
|
+
* send site); this is the inverse for the inbound direction. A dropped or
|
|
8
|
+
* reordered keystroke is not a cosmetic glitch the way a stale frame is —
|
|
9
|
+
* it lands in a real editor buffer — so the rules differ:
|
|
10
|
+
*
|
|
11
|
+
* - in-order seq delivers immediately (typing latency is the whole point of
|
|
12
|
+
* this path; nothing is buffered on the happy path)
|
|
13
|
+
* - a gap holds later keys for INPUT_HOLD_MS, then delivers what arrived anyway.
|
|
14
|
+
* Approximate order beats dropped keys: the relay is at-most-once, so a
|
|
15
|
+
* never-arriving seq must not wedge everything behind it forever.
|
|
16
|
+
* - a new epoch (sender restart) resets the high-water mark, and the old
|
|
17
|
+
* epoch's held keys are discarded rather than typed into the new run
|
|
18
|
+
* - a seq at or below the high-water mark is a straggler the hold already
|
|
19
|
+
* flushed past — typing it now would put it out of order. The transport is
|
|
20
|
+
* at-most-once, so this is never a transport duplicate; the sender is told
|
|
21
|
+
* (`stale`) so it can decide whether the key still matters.
|
|
22
|
+
*
|
|
23
|
+
* Every outcome that drops a message is reported — as a non-'ok' AcceptResult
|
|
24
|
+
* for the message being accepted, or through `onDiscard` for held messages
|
|
25
|
+
* swept out by an epoch change or reset. The caller's contract ("every
|
|
26
|
+
* refusal reaches the sender") is only as honest as this accounting.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.createInputSequencer = exports.MAX_PENDING_INPUTS = exports.INPUT_HOLD_MS = void 0;
|
|
30
|
+
/** How long a key waits for the gap in front of it to fill. */
|
|
31
|
+
exports.INPUT_HOLD_MS = 500;
|
|
32
|
+
/** Ceiling on the reorder buffer. A gap that never fills lets every later key
|
|
33
|
+
* pile up for the whole hold, and the flush then delivers them back to back —
|
|
34
|
+
* one blocking tmux inject each. 32 is far past any real burst at
|
|
35
|
+
* INPUT_HOLD_MS, so this only bites on a stuck or hostile sender. */
|
|
36
|
+
exports.MAX_PENDING_INPUTS = 32;
|
|
37
|
+
const defaultSchedule = (fn, ms) => {
|
|
38
|
+
const timer = setTimeout(fn, ms);
|
|
39
|
+
// A pending flush must not keep the daemon's event loop alive.
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
return () => clearTimeout(timer);
|
|
42
|
+
};
|
|
43
|
+
const createInputSequencer = (deliver, opts = {}) => {
|
|
44
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
45
|
+
let epoch = null;
|
|
46
|
+
let highWater = 0;
|
|
47
|
+
let cancelFlush = null;
|
|
48
|
+
/** The seq whose absence the armed deadline is waiting out. */
|
|
49
|
+
let holdingFor = 0;
|
|
50
|
+
const pending = new Map();
|
|
51
|
+
const arm = () => {
|
|
52
|
+
holdingFor = highWater + 1;
|
|
53
|
+
cancelFlush = schedule(flush, exports.INPUT_HOLD_MS);
|
|
54
|
+
};
|
|
55
|
+
const disarm = () => {
|
|
56
|
+
cancelFlush?.();
|
|
57
|
+
cancelFlush = null;
|
|
58
|
+
};
|
|
59
|
+
/** Sweep the hold buffer without delivering — each swept message is a
|
|
60
|
+
* keystroke its sender still waits on, so each is reported. */
|
|
61
|
+
const discardPending = () => {
|
|
62
|
+
const swept = [...pending.values()];
|
|
63
|
+
pending.clear();
|
|
64
|
+
disarm();
|
|
65
|
+
for (const msg of swept)
|
|
66
|
+
opts.onDiscard?.(msg);
|
|
67
|
+
};
|
|
68
|
+
const emit = (msg) => {
|
|
69
|
+
highWater = msg.seq;
|
|
70
|
+
deliver(msg);
|
|
71
|
+
};
|
|
72
|
+
/** Release the unbroken run sitting on top of the high-water mark. */
|
|
73
|
+
const drain = () => {
|
|
74
|
+
for (;;) {
|
|
75
|
+
const next = pending.get(highWater + 1);
|
|
76
|
+
if (!next)
|
|
77
|
+
break;
|
|
78
|
+
pending.delete(next.seq);
|
|
79
|
+
emit(next);
|
|
80
|
+
}
|
|
81
|
+
if (!pending.size) {
|
|
82
|
+
disarm();
|
|
83
|
+
}
|
|
84
|
+
else if (cancelFlush && highWater + 1 !== holdingFor) {
|
|
85
|
+
// The hole the deadline was for is filled and a later one is now
|
|
86
|
+
// first in line. Its deadline dates from now: inherited from the
|
|
87
|
+
// first gap, it fired while that run's keys were still arriving
|
|
88
|
+
// and flushed past them as stale — dropped letters.
|
|
89
|
+
disarm();
|
|
90
|
+
arm();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/** Hold expired: the missing seq is not coming. Deliver the rest in seq
|
|
94
|
+
* order, which also moves the high-water mark past the hole. */
|
|
95
|
+
const flush = () => {
|
|
96
|
+
cancelFlush = null;
|
|
97
|
+
const held = [...pending.values()].sort((a, b) => a.seq - b.seq);
|
|
98
|
+
pending.clear();
|
|
99
|
+
for (const msg of held)
|
|
100
|
+
emit(msg);
|
|
101
|
+
};
|
|
102
|
+
const accept = (msg) => {
|
|
103
|
+
if (epoch === null || msg.epoch > epoch) {
|
|
104
|
+
// First message of a run — whatever seq it carries is in order by
|
|
105
|
+
// definition, so joining a stream mid-flight costs no hold.
|
|
106
|
+
discardPending();
|
|
107
|
+
epoch = msg.epoch;
|
|
108
|
+
highWater = msg.seq - 1;
|
|
109
|
+
}
|
|
110
|
+
else if (msg.epoch < epoch) {
|
|
111
|
+
return 'superseded';
|
|
112
|
+
}
|
|
113
|
+
if (msg.seq <= highWater || pending.has(msg.seq))
|
|
114
|
+
return 'stale';
|
|
115
|
+
if (msg.seq === highWater + 1) {
|
|
116
|
+
emit(msg);
|
|
117
|
+
drain();
|
|
118
|
+
return 'ok';
|
|
119
|
+
}
|
|
120
|
+
// Drop the NEW message, not a held one: the buffer already holds the
|
|
121
|
+
// keys closest to the gap, and evicting those would reorder what is
|
|
122
|
+
// about to be typed.
|
|
123
|
+
if (pending.size >= exports.MAX_PENDING_INPUTS)
|
|
124
|
+
return 'overflow';
|
|
125
|
+
pending.set(msg.seq, msg);
|
|
126
|
+
// One deadline per gap: it dates from when the hole opened, so a
|
|
127
|
+
// steady stream of later keys can't extend the wait indefinitely —
|
|
128
|
+
// only filling the hole moves it on to the next one (drain).
|
|
129
|
+
if (!cancelFlush)
|
|
130
|
+
arm();
|
|
131
|
+
return 'ok';
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
accept,
|
|
135
|
+
reset: () => {
|
|
136
|
+
discardPending();
|
|
137
|
+
epoch = null;
|
|
138
|
+
highWater = 0;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
exports.createInputSequencer = createInputSequencer;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Whether `hostname` (from `URL.hostname`) is this machine. */
|
|
2
|
+
export declare const isLoopbackHost: (hostname: string) => boolean;
|
|
3
|
+
/** One line of warning for a URL that sends the key in the clear to a non-loopback host, else null. */
|
|
4
|
+
export declare const insecureUrlWarning: (url: string) => string | null;
|
|
5
|
+
/** `insecureUrlWarning` to stderr, once per URL per process (a command may build several clients). */
|
|
6
|
+
export declare const warnInsecureUrl: (url: string) => void;
|
|
7
|
+
/**
|
|
8
|
+
* Why the client will not fetch `url` as a file download, or null when it may.
|
|
9
|
+
* The URL comes from the API (`GET /files/<key>`), so a relay that rewrites
|
|
10
|
+
* the answer picks where this machine sends a GET: its own loopback services,
|
|
11
|
+
* the cloud metadata address, the LAN. Only S3 over `https` is a download
|
|
12
|
+
* address; a loopback one only when the API is loopback too (the local server
|
|
13
|
+
* hands out its LocalStack endpoint).
|
|
14
|
+
*/
|
|
15
|
+
export declare const downloadUrlProblem: (url: string, baseUrl: string) => string | null;
|
|
16
|
+
//# sourceMappingURL=insecure-url.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.downloadUrlProblem = exports.warnInsecureUrl = exports.insecureUrlWarning = exports.isLoopbackHost = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The API key goes wherever the base URL and the socket URL point. Over `https`
|
|
6
|
+
* and `wss` it is encrypted on the way; over plain `http` and `ws` anyone on the
|
|
7
|
+
* path reads it. Nothing forbids that — a dev server on the LAN is a real use —
|
|
8
|
+
* but a machine that does it unknowingly (a base URL typed without the `s`)
|
|
9
|
+
* should hear about it once.
|
|
10
|
+
*/
|
|
11
|
+
// `localhost.` (a trailing dot), the unspecified address and its IPv4-mapped loopback form are this machine too.
|
|
12
|
+
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;
|
|
13
|
+
/** Whether `hostname` (from `URL.hostname`) is this machine. */
|
|
14
|
+
const isLoopbackHost = (hostname) => LOOPBACK.test(hostname.replace(/\.$/, ''));
|
|
15
|
+
exports.isLoopbackHost = isLoopbackHost;
|
|
16
|
+
/** One line of warning for a URL that sends the key in the clear to a non-loopback host, else null. */
|
|
17
|
+
const insecureUrlWarning = (url) => {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = new URL(url);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if ((0, exports.isLoopbackHost)(parsed.hostname))
|
|
26
|
+
return null;
|
|
27
|
+
if (parsed.protocol === 'http:') {
|
|
28
|
+
return `hwip: ${parsed.host} is reached over plain http:// — your API key travels in the clear. Use an https:// base URL.`;
|
|
29
|
+
}
|
|
30
|
+
if (parsed.protocol === 'ws:') {
|
|
31
|
+
return `hwip: the WebSocket at ${parsed.host} is reached over plain ws:// — your API key travels in the clear. Use a wss:// URL.`;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
exports.insecureUrlWarning = insecureUrlWarning;
|
|
36
|
+
const warned = new Set();
|
|
37
|
+
/** `insecureUrlWarning` to stderr, once per URL per process (a command may build several clients). */
|
|
38
|
+
const warnInsecureUrl = (url) => {
|
|
39
|
+
const warning = (0, exports.insecureUrlWarning)(url);
|
|
40
|
+
if (!warning || warned.has(warning))
|
|
41
|
+
return;
|
|
42
|
+
warned.add(warning);
|
|
43
|
+
console.error(warning);
|
|
44
|
+
};
|
|
45
|
+
exports.warnInsecureUrl = warnInsecureUrl;
|
|
46
|
+
/**
|
|
47
|
+
* Why the client will not fetch `url` as a file download, or null when it may.
|
|
48
|
+
* The URL comes from the API (`GET /files/<key>`), so a relay that rewrites
|
|
49
|
+
* the answer picks where this machine sends a GET: its own loopback services,
|
|
50
|
+
* the cloud metadata address, the LAN. Only S3 over `https` is a download
|
|
51
|
+
* address; a loopback one only when the API is loopback too (the local server
|
|
52
|
+
* hands out its LocalStack endpoint).
|
|
53
|
+
*/
|
|
54
|
+
const downloadUrlProblem = (url, baseUrl) => {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(url);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return 'not a URL';
|
|
61
|
+
}
|
|
62
|
+
const host = parsed.hostname.replace(/\.$/, '').toLowerCase();
|
|
63
|
+
if (parsed.protocol === 'https:' && (host === 'amazonaws.com' || host.endsWith('.amazonaws.com')))
|
|
64
|
+
return null;
|
|
65
|
+
let apiHost = '';
|
|
66
|
+
try {
|
|
67
|
+
apiHost = new URL(baseUrl).hostname;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// An unreadable base URL is not a loopback one.
|
|
71
|
+
}
|
|
72
|
+
if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (0, exports.isLoopbackHost)(host) && (0, exports.isLoopbackHost)(apiHost))
|
|
73
|
+
return null;
|
|
74
|
+
return `${parsed.protocol}//${parsed.host} is not an S3 download address`;
|
|
75
|
+
};
|
|
76
|
+
exports.downloadUrlProblem = downloadUrlProblem;
|