@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
package/dist/config.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.legacyWsEnvNotice = exports.legacyWsEnv = exports.detectTmuxSessionName = exports.listenerDeviceId = exports.loadConfig = exports.claudeProjectDirName = exports.configModeProblem = exports.formatPushTitle = exports.detectProjectDir = exports.projectHash = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const os_1 = require("os");
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const url_check_js_1 = require("./url-check.js");
|
|
10
|
+
const DEFAULT_BASE_URL = 'https://api.hwip.app/v1';
|
|
11
|
+
/** The prod socket, used only with the prod API — the cli listener's rule (cli `src/ws-url.ts`):
|
|
12
|
+
* with any other base the stage is not known, and the wrong socket would split the agent. */
|
|
13
|
+
const DEFAULT_WS_URL = 'wss://ws.hwip.app';
|
|
14
|
+
const PROJECT_DIR_ENV_KEYS = ['CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'WINDSURF_PROJECT_DIR'];
|
|
15
|
+
/**
|
|
16
|
+
* Ceiling on any one command this module shells out to. Start-up waits on all
|
|
17
|
+
* of them, and a wedged tool (a hung tmux server, a stalled ioreg) used to hold
|
|
18
|
+
* the MCP server's start for as long as it hung. A timed-out command throws,
|
|
19
|
+
* which every caller already reads as "not available". Worst case with
|
|
20
|
+
* everything hung: cksum + ioreg + tmux + one ps, since a null `ps` ends the
|
|
21
|
+
* pane-ancestry walk.
|
|
22
|
+
*/
|
|
23
|
+
const SHELL_OUT_TIMEOUT_MS = 2_000;
|
|
24
|
+
/**
|
|
25
|
+
* `ioreg` is the exception: its answer seeds the device id, which must equal the
|
|
26
|
+
* one the cli listener computes. Giving up early would fall back to a different
|
|
27
|
+
* id and unthread `hwip_ask` from the agent chat, so it waits longer than the
|
|
28
|
+
* rest. cli bounds it at the same 5 s (listener-device-id.ts) and falls back the
|
|
29
|
+
* same way, so a stalled ioreg gives both the same hostname-hash id.
|
|
30
|
+
*/
|
|
31
|
+
const IOREG_TIMEOUT_MS = 5_000;
|
|
32
|
+
/** `projectHash` results by directory — the value cannot change within a process. */
|
|
33
|
+
const projectHashes = new Map();
|
|
34
|
+
/** The project directory the agent runs in, across supported agents. */
|
|
35
|
+
/**
|
|
36
|
+
* The key every per-project hwip file is named by. Shelling out to `cksum`
|
|
37
|
+
* (rather than a pure-JS CRC) is what guarantees it matches the files the bash
|
|
38
|
+
* hooks have already written — they key off `printf '%s' "$dir" | cksum`. Null
|
|
39
|
+
* when `cksum` is unavailable, which means no per-project file can be resolved.
|
|
40
|
+
*/
|
|
41
|
+
const projectHash = (dir) => {
|
|
42
|
+
const cached = projectHashes.get(dir);
|
|
43
|
+
if (cached)
|
|
44
|
+
return cached;
|
|
45
|
+
let hash = null;
|
|
46
|
+
try {
|
|
47
|
+
hash = (0, child_process_1.execFileSync)('cksum', { input: dir, encoding: 'utf-8', timeout: SHELL_OUT_TIMEOUT_MS }).split(' ')[0] || null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
hash = null;
|
|
51
|
+
}
|
|
52
|
+
// A failure is not cached: it may be a slow start, and every caller retries
|
|
53
|
+
// on the next call, as they always did.
|
|
54
|
+
if (hash !== null)
|
|
55
|
+
projectHashes.set(dir, hash);
|
|
56
|
+
return hash;
|
|
57
|
+
};
|
|
58
|
+
exports.projectHash = projectHash;
|
|
59
|
+
const detectProjectDir = () => {
|
|
60
|
+
for (const key of PROJECT_DIR_ENV_KEYS) {
|
|
61
|
+
const val = process.env[key];
|
|
62
|
+
if (val)
|
|
63
|
+
return val;
|
|
64
|
+
}
|
|
65
|
+
return process.cwd();
|
|
66
|
+
};
|
|
67
|
+
exports.detectProjectDir = detectProjectDir;
|
|
68
|
+
/** Last path segment of a directory: "/Users/me/code/hwip" -> "hwip". */
|
|
69
|
+
const projectNameFromDir = (dir) => dir.split('/').filter(Boolean).pop() ?? 'project';
|
|
70
|
+
/**
|
|
71
|
+
* Prefix a push title with the project name so the device feed stays
|
|
72
|
+
* scannable — "hwip · Build finished" instead of a bare "Build finished".
|
|
73
|
+
* Idempotent: a title already carrying the project segment is returned
|
|
74
|
+
* unchanged (guards against double-prefixing on retries).
|
|
75
|
+
*/
|
|
76
|
+
const formatPushTitle = (projectName, title) => {
|
|
77
|
+
const prefix = `${projectName} · `;
|
|
78
|
+
return title.startsWith(prefix) ? title : prefix + title;
|
|
79
|
+
};
|
|
80
|
+
exports.formatPushTitle = formatPushTitle;
|
|
81
|
+
const resolvedEnv = (key) => {
|
|
82
|
+
const val = process.env[key];
|
|
83
|
+
return val && !val.startsWith('${') ? val : undefined;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Why this machine should not trust the config file as it stands, or null — the
|
|
87
|
+
* rule of the cli's `configModeProblem` (src/config.ts), keep the two in step.
|
|
88
|
+
* The file holds the API key AND says where the key is sent (`baseUrl`, `wsUrl`),
|
|
89
|
+
* so one that another user owns, or that anyone can write, is a way to redirect
|
|
90
|
+
* the key. `refuse`: ignore the file; otherwise only warn. Group access to a file
|
|
91
|
+
* the user owns is not refused: a user-private group (umask 002, a dotfiles
|
|
92
|
+
* checkout) makes every file 664, and ignoring those would stop every session.
|
|
93
|
+
* Root may read what a user owns (sudo). This server only reads the file, so
|
|
94
|
+
* unlike the cli it does not narrow the mode itself.
|
|
95
|
+
*/
|
|
96
|
+
const configModeProblem = (st, myUid, file = (0, path_1.join)((0, os_1.homedir)(), '.hwip', 'config.json')) => {
|
|
97
|
+
const mode = (st.mode & 0o777).toString(8);
|
|
98
|
+
const envStillWorks = 'HWIP_API_KEY still works';
|
|
99
|
+
if (st.uid !== myUid && myUid !== 0) {
|
|
100
|
+
return { refuse: true, why: `${file} belongs to another user (uid ${st.uid}), so it is being ignored (${envStillWorks})` };
|
|
101
|
+
}
|
|
102
|
+
if (st.mode & 0o002) {
|
|
103
|
+
return { refuse: true, why: `${file} can be written by anyone (mode ${mode}), so it is being ignored — run \`chmod 600 ${file}\` (${envStillWorks})` };
|
|
104
|
+
}
|
|
105
|
+
if (st.mode & 0o064) {
|
|
106
|
+
return { refuse: false, why: `${file} can be reached by other users (mode ${mode}) and holds your API key — run \`chmod 600 ${file}\`` };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
exports.configModeProblem = configModeProblem;
|
|
111
|
+
/** Each message once per process: `loadConfig` and `legacyWsEnvNotice` both read the file. */
|
|
112
|
+
const toldOnce = new Set();
|
|
113
|
+
const sayOnce = (line) => {
|
|
114
|
+
if (toldOnce.has(line))
|
|
115
|
+
return;
|
|
116
|
+
toldOnce.add(line);
|
|
117
|
+
console.error(line);
|
|
118
|
+
};
|
|
119
|
+
/** `configModeProblem` for the file as it is now. A symlinked file (dotfiles) is judged by what it points at. Windows has no owner or mode to read. */
|
|
120
|
+
const configFileProblem = (file) => {
|
|
121
|
+
if (process.platform === 'win32' || typeof process.getuid !== 'function')
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
return (0, exports.configModeProblem)((0, fs_1.statSync)(file), process.getuid(), file);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const loadFileConfig = () => {
|
|
131
|
+
const file = (0, path_1.join)((0, os_1.homedir)(), '.hwip', 'config.json');
|
|
132
|
+
let bytes;
|
|
133
|
+
try {
|
|
134
|
+
bytes = (0, fs_1.readFileSync)(file);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err.code === 'ENOENT')
|
|
138
|
+
return { config: {}, problem: { kind: 'missing' } };
|
|
139
|
+
return { config: {}, problem: { kind: 'unreadable', detail: err.message } };
|
|
140
|
+
}
|
|
141
|
+
const modeProblem = configFileProblem(file);
|
|
142
|
+
if (modeProblem) {
|
|
143
|
+
sayOnce(`[hwip] ${modeProblem.why}.`);
|
|
144
|
+
if (modeProblem.refuse)
|
|
145
|
+
return { config: {}, problem: { kind: 'ignored', detail: modeProblem.why } };
|
|
146
|
+
}
|
|
147
|
+
// Not decoded leniently: bytes that are not UTF-8 would become U+FFFD and the rest of the file would still
|
|
148
|
+
// parse, key and server address included. Like the cli (`NotUtf8Error`), such a file is not used at all.
|
|
149
|
+
let text;
|
|
150
|
+
try {
|
|
151
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return { config: {}, problem: { kind: 'notUtf8' } };
|
|
155
|
+
}
|
|
156
|
+
// Nothing in it to lose, and what a crash in the cli's old plain write left behind: as the cli reads it, not an error.
|
|
157
|
+
if (text.trim() === '')
|
|
158
|
+
return { config: {} };
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(text);
|
|
161
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
162
|
+
return { config: {}, problem: { kind: 'notObject' } };
|
|
163
|
+
}
|
|
164
|
+
return { config: parsed };
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return { config: {}, problem: { kind: 'invalid', detail: err.message } };
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const FILE = '~/.hwip/config.json';
|
|
171
|
+
const noKeyReason = (problem) => {
|
|
172
|
+
switch (problem?.kind) {
|
|
173
|
+
case 'missing': return `${FILE} does not exist and HWIP_API_KEY is not set`;
|
|
174
|
+
case 'unreadable': return `${FILE} could not be read (${problem.detail})`;
|
|
175
|
+
// No `detail` for `invalid`: the parser's message can quote the text around the error, key included.
|
|
176
|
+
case 'invalid': return `${FILE} is not valid JSON`;
|
|
177
|
+
case 'notObject': return `${FILE} does not hold a JSON object`;
|
|
178
|
+
case 'notUtf8': return `${FILE} is not valid UTF-8 text`;
|
|
179
|
+
case 'ignored': return problem.detail;
|
|
180
|
+
default: return `${FILE} has no "apiKey"`;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Why a file that is not used is not used, for the stderr line when the key came from elsewhere: a
|
|
185
|
+
* file dropped in silence takes its `hookId` and `deviceId` with it, and the tool then blames a
|
|
186
|
+
* missing hookId. No `detail`: the parser's message can quote part of the file, key included.
|
|
187
|
+
* `missing` and `ignored` need no line (a file that is not there is normal; `ignored` said its own).
|
|
188
|
+
*/
|
|
189
|
+
const droppedFileReason = (problem) => {
|
|
190
|
+
switch (problem?.kind) {
|
|
191
|
+
case 'invalid':
|
|
192
|
+
case 'notObject':
|
|
193
|
+
case 'notUtf8':
|
|
194
|
+
return noKeyReason(problem);
|
|
195
|
+
case 'unreadable': return `${FILE} could not be read`;
|
|
196
|
+
default: return null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const SESSION_UUID_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
|
200
|
+
const CLAUDE_PROJECT_DIR_MAX = 200;
|
|
201
|
+
/**
|
|
202
|
+
* The directory name Claude Code gives a cwd under `~/.claude/projects/`.
|
|
203
|
+
* Copied from the Claude Code 2.1.287 binary (`k` / `Jx`): every character
|
|
204
|
+
* outside `[a-zA-Z0-9]` becomes `-`, so `.`, `_`, spaces, `@` and Hangul all
|
|
205
|
+
* collapse, one `-` per UTF-16 unit. Names past 200 characters are cut and
|
|
206
|
+
* suffixed with `-` plus the base-36 of a Java-style string hash of the cwd.
|
|
207
|
+
* Same rule and test vectors as the cli's `claudeProjectDirName`
|
|
208
|
+
* (cli/src/remote-agents.ts) — keep the two in step.
|
|
209
|
+
*/
|
|
210
|
+
const claudeProjectDirName = (cwd) => {
|
|
211
|
+
const name = cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
212
|
+
if (name.length <= CLAUDE_PROJECT_DIR_MAX)
|
|
213
|
+
return name;
|
|
214
|
+
let hash = 0;
|
|
215
|
+
for (let i = 0; i < cwd.length; i++)
|
|
216
|
+
hash = ((hash << 5) - hash + cwd.charCodeAt(i)) | 0;
|
|
217
|
+
return `${name.slice(0, CLAUDE_PROJECT_DIR_MAX)}-${Math.abs(hash).toString(36)}`;
|
|
218
|
+
};
|
|
219
|
+
exports.claudeProjectDirName = claudeProjectDirName;
|
|
220
|
+
/**
|
|
221
|
+
* Detect the Claude Code session ID for `projectDir`. Claude Code writes
|
|
222
|
+
* one transcript per session at
|
|
223
|
+
* `~/.claude/projects/<claudeProjectDirName(projectDir)>/<uuid>.jsonl`. We
|
|
224
|
+
* pick the most recently modified transcript — that's the live session.
|
|
225
|
+
*/
|
|
226
|
+
const detectClaudeSessionId = (projectDir) => {
|
|
227
|
+
try {
|
|
228
|
+
const sessionsDir = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'projects', (0, exports.claudeProjectDirName)(projectDir));
|
|
229
|
+
let latest;
|
|
230
|
+
for (const entry of (0, fs_1.readdirSync)(sessionsDir)) {
|
|
231
|
+
const match = SESSION_UUID_RE.exec(entry);
|
|
232
|
+
if (!match)
|
|
233
|
+
continue;
|
|
234
|
+
const stat = (0, fs_1.statSync)((0, path_1.join)(sessionsDir, entry));
|
|
235
|
+
if (!stat.isFile())
|
|
236
|
+
continue;
|
|
237
|
+
if (!latest || stat.mtimeMs > latest.mtime) {
|
|
238
|
+
latest = { name: match[1], mtime: stat.mtimeMs };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return latest?.name;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Truthy-env helper — accepts "1", "true", "yes", "on" (case-insensitive).
|
|
249
|
+
*/
|
|
250
|
+
const envIsTrue = (key) => {
|
|
251
|
+
const v = process.env[key];
|
|
252
|
+
if (!v)
|
|
253
|
+
return false;
|
|
254
|
+
return /^(1|true|yes|on)$/i.test(v.trim());
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Write the resolved session id to a per-user cache file so shell hooks
|
|
258
|
+
* (e.g. plugin/hooks/hwip-stop.sh) can pick it up when their own transcript
|
|
259
|
+
* scrape misses. The previous location was /tmp/hwip-session-<hash>, which
|
|
260
|
+
* on multi-user machines exposed a symlink race — predictable filename,
|
|
261
|
+
* world-writable directory. Living under ~/.cache (or $XDG_CACHE_HOME)
|
|
262
|
+
* removes that — only the owning user can write there. The file is also
|
|
263
|
+
* opened with O_NOFOLLOW so a pre-existing symlink can never redirect us
|
|
264
|
+
* to /etc/passwd or similar.
|
|
265
|
+
*
|
|
266
|
+
* Users can opt out entirely by setting HWIP_DISABLE_SESSION_CACHE=1.
|
|
267
|
+
* Useful for:
|
|
268
|
+
* - Read-only filesystems (some container runtimes)
|
|
269
|
+
* - CI runners where ~/.cache isn't persisted anyway, so the write is
|
|
270
|
+
* pure overhead
|
|
271
|
+
* - Sandboxed environments where extra filesystem writes trigger audit
|
|
272
|
+
* noise
|
|
273
|
+
* The shell hook still works without the cache — it primarily extracts
|
|
274
|
+
* the session id from the transcript_path UUID; the cache is just a
|
|
275
|
+
* fallback for older Claude Code versions.
|
|
276
|
+
*/
|
|
277
|
+
const writeSessionCache = (sessionId, projectDir) => {
|
|
278
|
+
if (envIsTrue('HWIP_DISABLE_SESSION_CACHE'))
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
const hash = (0, exports.projectHash)(projectDir);
|
|
282
|
+
if (!hash)
|
|
283
|
+
return;
|
|
284
|
+
const cacheDir = (0, path_1.join)(process.env.XDG_CACHE_HOME ?? (0, path_1.join)((0, os_1.homedir)(), '.cache'), 'hwip');
|
|
285
|
+
(0, fs_1.mkdirSync)(cacheDir, { recursive: true, mode: 0o700 });
|
|
286
|
+
const cachePath = (0, path_1.join)(cacheDir, `session-${hash}`);
|
|
287
|
+
const flags = fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_TRUNC | fs_1.constants.O_NOFOLLOW;
|
|
288
|
+
const fd = (0, fs_1.openSync)(cachePath, flags, 0o600);
|
|
289
|
+
try {
|
|
290
|
+
(0, fs_1.writeSync)(fd, sessionId);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
(0, fs_1.closeSync)(fd);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* best-effort — hook stop script also extracts session id from
|
|
298
|
+
* the transcript path, so failing here is non-fatal. */
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const warnInsecure = (url) => {
|
|
302
|
+
const warning = (0, url_check_js_1.insecureUrlWarning)(url);
|
|
303
|
+
if (warning)
|
|
304
|
+
sayOnce(warning);
|
|
305
|
+
};
|
|
306
|
+
/** `wsUrl` if it can be a socket URL, else undefined after saying why. Plain `ws://` off this machine is allowed, with a warning. */
|
|
307
|
+
const usableWsUrl = (wsUrl) => {
|
|
308
|
+
if (wsUrl === undefined || wsUrl === null)
|
|
309
|
+
return undefined;
|
|
310
|
+
const problem = (0, url_check_js_1.wsUrlProblem)(wsUrl);
|
|
311
|
+
if (problem) {
|
|
312
|
+
sayOnce(`[hwip] ${problem} — it is ignored.`);
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
warnInsecure(wsUrl);
|
|
316
|
+
return wsUrl;
|
|
317
|
+
};
|
|
318
|
+
/** `scheme://host[:port]` of a URL, or null when it is not one. Userinfo, path, query and fragment are not part of it. */
|
|
319
|
+
const originOf = (url) => {
|
|
320
|
+
try {
|
|
321
|
+
const { origin } = new URL(url);
|
|
322
|
+
return origin === 'null' ? null : origin;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Would `fetch` send this as a header value? Its rule, held to a real send in config.test.ts (Node 24.21,
|
|
330
|
+
* 2026-10-14): white space at either end is dropped, and what is left is a tab, printable ASCII or
|
|
331
|
+
* U+0080–U+00FF. A key that fails it fails every call — with an error that quotes it whole when what it
|
|
332
|
+
* has is a NUL, CR or LF (the place and the code point for one past U+00FF, the header's name for any
|
|
333
|
+
* other control character).
|
|
334
|
+
*/
|
|
335
|
+
const fitsInHeader = (value) => !/[^\t\u0020-\u007e\u0080-\u00ff]/.test(value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ''));
|
|
336
|
+
const loadConfig = () => {
|
|
337
|
+
const { config: fileConfig, problem } = loadFileConfig();
|
|
338
|
+
const envKey = resolvedEnv('HWIP_API_KEY');
|
|
339
|
+
const apiKey = envKey ?? fileConfig.apiKey; // the file's is typed as text, and is whatever its JSON holds
|
|
340
|
+
if (apiKey === undefined || apiKey === null || apiKey === '') {
|
|
341
|
+
throw new Error(`No API key: ${noKeyReason(problem)}. Run \`hwip login\` or \`hwip install\` (both write the file), or set HWIP_API_KEY.`);
|
|
342
|
+
}
|
|
343
|
+
// The file is JSON anyone may have edited: `"apiKey": 12345` is a mistake to name, as a base URL that is one is.
|
|
344
|
+
if (typeof apiKey !== 'string') {
|
|
345
|
+
throw new Error(`${FILE} has an "apiKey" that is not text. Set it again — \`hwip login\` writes the file, or set HWIP_API_KEY.`);
|
|
346
|
+
}
|
|
347
|
+
// M41: white space alone is not a key — it passes `fitsInHeader` (trimmed to nothing, nothing
|
|
348
|
+
// forbidden in nothing) and then fails every call as UNAUTHORIZED. Refused here, naming where.
|
|
349
|
+
if (apiKey.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, '') === '') {
|
|
350
|
+
throw new Error(`The API key in ${envKey ? 'HWIP_API_KEY' : FILE} is blank. Set it again — \`hwip login\` writes the file, or set HWIP_API_KEY.`);
|
|
351
|
+
}
|
|
352
|
+
if (!fitsInHeader(apiKey)) {
|
|
353
|
+
// Where it is, never what it is.
|
|
354
|
+
throw new Error(`The API key in ${envKey ? 'HWIP_API_KEY' : FILE} cannot be sent: it has a line break inside it, or a character no key has. Set it again — \`hwip login\` writes the file.`);
|
|
355
|
+
}
|
|
356
|
+
// Where the key goes, decided before anything is written: the cli's `resolveBaseUrl` rule. The file
|
|
357
|
+
// may hold a base URL the key belongs to (staging, self-hosted), and one that cannot be read must not
|
|
358
|
+
// send an environment key to the default API instead. A file ignored for its mode is not trusted for
|
|
359
|
+
// a base URL on purpose, and the default stands. (The mode is only read after the file was, so a file nobody can read is
|
|
360
|
+
// stopped on rather than ignored: the stricter side of the same rule.)
|
|
361
|
+
const envBase = resolvedEnv('HWIP_BASE_URL');
|
|
362
|
+
if (!envBase && (problem?.kind === 'invalid' || problem?.kind === 'notUtf8' || problem?.kind === 'notObject' || problem?.kind === 'unreadable')) {
|
|
363
|
+
throw new Error(`${noKeyReason(problem)}, so the API base URL it may hold is unknown — fix it, or move it aside, or set HWIP_BASE_URL.`);
|
|
364
|
+
}
|
|
365
|
+
if (!envBase && fileConfig.baseUrl !== undefined && typeof fileConfig.baseUrl !== 'string') { // null too, as in the cli
|
|
366
|
+
throw new Error(`${FILE} has a "baseUrl" that is not text, so where the key goes is unknown — fix it, or set HWIP_BASE_URL.`);
|
|
367
|
+
}
|
|
368
|
+
// M29: a key from the config file belongs to the addresses the file says (its `baseUrl`, else the default API).
|
|
369
|
+
// An address from the environment is not where it was issued for — an `export` in a shell profile, a project's
|
|
370
|
+
// env block — so the file's key does not follow it; the key given in the environment too, or the same origin, does.
|
|
371
|
+
const fileKeyHome = typeof fileConfig.baseUrl === 'string' && fileConfig.baseUrl ? fileConfig.baseUrl : DEFAULT_BASE_URL;
|
|
372
|
+
// An address with no origin (not a URL, `data:`) is not the same as anything, another such one included.
|
|
373
|
+
if (!envKey && envBase && (originOf(envBase) === null || originOf(envBase) !== originOf(fileKeyHome))) {
|
|
374
|
+
throw new Error(`HWIP_BASE_URL points at ${originOf(envBase) ?? 'something that is not a URL'}, but the API key in ${FILE} belongs to ` +
|
|
375
|
+
`${originOf(fileKeyHome) ?? 'an address it cannot read'}, so it is not sent there — set HWIP_API_KEY as well, or unset HWIP_BASE_URL.`);
|
|
376
|
+
}
|
|
377
|
+
const dropped = droppedFileReason(problem);
|
|
378
|
+
if (dropped)
|
|
379
|
+
sayOnce(`[hwip] ${dropped}, so it is being ignored.`);
|
|
380
|
+
const projectDir = (0, exports.detectProjectDir)();
|
|
381
|
+
// Claude Code names the running session outright; take it over the
|
|
382
|
+
// transcript scan, which picks the newest file in the project directory and
|
|
383
|
+
// so returns a sibling agent's id whenever two run in one project — the
|
|
384
|
+
// server then threads this session's pushes into the neighbour's chat.
|
|
385
|
+
const sessionId = resolvedEnv('HWIP_SESSION_ID') ??
|
|
386
|
+
resolvedEnv('CLAUDE_CODE_SESSION_ID') ??
|
|
387
|
+
detectClaudeSessionId(projectDir) ??
|
|
388
|
+
`sess_${(0, crypto_1.randomBytes)(12).toString('base64url')}`;
|
|
389
|
+
writeSessionCache(sessionId, projectDir);
|
|
390
|
+
// `||`, as the cli's resolveBaseUrl: an empty "baseUrl" is no URL and the default stands.
|
|
391
|
+
const baseUrl = (envBase || fileConfig.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
392
|
+
warnInsecure(baseUrl);
|
|
393
|
+
// The socket URL carries the key in its query, so the same rule holds for the one the environment can still supply.
|
|
394
|
+
let envWs = fileConfig.wsUrl === undefined ? (0, exports.legacyWsEnv)() : undefined;
|
|
395
|
+
if (envWs !== undefined && !envKey && !(baseUrl === DEFAULT_BASE_URL && originOf(envWs) === originOf(DEFAULT_WS_URL))) {
|
|
396
|
+
sayOnce(`[hwip] HWIP_WS_URL is not used: the API key in ${FILE} is not sent to an address from the environment — set HWIP_API_KEY as well, or put "wsUrl" in ${FILE}.`);
|
|
397
|
+
envWs = undefined;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
apiKey,
|
|
401
|
+
baseUrl,
|
|
402
|
+
// Config first, environment second — the inverse of what this used to
|
|
403
|
+
// be. `HWIP_WS_URL` sat ABOVE ~/.hwip/config.json, so an `export` in a
|
|
404
|
+
// shell profile silently outranked the file with nothing anywhere to
|
|
405
|
+
// say it had. It still answers when the file has nothing, so a
|
|
406
|
+
// self-hosted machine does not quietly lose its WebSocket fast path.
|
|
407
|
+
// A value that is not a socket URL is told and skipped, not carried into `new WebSocket`.
|
|
408
|
+
// The file wins whole: a bad value there is not handed to the environment's export,
|
|
409
|
+
// which `legacyWsEnvNotice` has just told the user does nothing.
|
|
410
|
+
wsUrl: (fileConfig.wsUrl !== undefined ? usableWsUrl(fileConfig.wsUrl) : usableWsUrl(envWs))
|
|
411
|
+
?? (baseUrl === DEFAULT_BASE_URL ? DEFAULT_WS_URL : undefined),
|
|
412
|
+
hookId: resolvedEnv('HWIP_HOOK_ID') ?? fileConfig.hookId,
|
|
413
|
+
deviceId: resolvedEnv('HWIP_DEVICE_ID') ?? fileConfig.deviceId,
|
|
414
|
+
sessionId,
|
|
415
|
+
agentDeviceId: (0, exports.listenerDeviceId)(),
|
|
416
|
+
agentSessionName: (0, exports.detectTmuxSessionName)(),
|
|
417
|
+
projectName: projectNameFromDir(projectDir),
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
exports.loadConfig = loadConfig;
|
|
421
|
+
const LISTENER_ID_FILE = (0, path_1.join)((0, os_1.homedir)(), '.hwip', 'listener-device-id');
|
|
422
|
+
const hashListenerId = (seed) => `dev_listener_${(0, crypto_1.createHash)('sha256').update(seed).digest('hex').slice(0, 8)}`;
|
|
423
|
+
/** ioreg by its absolute path: /usr/sbin is not on every PATH (an IDE or hook environment),
|
|
424
|
+
* and a failed lookup falls back to a hostname hash — another device id for the same Mac. */
|
|
425
|
+
const IOREG = '/usr/sbin/ioreg';
|
|
426
|
+
/** Platform machine id (macOS IOPlatformUUID / Linux machine-id), or null.
|
|
427
|
+
* Mirrors cli `readMachineId` so the hash below matches the listener's id. */
|
|
428
|
+
const readMachineId = () => {
|
|
429
|
+
try {
|
|
430
|
+
if (process.platform === 'darwin') {
|
|
431
|
+
const out = (0, child_process_1.execFileSync)(IOREG, ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf-8', timeout: IOREG_TIMEOUT_MS });
|
|
432
|
+
const m = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
|
433
|
+
if (m)
|
|
434
|
+
return m[1];
|
|
435
|
+
}
|
|
436
|
+
if (process.platform === 'linux') {
|
|
437
|
+
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
438
|
+
try {
|
|
439
|
+
const v = (0, fs_1.readFileSync)(p, 'utf-8').trim();
|
|
440
|
+
if (v)
|
|
441
|
+
return v;
|
|
442
|
+
}
|
|
443
|
+
catch { /* try next path */ }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch { /* no machine id readable — fall through */ }
|
|
448
|
+
return null;
|
|
449
|
+
};
|
|
450
|
+
/** Listener device id — MUST match cli `computeListenerDeviceId` so `hwip_ask`
|
|
451
|
+
* hooks land under the same agent-session key as the listener's
|
|
452
|
+
* agent.command/state pushes (otherwise they reach the feed but never thread
|
|
453
|
+
* into the agent chat). cli hashes the platform machine id first and falls
|
|
454
|
+
* back to a hostname hash pinned in a sticky file; mirror that order, reading
|
|
455
|
+
* (never writing) the file so both processes resolve the same id. */
|
|
456
|
+
const listenerDeviceId = () => {
|
|
457
|
+
const machineId = readMachineId();
|
|
458
|
+
if (machineId)
|
|
459
|
+
return hashListenerId(machineId);
|
|
460
|
+
try {
|
|
461
|
+
const saved = (0, fs_1.readFileSync)(LISTENER_ID_FILE, 'utf-8').trim();
|
|
462
|
+
if (/^dev_listener_[0-9a-f]{8}$/.test(saved))
|
|
463
|
+
return saved;
|
|
464
|
+
}
|
|
465
|
+
catch { /* no sticky file — fall back to hostname */ }
|
|
466
|
+
return hashListenerId((0, os_1.hostname)());
|
|
467
|
+
};
|
|
468
|
+
exports.listenerDeviceId = listenerDeviceId;
|
|
469
|
+
/** How far up the process tree the pane search walks. A pane's agent spawns
|
|
470
|
+
* this server as a child or grandchild; more hops than this means the match
|
|
471
|
+
* would be some unrelated ancestor, not the pane running the agent. */
|
|
472
|
+
const PANE_ANCESTRY_HOPS = 8;
|
|
473
|
+
/** `ps` for one pid's parent, or null at the top of the tree (or on error). */
|
|
474
|
+
const parentPid = (pid, run) => {
|
|
475
|
+
const out = run('ps', ['-o', 'ppid=', '-p', String(pid)]);
|
|
476
|
+
const parsed = Number(out?.trim());
|
|
477
|
+
return Number.isInteger(parsed) && parsed > 1 ? parsed : null;
|
|
478
|
+
};
|
|
479
|
+
const liveRunner = (cmd, args) => {
|
|
480
|
+
try {
|
|
481
|
+
return (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: SHELL_OUT_TIMEOUT_MS });
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* The tmux session whose pane is an ancestor of this process, for an agent that
|
|
489
|
+
* spawns its MCP servers with a sanitized environment.
|
|
490
|
+
*
|
|
491
|
+
* Codex CLI does exactly that — measured 2026-09-14, codex-cli 0.154.0: the
|
|
492
|
+
* `hwip mcp` child it starts inside a tmux pane has only HOME/PATH/LANG/TERM/
|
|
493
|
+
* USER/SHELL/TMPDIR/LOGNAME, no TMUX, and `shell_environment_policy.inherit=all`
|
|
494
|
+
* does not change it (that policy governs its shell tool, not MCP spawns). The
|
|
495
|
+
* env check alone therefore reported "not in tmux" for a session plainly running
|
|
496
|
+
* in one, the push lost its session key, and the phone opened the plain push
|
|
497
|
+
* screen instead of the agent chat with its live terminal.
|
|
498
|
+
*
|
|
499
|
+
* The pane's own pid IS in the tree, so ask tmux which pids own panes and walk
|
|
500
|
+
* up from here until one matches.
|
|
501
|
+
*/
|
|
502
|
+
const tmuxSessionByPaneAncestry = (run) => {
|
|
503
|
+
const panes = run('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #S']);
|
|
504
|
+
if (!panes)
|
|
505
|
+
return undefined;
|
|
506
|
+
const sessionByPanePid = new Map();
|
|
507
|
+
for (const line of panes.split('\n')) {
|
|
508
|
+
const [pid, ...rest] = line.trim().split(' ');
|
|
509
|
+
const name = rest.join(' ').trim();
|
|
510
|
+
if (pid && name)
|
|
511
|
+
sessionByPanePid.set(Number(pid), name);
|
|
512
|
+
}
|
|
513
|
+
let pid = process.pid;
|
|
514
|
+
for (let hop = 0; hop < PANE_ANCESTRY_HOPS && pid !== null; hop += 1) {
|
|
515
|
+
const name = sessionByPanePid.get(pid);
|
|
516
|
+
if (name)
|
|
517
|
+
return name;
|
|
518
|
+
pid = parentPid(pid, run);
|
|
519
|
+
}
|
|
520
|
+
return undefined;
|
|
521
|
+
};
|
|
522
|
+
/** The tmux session name the agent runs in (`hwip-<project>`) — stable half of
|
|
523
|
+
* the session key. Undefined outside tmux; the hook then stays feed-only. */
|
|
524
|
+
const detectTmuxSessionName = (run = liveRunner) => {
|
|
525
|
+
if (process.env.TMUX) {
|
|
526
|
+
const name = run('tmux', ['display-message', '-p', '#S'])?.trim();
|
|
527
|
+
if (name)
|
|
528
|
+
return name;
|
|
529
|
+
}
|
|
530
|
+
return tmuxSessionByPaneAncestry(run);
|
|
531
|
+
};
|
|
532
|
+
exports.detectTmuxSessionName = detectTmuxSessionName;
|
|
533
|
+
/**
|
|
534
|
+
* A one-line notice for a machine that still exports `HWIP_WS_URL`, or null.
|
|
535
|
+
*
|
|
536
|
+
* The variable is no longer read. Dropping it in silence would repeat the
|
|
537
|
+
* failure it caused — a value that looks like it is in effect while something
|
|
538
|
+
* else decides — so it is read here only to say it is being ignored.
|
|
539
|
+
*/
|
|
540
|
+
const legacyWsEnv = (env = process.env) => {
|
|
541
|
+
const value = env.HWIP_WS_URL;
|
|
542
|
+
// Same guard resolvedEnv applies: an unexpanded "${HWIP_WS_URL}" out of an
|
|
543
|
+
// agent's env block is a placeholder, not a value.
|
|
544
|
+
return value && !value.startsWith('${') ? value : undefined;
|
|
545
|
+
};
|
|
546
|
+
exports.legacyWsEnv = legacyWsEnv;
|
|
547
|
+
/**
|
|
548
|
+
* What to tell a machine that still exports `HWIP_WS_URL`, or null. Two
|
|
549
|
+
* messages, because the two situations differ: an export beside a config file
|
|
550
|
+
* is dead weight, while an export on its own is still carrying the value.
|
|
551
|
+
*/
|
|
552
|
+
const legacyWsEnvNotice = (
|
|
553
|
+
// The file's own value, not the resolved one: that is the export's when the file has none.
|
|
554
|
+
fileConfig = loadFileConfig().config, env = process.env) => {
|
|
555
|
+
if (!(0, exports.legacyWsEnv)(env))
|
|
556
|
+
return null;
|
|
557
|
+
return fileConfig.wsUrl
|
|
558
|
+
? '[Config] HWIP_WS_URL is set but ~/.hwip/config.json wins now — the export does nothing and can go.'
|
|
559
|
+
: '[Config] HWIP_WS_URL is deprecated and will stop being read — move the value to "wsUrl" in ~/.hwip/config.json.';
|
|
560
|
+
};
|
|
561
|
+
exports.legacyWsEnvNotice = legacyWsEnvNotice;
|