@hs-x/mcp 0.3.6 → 0.3.7

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/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { request as httpsRequest } from 'node:https';
2
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { validateProject } from '@hs-x/validator';
5
6
  import { Redacted } from 'effect';
6
- import { loadMcpConfig } from './config.js';
7
+ import { isPrivateOrLoopbackHostname, loadMcpConfig } from './config.js';
7
8
  /**
8
9
  * serverInfo.version is read from this package's own package.json at runtime —
9
10
  * never hardcoded. (Hardcoded version constants shipped stale repeatedly:
@@ -31,6 +32,8 @@ export const MCP_PROTOCOL_VERSION = '2024-11-05';
31
32
  const rootArgSchema = {
32
33
  type: 'string',
33
34
  description: 'Project root. Defaults to the MCP server working directory.',
35
+ minLength: 1,
36
+ maxLength: 4096,
34
37
  };
35
38
  const tools = [
36
39
  {
@@ -38,6 +41,7 @@ const tools = [
38
41
  description: 'Return local HS-X project status using the same validation substrate as the CLI.',
39
42
  inputSchema: {
40
43
  type: 'object',
44
+ additionalProperties: false,
41
45
  properties: {
42
46
  root: rootArgSchema,
43
47
  },
@@ -48,79 +52,50 @@ const tools = [
48
52
  description: 'Run hs-x check for a local project and return structured diagnostics.',
49
53
  inputSchema: {
50
54
  type: 'object',
55
+ additionalProperties: false,
51
56
  properties: {
52
57
  root: rootArgSchema,
53
58
  },
54
59
  },
55
60
  },
56
61
  {
57
- name: 'hsx.dev.capabilities',
58
- description: 'List the workers and capabilities (tools, triggers, card backends, syncs) declared by a local HS-X project — the targets hsx.dev.invoke accepts.',
59
- inputSchema: {
60
- type: 'object',
61
- properties: {
62
- root: rootArgSchema,
63
- },
64
- },
65
- },
66
- {
67
- name: 'hsx.dev.invoke',
68
- description: 'Invoke one capability of a local HS-X project through the PRODUCTION runtime router (payload validation, context, handler, result envelope — exactly what a deployed Worker runs). Fixture defaults fill the dispatch payload; pass input/enrolledObject/install to override. Works on freshly-migrated apps.',
69
- inputSchema: {
70
- type: 'object',
71
- properties: {
72
- root: rootArgSchema,
73
- capabilityId: { type: 'string', description: 'Capability id (see hsx.dev.capabilities).' },
74
- input: { type: 'object', description: 'Handler input (default: {}).' },
75
- enrolledObject: {
76
- type: 'object',
77
- description: 'Enrolled object {id, objectType, properties} (default: fixture).',
78
- },
79
- install: { type: 'object', description: 'Install identity (default: local fixture).' },
80
- },
81
- },
82
- },
83
- {
84
- name: 'hsx.dev.invoke_last',
85
- description: 'Replay the last recorded invocation for a local HS-X project (.hs-x/last-invocation.json) — the failure-replay loop.',
62
+ name: 'hsx.dev.session.status',
63
+ description: 'Health + identity of an existing HS-X project dev session.',
86
64
  inputSchema: {
87
65
  type: 'object',
88
- properties: {
89
- root: rootArgSchema,
90
- },
66
+ additionalProperties: false,
67
+ properties: { root: rootArgSchema },
91
68
  },
92
69
  },
93
70
  {
94
- name: 'hsx.dev.session.start',
95
- description: 'Start a dev server for a local HS-X project (background process; logs to .hs-x/dev-server.log). Returns the URL once /_hsx/health responds. One session per project.',
71
+ name: 'hsx.dev.session.stop',
72
+ description: 'Stop the project dev session (SIGTERM to the recorded pid).',
96
73
  inputSchema: {
97
74
  type: 'object',
98
- properties: {
99
- root: rootArgSchema,
100
- port: { type: 'number', description: 'Port (default 8787).' },
101
- },
75
+ additionalProperties: false,
76
+ properties: { root: rootArgSchema },
102
77
  },
103
78
  },
104
- {
105
- name: 'hsx.dev.session.status',
106
- description: 'Health + identity of the project dev session started via hsx.dev.session.start.',
107
- inputSchema: { type: 'object', properties: { root: rootArgSchema } },
108
- },
109
- {
110
- name: 'hsx.dev.session.stop',
111
- description: 'Stop the project dev session (SIGTERM to the recorded pid).',
112
- inputSchema: { type: 'object', properties: { root: rootArgSchema } },
113
- },
114
79
  {
115
80
  name: 'hsx.dev.logs',
116
81
  description: 'The project log surface: tails the dev session log (.hs-x/dev-server.log) AND, when the project has a deployed HubSpot app binding, includes HubSpot app-log rows (card/extension renders, proxy executions, webhooks, OAuth) — the same merged timeline as `hs-x logs`.',
117
82
  inputSchema: {
118
83
  type: 'object',
84
+ additionalProperties: false,
119
85
  properties: {
120
86
  root: rootArgSchema,
121
- lines: { type: 'number', description: 'Trailing local lines (default 50).' },
87
+ lines: {
88
+ type: 'integer',
89
+ minimum: 1,
90
+ maximum: 1000,
91
+ default: 50,
92
+ description: 'Trailing local lines (default 50, max 1000).',
93
+ },
122
94
  sinceMinutes: {
123
- type: 'number',
95
+ type: 'integer',
96
+ minimum: 1,
97
+ maximum: 10_080,
98
+ default: 15,
124
99
  description: 'HubSpot app-log lookback window in minutes (default 15, max 10080).',
125
100
  },
126
101
  },
@@ -128,17 +103,22 @@ const tools = [
128
103
  },
129
104
  {
130
105
  name: 'hsx.secrets.hubspot_oauth.set',
131
- description: 'Store a HubSpot app OAuth client secret for one HS-X account/project/environment/app.',
106
+ description: 'Store a HubSpot app OAuth client secret for one HS-X account/project/environment/app. This credential mutation requires confirm: true.',
132
107
  inputSchema: {
133
108
  type: 'object',
109
+ additionalProperties: false,
110
+ required: ['accountId', 'projectId', 'hubSpotAppId', 'clientId', 'clientSecret', 'confirm'],
134
111
  properties: {
135
- accountId: { type: 'string' },
136
- projectId: { type: 'string' },
137
- hubSpotAppId: { type: 'number' },
138
- clientId: { type: 'string' },
139
- clientSecret: { type: 'string' },
112
+ accountId: { type: 'string', minLength: 1, maxLength: 128, pattern: '\\S' },
113
+ projectId: { type: 'string', minLength: 1, maxLength: 128, pattern: '\\S' },
114
+ hubSpotAppId: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
115
+ clientId: { type: 'string', minLength: 1, maxLength: 512, pattern: '\\S' },
116
+ clientSecret: { type: 'string', minLength: 1, maxLength: 4096, pattern: '\\S' },
140
117
  environment: { type: 'string', enum: ['dev', 'staging', 'production'] },
141
- controlPlaneUrl: { type: 'string' },
118
+ confirm: {
119
+ type: 'boolean',
120
+ description: 'Must be true to explicitly confirm this credential mutation before HS-X reads a bearer credential or sends a network request.',
121
+ },
142
122
  },
143
123
  },
144
124
  },
@@ -147,12 +127,23 @@ const tools = [
147
127
  description: 'Search the HS-X documentation (guides, answers, reference) by keyword. Returns ranked pages with title, section, the markdown URL, and a summary. Follow up with hsx.docs.fetch to read a page in full.',
148
128
  inputSchema: {
149
129
  type: 'object',
130
+ additionalProperties: false,
131
+ required: ['query'],
150
132
  properties: {
151
133
  query: {
152
134
  type: 'string',
135
+ minLength: 1,
136
+ maxLength: 512,
137
+ pattern: '\\S',
153
138
  description: 'Keywords to search for, e.g. "webhook signature dedup".',
154
139
  },
155
- limit: { type: 'number', description: 'Max results (default 8, max 25).' },
140
+ limit: {
141
+ type: 'integer',
142
+ minimum: 1,
143
+ maximum: 25,
144
+ default: 8,
145
+ description: 'Max results (default 8, max 25).',
146
+ },
156
147
  },
157
148
  },
158
149
  },
@@ -161,8 +152,16 @@ const tools = [
161
152
  description: 'Fetch one HS-X documentation page as clean markdown. Pass a docs path or URL from hsx.docs.search (e.g. "/docs/guides/triggers" or "/docs/guides/triggers.md").',
162
153
  inputSchema: {
163
154
  type: 'object',
155
+ additionalProperties: false,
156
+ required: ['path'],
164
157
  properties: {
165
- path: { type: 'string', description: 'Docs path or URL (with or without the .md suffix).' },
158
+ path: {
159
+ type: 'string',
160
+ minLength: 1,
161
+ maxLength: 2048,
162
+ pattern: '\\S',
163
+ description: 'Docs path or URL (with or without the .md suffix).',
164
+ },
166
165
  },
167
166
  },
168
167
  },
@@ -170,7 +169,50 @@ const tools = [
170
169
  export function listMcpTools() {
171
170
  return tools;
172
171
  }
173
- export async function handleMcpRequest(request, context) {
172
+ class JsonRpcError extends Error {
173
+ code;
174
+ constructor(code, message) {
175
+ super(message);
176
+ this.code = code;
177
+ }
178
+ }
179
+ function decodeJsonRpcRequest(input) {
180
+ if (!isRecord(input) || Array.isArray(input)) {
181
+ return { ok: false, message: 'Invalid Request: expected a JSON-RPC request object.' };
182
+ }
183
+ if (input.jsonrpc !== '2.0') {
184
+ return { ok: false, message: 'Invalid Request: jsonrpc must be "2.0".' };
185
+ }
186
+ if (typeof input.method !== 'string' || input.method.length === 0) {
187
+ return { ok: false, message: 'Invalid Request: method must be a non-empty string.' };
188
+ }
189
+ if (input.id !== undefined &&
190
+ !(typeof input.id === 'string' ||
191
+ (typeof input.id === 'number' && Number.isSafeInteger(input.id)))) {
192
+ return { ok: false, message: 'Invalid Request: id must be a string or integer.' };
193
+ }
194
+ if (input.params !== undefined && (!isRecord(input.params) || Array.isArray(input.params))) {
195
+ return { ok: false, message: 'Invalid Request: params must be an object when provided.' };
196
+ }
197
+ return {
198
+ ok: true,
199
+ request: {
200
+ jsonrpc: '2.0',
201
+ method: input.method,
202
+ ...(input.id !== undefined ? { id: input.id } : {}),
203
+ ...(input.params !== undefined ? { params: input.params } : {}),
204
+ },
205
+ };
206
+ }
207
+ function rpcFailure(id, code, message) {
208
+ return { jsonrpc: '2.0', id, error: { code, message } };
209
+ }
210
+ export async function handleMcpRequest(input, context) {
211
+ const decoded = decodeJsonRpcRequest(input);
212
+ if (!decoded.ok) {
213
+ return rpcFailure(null, -32600, decoded.message);
214
+ }
215
+ const request = decoded.request;
174
216
  try {
175
217
  const result = await dispatchMcpRequest(request, context);
176
218
  if (request.id === undefined) {
@@ -182,18 +224,19 @@ export async function handleMcpRequest(request, context) {
182
224
  if (request.id === undefined) {
183
225
  return undefined;
184
226
  }
185
- return {
186
- jsonrpc: '2.0',
187
- id: request.id ?? null,
188
- error: {
189
- code: -32000,
190
- message: error instanceof Error ? error.message : String(error),
191
- },
192
- };
227
+ const code = error instanceof JsonRpcError ? error.code : -32000;
228
+ return rpcFailure(request.id ?? null, code, error instanceof Error ? error.message : String(error));
193
229
  }
194
230
  }
195
231
  export async function callMcpTool(name, args, context) {
196
- const root = projectRoot(args, context);
232
+ const tool = tools.find((candidate) => candidate.name === name);
233
+ if (!tool)
234
+ throw new Error(`Unknown MCP tool: ${name}`);
235
+ validateToolArguments(tool, args);
236
+ if (name === 'hsx.secrets.hubspot_oauth.set' && args.confirm !== true) {
237
+ throw new Error('hsx.secrets.hubspot_oauth.set requires confirm: true before storing OAuth credentials.');
238
+ }
239
+ const root = await projectRoot(args, context);
197
240
  if (name === 'hsx.status') {
198
241
  const validation = await validateProject({ root });
199
242
  const diagnosticsBySeverity = { error: 0, warning: 0 };
@@ -215,6 +258,7 @@ export async function callMcpTool(name, args, context) {
215
258
  });
216
259
  }
217
260
  if (name === 'hsx.dev.capabilities') {
261
+ requireUnsafeProjectExecutionOptIn(context, name);
218
262
  const { loadProjectWorkers, DevInvokeError } = await import('@hs-x/cli/dev/invoke');
219
263
  try {
220
264
  const workers = await loadProjectWorkers(root);
@@ -236,6 +280,7 @@ export async function callMcpTool(name, args, context) {
236
280
  }
237
281
  }
238
282
  if (name === 'hsx.dev.invoke' || name === 'hsx.dev.invoke_last') {
283
+ requireUnsafeProjectExecutionOptIn(context, name);
239
284
  const { invokeCapabilityLocally, loadLastInvocation, DevInvokeError } = await import('@hs-x/cli/dev/invoke');
240
285
  let capabilityId;
241
286
  let payloadOverrides = {};
@@ -283,74 +328,119 @@ export async function callMcpTool(name, args, context) {
283
328
  }
284
329
  }
285
330
  if (name === 'hsx.dev.session.start') {
286
- const port = typeof args.port === 'number' ? args.port : 8787;
331
+ requireUnsafeProjectExecutionOptIn(context, name);
332
+ const port = optionalPort(args.port);
287
333
  const existing = await readDevSession(root);
288
- if (existing && (await devSessionHealthy(existing.port))) {
289
- return content({ ok: true, command: 'dev session start', alreadyRunning: true, ...existing });
334
+ if (existing) {
335
+ if ((await devSessionMatchesProcess(existing, root)) &&
336
+ (await devSessionHealthy(existing.port))) {
337
+ return content({
338
+ ok: true,
339
+ command: 'dev session start',
340
+ alreadyRunning: true,
341
+ ...(await devSessionView(root, existing)),
342
+ });
343
+ }
344
+ throw new Error('The dev-session record does not identify a live HS-X dev process for this project. Refusing to overwrite it; verify the process, then remove .hs-x/dev-session.json manually.');
290
345
  }
291
346
  const { spawn } = await import('node:child_process');
292
- const { mkdir } = await import('node:fs/promises');
293
- const { createWriteStream } = await import('node:fs');
294
- const { join } = await import('node:path');
295
- await mkdir(join(root, '.hs-x'), { recursive: true });
296
- const logPath = join(root, '.hs-x', 'dev-server.log');
297
- const log = createWriteStream(logPath, { flags: 'a' });
298
347
  // HSX_MCP_DEV_BINARY lets tests point at the repo CLI; default = installed hs-x.
299
348
  const binary = process.env.HSX_MCP_DEV_BINARY ?? 'hs-x';
300
349
  const [command, ...prefixArgs] = binary.split(' ');
301
- const child = spawn(command, [...prefixArgs, 'dev', '--cwd', root, '--port', String(port)], {
302
- detached: true,
303
- stdio: ['ignore', 'pipe', 'pipe'],
350
+ if (!command)
351
+ throw new Error('HSX_MCP_DEV_BINARY must name an executable.');
352
+ const { logPath, stream: log } = await openDevLogForAppend(root);
353
+ const child = (() => {
354
+ try {
355
+ return spawn(command, [...prefixArgs, 'dev', '--cwd', root, '--port', String(port)], {
356
+ detached: true,
357
+ stdio: ['ignore', 'pipe', 'pipe'],
358
+ });
359
+ }
360
+ catch (error) {
361
+ log.end();
362
+ throw error;
363
+ }
364
+ })();
365
+ let spawnError;
366
+ child.once('error', (error) => {
367
+ spawnError = error;
304
368
  });
305
369
  child.stdout?.pipe(log);
306
370
  child.stderr?.pipe(log);
307
371
  child.unref();
308
- const session = { pid: child.pid ?? 0, port, url: `http://127.0.0.1:${port}`, logPath };
309
- await writeDevSession(root, session);
372
+ const childPid = child.pid;
373
+ if (!Number.isSafeInteger(childPid) || (childPid ?? 0) <= 0) {
374
+ log.end();
375
+ throw new Error('Could not start the HS-X dev process: no safe child pid was assigned.');
376
+ }
377
+ const session = { schemaVersion: 1, pid: childPid, port };
378
+ try {
379
+ await writeDevSession(root, session);
380
+ }
381
+ catch (error) {
382
+ child.kill('SIGTERM');
383
+ log.end();
384
+ throw error;
385
+ }
310
386
  // A source-loaded CLI can take longer to boot while the full CI suite is
311
387
  // saturating the runner. Keep polling for 30s before declaring failure.
312
388
  for (let attempt = 0; attempt < 120; attempt += 1) {
389
+ if (spawnError) {
390
+ await removeDevSession(root);
391
+ log.end();
392
+ throw new Error(`Could not start the HS-X dev process: ${spawnError.message}`);
393
+ }
313
394
  if (await devSessionHealthy(port)) {
314
- return content({ ok: true, command: 'dev session start', ...session });
395
+ return content({
396
+ ok: true,
397
+ command: 'dev session start',
398
+ ...(await devSessionView(root, session)),
399
+ });
315
400
  }
316
401
  await new Promise((resolve) => setTimeout(resolve, 250));
317
402
  }
318
- const { readFile } = await import('node:fs/promises');
319
- const logTail = await readFile(logPath, 'utf8')
320
- .then((raw) => raw.split('\n').filter(Boolean).slice(-10).join('\n'))
321
- .catch(() => '');
403
+ child.kill('SIGTERM');
404
+ await removeDevSession(root);
405
+ log.end();
406
+ const logTail = (await readBoundedLogTail(root, 10)).join('\n');
322
407
  throw new Error(`Dev server did not become healthy on port ${port} within 30s — read the log via hsx.dev.logs.${logTail ? `\n${logTail}` : ''}`);
323
408
  }
324
409
  if (name === 'hsx.dev.session.status') {
325
410
  const session = await readDevSession(root);
326
411
  if (!session)
327
412
  return content({ ok: false, command: 'dev session status', running: false });
328
- const healthy = await devSessionHealthy(session.port);
329
- return content({ ok: healthy, command: 'dev session status', running: healthy, ...session });
413
+ const processMatches = await devSessionMatchesProcess(session, root);
414
+ const healthy = processMatches && (await devSessionHealthy(session.port));
415
+ return content({
416
+ ok: healthy,
417
+ command: 'dev session status',
418
+ running: healthy,
419
+ ...(await devSessionView(root, session)),
420
+ ...(processMatches ? {} : { reason: 'recorded process identity mismatch' }),
421
+ });
330
422
  }
331
423
  if (name === 'hsx.dev.session.stop') {
332
424
  const session = await readDevSession(root);
333
425
  if (!session)
334
426
  return content({ ok: true, command: 'dev session stop', running: false });
427
+ if (!(await devSessionMatchesProcess(session, root))) {
428
+ throw new Error('Refusing to stop the recorded pid because it is not the HS-X dev process for this project.');
429
+ }
335
430
  try {
336
431
  process.kill(session.pid, 'SIGTERM');
337
432
  }
338
- catch {
339
- // already gone
433
+ catch (error) {
434
+ throw new Error(`Could not stop HS-X dev pid ${session.pid}: ${error instanceof Error ? error.message : String(error)}`);
340
435
  }
341
- const { rm } = await import('node:fs/promises');
342
- const { join } = await import('node:path');
343
- await rm(join(root, '.hs-x', 'dev-session.json'), { force: true });
436
+ await removeDevSession(root);
344
437
  return content({ ok: true, command: 'dev session stop', stopped: session.pid });
345
438
  }
346
439
  if (name === 'hsx.dev.logs') {
347
- const lines = typeof args.lines === 'number' ? args.lines : 50;
348
- const { readFile } = await import('node:fs/promises');
349
- const { join } = await import('node:path');
350
- const session = await readDevSession(root);
351
- const logPath = session?.logPath ?? join(root, '.hs-x', 'dev-server.log');
352
- const raw = await readFile(logPath, 'utf8').catch(() => '');
353
- const tail = raw.split('\n').filter(Boolean).slice(-lines);
440
+ const lines = optionalLogLines(args.lines);
441
+ const paths = await resolveDevStatePaths(root, false);
442
+ const logPath = paths.logPath;
443
+ const tail = await readBoundedLogTail(root, lines);
354
444
  // One log surface: the same HubSpot app-log rows `hs-x logs` merges.
355
445
  // Missing binding/credentials degrades to a reason, never an error.
356
446
  const sinceMinutes = typeof args.sinceMinutes === 'number'
@@ -396,20 +486,36 @@ export async function callMcpTool(name, args, context) {
396
486
  const clientSecret = requiredString(args, 'clientSecret');
397
487
  const environment = optionalString(args, 'environment') ?? 'production';
398
488
  const config = resolveMcpConfig(context);
399
- const controlPlaneUrl = optionalString(args, 'controlPlaneUrl') ?? config.controlPlaneUrl.toString();
400
- const response = await fetch(new URL(`/v1/accounts/${encodeURIComponent(accountId)}/projects/${encodeURIComponent(projectId)}/hubspot-apps/${encodeURIComponent(String(hubSpotAppId))}/oauth-secret`, controlPlaneUrl), {
401
- method: 'PUT',
402
- headers: {
403
- 'content-type': 'application/json',
489
+ if (args.controlPlaneUrl !== undefined) {
490
+ throw new Error('controlPlaneUrl is server-configured and cannot be supplied by an MCP tool call.');
491
+ }
492
+ const addresses = await requirePublicControlPlaneResolution(config.controlPlaneUrl, context.lookup);
493
+ const target = new URL(`/v1/accounts/${encodeURIComponent(accountId)}/projects/${encodeURIComponent(projectId)}/hubspot-apps/${encodeURIComponent(String(hubSpotAppId))}/oauth-secret`, config.controlPlaneUrl);
494
+ const requestBody = JSON.stringify({ clientId, clientSecret, environment });
495
+ const credentialResponse = context.fetch
496
+ ? await context.fetch(target, {
497
+ method: 'PUT',
498
+ redirect: 'error',
499
+ headers: {
500
+ 'content-type': 'application/json',
501
+ authorization: `Bearer ${sessionToken}`,
502
+ },
503
+ body: requestBody,
504
+ })
505
+ : await (context.credentialRequest ?? requestPinnedCredentialJson)({
506
+ url: target,
507
+ addresses,
404
508
  authorization: `Bearer ${sessionToken}`,
405
- },
406
- body: JSON.stringify({ clientId, clientSecret, environment }),
407
- });
408
- const body = await response.json().catch(() => undefined);
409
- if (!response.ok) {
509
+ body: requestBody,
510
+ });
511
+ const status = credentialResponse.status;
512
+ const body = credentialResponse instanceof Response
513
+ ? await readBoundedResponseJson(credentialResponse, MAX_CREDENTIAL_RESPONSE_BYTES, 'Control-plane credential response')
514
+ : credentialResponse.body;
515
+ if (status < 200 || status >= 300) {
410
516
  throw new Error(isRecord(body) && typeof body.message === 'string'
411
517
  ? body.message
412
- : `Control plane returned ${response.status}`);
518
+ : `Control plane returned ${status}`);
413
519
  }
414
520
  // Whitelist non-sensitive response fields. Never echo client secret material
415
521
  // (or any other token-shaped value) back through the MCP transport.
@@ -430,13 +536,17 @@ export async function callMcpTool(name, args, context) {
430
536
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 25) : 8;
431
537
  const docsUrl = resolveMcpConfig(context).docsUrl;
432
538
  const catalogUrl = new URL('/llms.txt', docsUrl);
433
- const response = await fetch(catalogUrl, { signal: AbortSignal.timeout(8000) }).catch((error) => {
539
+ const response = await (context.fetch ?? fetch)(catalogUrl, {
540
+ signal: AbortSignal.timeout(8000),
541
+ }).catch((error) => {
434
542
  throw new Error(`Could not reach the docs catalog at ${catalogUrl.toString()}: ${error instanceof Error ? error.message : String(error)}`);
435
543
  });
436
544
  if (!response.ok) {
545
+ void response.body?.cancel().catch(() => undefined);
437
546
  throw new Error(`Docs catalog returned ${response.status} from ${catalogUrl.toString()}`);
438
547
  }
439
- const entries = parseDocsCatalog(await response.text());
548
+ const catalog = await readBoundedResponseText(response, MAX_DOCS_CATALOG_RESPONSE_BYTES, 'Docs catalog response');
549
+ const entries = parseDocsCatalog(catalog);
440
550
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
441
551
  const results = entries
442
552
  .map((entry) => ({ entry, score: scoreDocEntry(entry, terms) }))
@@ -476,17 +586,107 @@ export async function callMcpTool(name, args, context) {
476
586
  if (!path.endsWith('.md'))
477
587
  path = `${path.replace(/\/+$/, '')}.md`;
478
588
  const target = new URL(path, docsUrl);
479
- const response = await fetch(target, { signal: AbortSignal.timeout(8000) }).catch((error) => {
589
+ const response = await (context.fetch ?? fetch)(target, {
590
+ signal: AbortSignal.timeout(8000),
591
+ }).catch((error) => {
480
592
  throw new Error(`Could not reach ${target.toString()}: ${error instanceof Error ? error.message : String(error)}`);
481
593
  });
482
594
  if (!response.ok) {
595
+ void response.body?.cancel().catch(() => undefined);
483
596
  throw new Error(`Doc not found (${response.status}): ${path}`);
484
597
  }
485
598
  // Return the markdown directly — it IS the useful payload for an agent.
486
- return { content: [{ type: 'text', text: await response.text() }] };
599
+ return {
600
+ content: [
601
+ {
602
+ type: 'text',
603
+ text: await readBoundedResponseText(response, MAX_DOCS_PAGE_RESPONSE_BYTES, 'Docs page response'),
604
+ },
605
+ ],
606
+ };
487
607
  }
488
608
  throw new Error(`Unknown MCP tool: ${name}`);
489
609
  }
610
+ const MAX_DOCS_CATALOG_RESPONSE_BYTES = 1024 * 1024;
611
+ const MAX_DOCS_PAGE_RESPONSE_BYTES = 2 * 1024 * 1024;
612
+ async function readBoundedResponseJson(response, maxBytes, label) {
613
+ const text = await readBoundedResponseText(response, maxBytes, label);
614
+ return parseUpstreamJson(text, label);
615
+ }
616
+ async function readBoundedResponseText(response, maxBytes, label) {
617
+ if (declaredContentLengthExceeds(response.headers.get('content-length'), maxBytes)) {
618
+ void response.body?.cancel().catch(() => undefined);
619
+ throw new Error(`${label} exceeded ${formatByteLimit(maxBytes)}.`);
620
+ }
621
+ if (!response.body)
622
+ return '';
623
+ const reader = response.body.getReader();
624
+ const decoder = new TextDecoder('utf-8', { fatal: true });
625
+ let bytes = 0;
626
+ let text = '';
627
+ try {
628
+ while (true) {
629
+ const chunk = await reader.read();
630
+ if (chunk.done)
631
+ break;
632
+ bytes += chunk.value.byteLength;
633
+ if (bytes > maxBytes) {
634
+ throw new Error(`${label} exceeded ${formatByteLimit(maxBytes)}.`);
635
+ }
636
+ try {
637
+ text += decoder.decode(chunk.value, { stream: true });
638
+ }
639
+ catch {
640
+ throw new Error(`${label} was not valid UTF-8.`);
641
+ }
642
+ }
643
+ try {
644
+ return text + decoder.decode();
645
+ }
646
+ catch {
647
+ throw new Error(`${label} was not valid UTF-8.`);
648
+ }
649
+ }
650
+ catch (error) {
651
+ await reader.cancel(error).catch(() => undefined);
652
+ throw error;
653
+ }
654
+ finally {
655
+ reader.releaseLock();
656
+ }
657
+ }
658
+ function decodeUpstreamUtf8(bytes, label) {
659
+ try {
660
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
661
+ }
662
+ catch {
663
+ throw new Error(`${label} was not valid UTF-8.`);
664
+ }
665
+ }
666
+ function parseUpstreamJson(text, label) {
667
+ if (text.length === 0) {
668
+ throw new Error(`${label} was empty; expected JSON.`);
669
+ }
670
+ try {
671
+ return JSON.parse(text);
672
+ }
673
+ catch {
674
+ throw new Error(`${label} was not valid JSON.`);
675
+ }
676
+ }
677
+ function declaredContentLengthExceeds(value, maxBytes) {
678
+ const normalized = value?.trim();
679
+ if (!normalized || !/^\d+$/.test(normalized))
680
+ return false;
681
+ return BigInt(normalized) > BigInt(maxBytes);
682
+ }
683
+ function formatByteLimit(maxBytes) {
684
+ if (maxBytes % (1024 * 1024) === 0)
685
+ return `${maxBytes / (1024 * 1024)} MiB`;
686
+ if (maxBytes % 1024 === 0)
687
+ return `${maxBytes / 1024} KiB`;
688
+ return `${maxBytes} bytes`;
689
+ }
490
690
  /** Parse the docs site's /llms.txt catalog: `## Section` headers and
491
691
  * `- [Title](/docs/...md): description` entries. */
492
692
  function parseDocsCatalog(text) {
@@ -495,16 +695,20 @@ function parseDocsCatalog(text) {
495
695
  for (const raw of text.split('\n')) {
496
696
  const line = raw.trimEnd();
497
697
  const sectionMatch = line.match(/^##\s+(.+)$/);
498
- if (sectionMatch) {
499
- section = sectionMatch[1].trim();
698
+ const sectionTitle = sectionMatch?.[1];
699
+ if (sectionTitle) {
700
+ section = sectionTitle.trim();
500
701
  continue;
501
702
  }
502
703
  const entryMatch = line.match(/^-\s+\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/);
503
- if (entryMatch) {
704
+ const title = entryMatch?.[1];
705
+ const url = entryMatch?.[2];
706
+ const description = entryMatch?.[3];
707
+ if (title && url && description) {
504
708
  entries.push({
505
- title: entryMatch[1].trim(),
506
- url: entryMatch[2].trim(),
507
- description: entryMatch[3].trim(),
709
+ title: title.trim(),
710
+ url: url.trim(),
711
+ description: description.trim(),
508
712
  section,
509
713
  });
510
714
  }
@@ -541,43 +745,160 @@ async function dispatchMcpRequest(request, context) {
541
745
  }
542
746
  if (request.method === 'tools/call') {
543
747
  const params = parseToolCallParams(request.params);
544
- return callMcpTool(params.name, params.arguments, context);
748
+ if (!tools.some((tool) => tool.name === params.name)) {
749
+ throw new JsonRpcError(-32602, `Unknown MCP tool: ${params.name}`);
750
+ }
751
+ try {
752
+ return await callMcpTool(params.name, params.arguments, context);
753
+ }
754
+ catch (error) {
755
+ return toolError(params.name, error);
756
+ }
757
+ }
758
+ if (request.method === 'notifications/initialized' ||
759
+ request.method === 'notifications/cancelled') {
760
+ return {};
545
761
  }
546
- throw new Error(`Unsupported MCP method: ${request.method ?? '(missing)'}`);
762
+ throw new JsonRpcError(-32601, `Unsupported MCP method: ${request.method}`);
547
763
  }
548
764
  function parseToolCallParams(params) {
549
- if (!isRecord(params) || typeof params.name !== 'string') {
550
- throw new Error('tools/call requires params.name');
765
+ if (!isRecord(params) || Array.isArray(params) || typeof params.name !== 'string') {
766
+ throw new JsonRpcError(-32602, 'tools/call requires params.name');
551
767
  }
552
- if (params.arguments !== undefined && !isRecord(params.arguments)) {
553
- throw new Error('tools/call params.arguments must be an object when provided');
768
+ for (const key of Object.keys(params)) {
769
+ if (key !== 'name' && key !== 'arguments') {
770
+ throw new JsonRpcError(-32602, `Unexpected tools/call parameter: ${key}`);
771
+ }
772
+ }
773
+ if (params.name.length === 0 || params.name.length > 256) {
774
+ throw new JsonRpcError(-32602, 'tools/call params.name must be 1 to 256 characters');
775
+ }
776
+ if (params.arguments !== undefined &&
777
+ (!isRecord(params.arguments) || Array.isArray(params.arguments))) {
778
+ throw new JsonRpcError(-32602, 'tools/call params.arguments must be an object when provided');
554
779
  }
555
780
  return {
556
781
  name: params.name,
557
782
  arguments: params.arguments ?? {},
558
783
  };
559
784
  }
560
- function projectRoot(args, context) {
785
+ function validateToolArguments(tool, args) {
786
+ validateObjectAgainstSchema(args, tool.inputSchema, 'arguments');
787
+ }
788
+ function validateObjectAgainstSchema(value, schema, path) {
789
+ for (const required of schema.required ?? []) {
790
+ if (!(required in value) || value[required] === undefined) {
791
+ throw new Error(`${path}.${required} is required`);
792
+ }
793
+ }
794
+ if (schema.additionalProperties === false) {
795
+ for (const key of Object.keys(value)) {
796
+ if (!schema.properties || !(key in schema.properties)) {
797
+ throw new Error(`${path}.${key} is not allowed`);
798
+ }
799
+ }
800
+ }
801
+ for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
802
+ const propertyValue = value[key];
803
+ if (propertyValue === undefined)
804
+ continue;
805
+ validateValueAgainstSchema(propertyValue, propertySchema, `${path}.${key}`);
806
+ }
807
+ }
808
+ function validateValueAgainstSchema(value, schema, path) {
809
+ if (schema.type === 'string') {
810
+ if (typeof value !== 'string')
811
+ throw new Error(`${path} must be a string`);
812
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
813
+ throw new Error(`${path} must contain at least ${schema.minLength} character(s)`);
814
+ }
815
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
816
+ throw new Error(`${path} must contain at most ${schema.maxLength} characters`);
817
+ }
818
+ if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) {
819
+ throw new Error(`${path} does not match the required format`);
820
+ }
821
+ }
822
+ else if (schema.type === 'number' || schema.type === 'integer') {
823
+ if (typeof value !== 'number' ||
824
+ !Number.isFinite(value) ||
825
+ (schema.type === 'integer' && !Number.isSafeInteger(value))) {
826
+ throw new Error(`${path} must be ${schema.type === 'integer' ? 'an integer' : 'a number'}`);
827
+ }
828
+ if (schema.minimum !== undefined && value < schema.minimum) {
829
+ throw new Error(`${path} must be at least ${schema.minimum}`);
830
+ }
831
+ if (schema.maximum !== undefined && value > schema.maximum) {
832
+ throw new Error(`${path} must be at most ${schema.maximum}`);
833
+ }
834
+ }
835
+ else if (schema.type === 'boolean') {
836
+ if (typeof value !== 'boolean')
837
+ throw new Error(`${path} must be a boolean`);
838
+ }
839
+ else {
840
+ if (!isRecord(value) || Array.isArray(value))
841
+ throw new Error(`${path} must be an object`);
842
+ if (schema.properties || schema.additionalProperties === false) {
843
+ validateObjectAgainstSchema(value, schema, path);
844
+ }
845
+ }
846
+ if (schema.enum && !schema.enum.includes(value)) {
847
+ throw new Error(`${path} must be one of: ${schema.enum.join(', ')}`);
848
+ }
849
+ }
850
+ async function projectRoot(args, context) {
561
851
  if (args.root !== undefined && typeof args.root !== 'string') {
562
852
  throw new Error('root must be a string when provided');
563
853
  }
564
854
  const rootArg = args.root ?? '.';
565
- const allowedRoots = buildAllowedRoots(context);
566
855
  const resolved = resolve(context.cwd, rootArg);
856
+ const containmentError = 'root must resolve within the MCP working directory or an HSX_MCP_ALLOWED_ROOTS entry';
857
+ if (!buildLexicalAllowedRoots(context).some((allowed) => isWithin(resolved, allowed))) {
858
+ throw new Error(containmentError);
859
+ }
860
+ const { realpath, stat } = await import('node:fs/promises');
861
+ const allowedRoots = await buildAllowedRoots(context);
862
+ let canonicalRoot;
863
+ try {
864
+ canonicalRoot = await realpath(resolved);
865
+ if (!(await stat(canonicalRoot)).isDirectory()) {
866
+ throw new Error('root must resolve to a directory');
867
+ }
868
+ }
869
+ catch (error) {
870
+ if (error instanceof Error && error.message === 'root must resolve to a directory')
871
+ throw error;
872
+ throw new Error('root must resolve to an existing directory');
873
+ }
567
874
  for (const allowed of allowedRoots) {
568
- if (isWithin(resolved, allowed)) {
569
- return resolved;
875
+ if (isWithin(canonicalRoot, allowed)) {
876
+ return canonicalRoot;
570
877
  }
571
878
  }
572
- throw new Error('root must resolve within the MCP working directory or an HSX_MCP_ALLOWED_ROOTS entry');
879
+ throw new Error(containmentError);
573
880
  }
574
- function buildAllowedRoots(context) {
881
+ function buildLexicalAllowedRoots(context) {
575
882
  const config = resolveMcpConfig(context);
576
- const roots = new Set();
577
- roots.add(resolve(context.cwd));
883
+ const lexicalRoots = new Set();
884
+ lexicalRoots.add(resolve(context.cwd));
578
885
  for (const entry of config.allowedRoots) {
579
886
  if (isAbsolute(entry)) {
580
- roots.add(resolve(entry));
887
+ lexicalRoots.add(resolve(entry));
888
+ }
889
+ }
890
+ return Array.from(lexicalRoots);
891
+ }
892
+ async function buildAllowedRoots(context) {
893
+ const { realpath } = await import('node:fs/promises');
894
+ const lexicalRoots = buildLexicalAllowedRoots(context);
895
+ const roots = new Set();
896
+ for (const lexicalRoot of lexicalRoots) {
897
+ try {
898
+ roots.add(await realpath(lexicalRoot));
899
+ }
900
+ catch {
901
+ // A configured root that does not exist cannot authorize anything.
581
902
  }
582
903
  }
583
904
  return Array.from(roots);
@@ -605,6 +926,111 @@ function readSessionToken(_context) {
605
926
  function resolveMcpConfig(context) {
606
927
  return context.config ?? loadMcpConfig();
607
928
  }
929
+ async function requirePublicControlPlaneResolution(origin, injectedLookup) {
930
+ const lookup = injectedLookup ??
931
+ (async (hostname) => {
932
+ const { lookup: dnsLookup } = await import('node:dns/promises');
933
+ return dnsLookup(hostname, { all: true, verbatim: true });
934
+ });
935
+ let addresses;
936
+ try {
937
+ addresses = await lookup(origin.hostname.replace(/^\[|\]$/g, ''));
938
+ }
939
+ catch (error) {
940
+ throw new Error(`Could not resolve the configured control-plane origin safely: ${error instanceof Error ? error.message : String(error)}`);
941
+ }
942
+ if (addresses.length === 0 ||
943
+ addresses.some((entry) => isPrivateOrLoopbackHostname(entry.address))) {
944
+ throw new Error('The configured control-plane origin resolves to a private or loopback address; refusing to send credentials.');
945
+ }
946
+ return addresses;
947
+ }
948
+ const MAX_CREDENTIAL_RESPONSE_BYTES = 64 * 1024;
949
+ class CredentialRequestPolicyError extends Error {
950
+ }
951
+ async function requestPinnedCredentialJson(input) {
952
+ if (input.addresses.length === 0) {
953
+ throw new Error('The configured control-plane origin resolved to no addresses.');
954
+ }
955
+ let lastError;
956
+ for (const pinned of input.addresses) {
957
+ try {
958
+ return await requestPinnedCredentialJsonAtAddress(input, pinned);
959
+ }
960
+ catch (error) {
961
+ if (error instanceof CredentialRequestPolicyError)
962
+ throw error;
963
+ lastError = error;
964
+ }
965
+ }
966
+ throw lastError instanceof Error
967
+ ? lastError
968
+ : new Error('Could not connect to any validated control-plane address.');
969
+ }
970
+ async function requestPinnedCredentialJsonAtAddress(input, pinned) {
971
+ const lookup = (_hostname, options, callback) => {
972
+ if (typeof options === 'object' && options.all) {
973
+ callback(null, [{ ...pinned }]);
974
+ return;
975
+ }
976
+ callback(null, pinned.address, pinned.family);
977
+ };
978
+ return new Promise((resolve, reject) => {
979
+ const request = httpsRequest(input.url, {
980
+ method: 'PUT',
981
+ lookup,
982
+ headers: {
983
+ accept: 'application/json',
984
+ authorization: input.authorization,
985
+ 'content-type': 'application/json',
986
+ 'content-length': Buffer.byteLength(input.body),
987
+ },
988
+ }, (response) => {
989
+ const status = response.statusCode ?? 0;
990
+ if (status >= 300 && status < 400) {
991
+ response.resume();
992
+ reject(new CredentialRequestPolicyError('Control-plane redirects are not allowed for credential requests.'));
993
+ return;
994
+ }
995
+ const declaredLength = response.headers['content-length'];
996
+ if (declaredContentLengthExceeds(Array.isArray(declaredLength) ? declaredLength[0] : declaredLength, MAX_CREDENTIAL_RESPONSE_BYTES)) {
997
+ response.resume();
998
+ reject(new CredentialRequestPolicyError(`Control-plane credential response exceeded ${formatByteLimit(MAX_CREDENTIAL_RESPONSE_BYTES)}.`));
999
+ return;
1000
+ }
1001
+ const chunks = [];
1002
+ let bytes = 0;
1003
+ response.on('data', (chunk) => {
1004
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1005
+ bytes += buffer.byteLength;
1006
+ if (bytes > MAX_CREDENTIAL_RESPONSE_BYTES) {
1007
+ response.destroy(new CredentialRequestPolicyError(`Control-plane credential response exceeded ${formatByteLimit(MAX_CREDENTIAL_RESPONSE_BYTES)}.`));
1008
+ return;
1009
+ }
1010
+ chunks.push(buffer);
1011
+ });
1012
+ response.on('end', () => {
1013
+ try {
1014
+ const text = decodeUpstreamUtf8(Buffer.concat(chunks), 'Control-plane credential response');
1015
+ resolve({ status, body: parseUpstreamJson(text, 'Control-plane credential response') });
1016
+ }
1017
+ catch (error) {
1018
+ reject(new CredentialRequestPolicyError(error instanceof Error ? error.message : String(error)));
1019
+ }
1020
+ });
1021
+ response.on('error', reject);
1022
+ });
1023
+ request.setTimeout(8_000, () => {
1024
+ request.destroy(new Error('Control-plane credential request timed out.'));
1025
+ });
1026
+ request.on('error', reject);
1027
+ request.end(input.body);
1028
+ });
1029
+ }
1030
+ function requireUnsafeProjectExecutionOptIn(context, toolName) {
1031
+ void context;
1032
+ throw new Error(`${toolName} is not available: HS-X does not expose project-code execution through the local MCP process. Use the hs-x CLI directly until an isolated execution service exists.`);
1033
+ }
608
1034
  function requiredString(args, key) {
609
1035
  const value = args[key];
610
1036
  if (typeof value !== 'string' || value.length === 0) {
@@ -633,24 +1059,236 @@ function content(value) {
633
1059
  content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
634
1060
  };
635
1061
  }
1062
+ function toolError(toolName, error) {
1063
+ const message = error instanceof Error ? error.message : String(error);
1064
+ return {
1065
+ isError: true,
1066
+ content: [
1067
+ {
1068
+ type: 'text',
1069
+ text: JSON.stringify({ ok: false, tool: toolName, error: message }, null, 2),
1070
+ },
1071
+ ],
1072
+ };
1073
+ }
636
1074
  function isRecord(value) {
637
1075
  return typeof value === 'object' && value !== null;
638
1076
  }
639
1077
  async function readDevSession(root) {
1078
+ const { constants } = await import('node:fs');
1079
+ const { open } = await import('node:fs/promises');
1080
+ const paths = await resolveDevStatePaths(root, false);
1081
+ if (!paths.stateDirectoryExists)
1082
+ return undefined;
1083
+ let handle;
1084
+ let raw;
640
1085
  try {
641
- const { readFile } = await import('node:fs/promises');
642
- const { join } = await import('node:path');
643
- const parsed = JSON.parse(await readFile(join(root, '.hs-x', 'dev-session.json'), 'utf8'));
644
- return typeof parsed.pid === 'number' && typeof parsed.port === 'number' ? parsed : undefined;
1086
+ handle = await open(paths.sessionPath, constants.O_RDONLY | constants.O_NOFOLLOW);
1087
+ const metadata = await handle.stat();
1088
+ if (!metadata.isFile()) {
1089
+ throw new Error('The dev-session record is not a regular file; refusing to trust it.');
1090
+ }
1091
+ if (metadata.size > 16 * 1024) {
1092
+ throw new Error('The dev-session record is too large; refusing to trust it.');
1093
+ }
1094
+ raw = await handle.readFile('utf8');
1095
+ }
1096
+ catch (error) {
1097
+ if (isNodeError(error) && error.code === 'ENOENT')
1098
+ return undefined;
1099
+ throw error;
1100
+ }
1101
+ finally {
1102
+ await handle?.close();
1103
+ }
1104
+ let parsed;
1105
+ try {
1106
+ parsed = JSON.parse(raw);
645
1107
  }
646
1108
  catch {
647
- return undefined;
1109
+ throw new Error('The dev-session record is not valid JSON; refusing to trust it.');
648
1110
  }
1111
+ if (!isRecord(parsed) || Array.isArray(parsed)) {
1112
+ throw new Error('The dev-session record has an invalid schema; refusing to trust it.');
1113
+ }
1114
+ const keys = Object.keys(parsed).sort();
1115
+ if (keys.join(',') !== 'pid,port,schemaVersion' ||
1116
+ parsed.schemaVersion !== 1 ||
1117
+ !Number.isSafeInteger(parsed.pid) ||
1118
+ parsed.pid <= 0 ||
1119
+ !Number.isSafeInteger(parsed.port) ||
1120
+ parsed.port < 1 ||
1121
+ parsed.port > 65_535) {
1122
+ throw new Error('The dev-session record has an invalid schema; refusing to trust it.');
1123
+ }
1124
+ return parsed;
649
1125
  }
650
1126
  async function writeDevSession(root, session) {
651
- const { writeFile } = await import('node:fs/promises');
1127
+ const { open } = await import('node:fs/promises');
1128
+ const paths = await resolveDevStatePaths(root, true);
1129
+ let handle;
1130
+ try {
1131
+ handle = await open(paths.sessionPath, 'wx', 0o600);
1132
+ await handle.writeFile(`${JSON.stringify(session, null, 2)}\n`, 'utf8');
1133
+ }
1134
+ catch (error) {
1135
+ if (isNodeError(error) && error.code === 'EEXIST') {
1136
+ throw new Error('A dev-session record already exists; refusing to overwrite it.');
1137
+ }
1138
+ throw error;
1139
+ }
1140
+ finally {
1141
+ await handle?.close();
1142
+ }
1143
+ }
1144
+ async function removeDevSession(root) {
1145
+ const { rm } = await import('node:fs/promises');
1146
+ const paths = await resolveDevStatePaths(root, false);
1147
+ if (paths.stateDirectoryExists)
1148
+ await rm(paths.sessionPath, { force: true });
1149
+ }
1150
+ async function devSessionView(root, session) {
1151
+ const paths = await resolveDevStatePaths(root, false);
1152
+ return {
1153
+ pid: session.pid,
1154
+ port: session.port,
1155
+ url: `http://127.0.0.1:${session.port}`,
1156
+ logPath: paths.logPath,
1157
+ };
1158
+ }
1159
+ async function devSessionMatchesProcess(session, root) {
1160
+ if (!Number.isSafeInteger(session.pid) || session.pid <= 0)
1161
+ return false;
1162
+ const binary = process.env.HSX_MCP_DEV_BINARY ?? 'hs-x';
1163
+ const [, ...prefixArgs] = binary.split(' ');
1164
+ const expectedSuffix = [...prefixArgs, 'dev', '--cwd', root, '--port', String(session.port)].join(' ');
1165
+ try {
1166
+ const { execFile } = await import('node:child_process');
1167
+ const command = await new Promise((resolvePromise, reject) => {
1168
+ execFile('ps', ['-ww', '-p', String(session.pid), '-o', 'command='], { timeout: 1500, maxBuffer: 64 * 1024 }, (error, stdout) => {
1169
+ if (error)
1170
+ reject(error);
1171
+ else
1172
+ resolvePromise(stdout.trim());
1173
+ });
1174
+ });
1175
+ return command === expectedSuffix || command.endsWith(` ${expectedSuffix}`);
1176
+ }
1177
+ catch {
1178
+ return false;
1179
+ }
1180
+ }
1181
+ async function resolveDevStatePaths(root, create) {
1182
+ const { mkdir, realpath, stat } = await import('node:fs/promises');
652
1183
  const { join } = await import('node:path');
653
- await writeFile(join(root, '.hs-x', 'dev-session.json'), `${JSON.stringify(session, null, 2)}\n`);
1184
+ const canonicalRoot = await realpath(root);
1185
+ const lexicalStateDirectory = join(canonicalRoot, '.hs-x');
1186
+ if (create)
1187
+ await mkdir(lexicalStateDirectory, { recursive: true, mode: 0o700 });
1188
+ let canonicalStateDirectory;
1189
+ try {
1190
+ canonicalStateDirectory = await realpath(lexicalStateDirectory);
1191
+ }
1192
+ catch (error) {
1193
+ if (!create && isNodeError(error) && error.code === 'ENOENT') {
1194
+ return {
1195
+ stateDirectoryExists: false,
1196
+ stateDirectory: lexicalStateDirectory,
1197
+ sessionPath: join(lexicalStateDirectory, 'dev-session.json'),
1198
+ logPath: join(lexicalStateDirectory, 'dev-server.log'),
1199
+ };
1200
+ }
1201
+ throw error;
1202
+ }
1203
+ if (!isWithin(canonicalStateDirectory, canonicalRoot)) {
1204
+ throw new Error('The .hs-x directory resolves outside the project root; refusing access.');
1205
+ }
1206
+ const state = await stat(canonicalStateDirectory);
1207
+ if (!state.isDirectory()) {
1208
+ throw new Error('The .hs-x path is not a directory; refusing access.');
1209
+ }
1210
+ return {
1211
+ stateDirectoryExists: true,
1212
+ stateDirectory: canonicalStateDirectory,
1213
+ sessionPath: join(canonicalStateDirectory, 'dev-session.json'),
1214
+ logPath: join(canonicalStateDirectory, 'dev-server.log'),
1215
+ };
1216
+ }
1217
+ const MAX_LOG_LINES = 1000;
1218
+ const MAX_LOG_TAIL_BYTES = 256 * 1024;
1219
+ function optionalLogLines(value) {
1220
+ if (value === undefined)
1221
+ return 50;
1222
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_LOG_LINES) {
1223
+ throw new Error(`lines must be an integer from 1 to ${MAX_LOG_LINES}`);
1224
+ }
1225
+ return value;
1226
+ }
1227
+ function optionalPort(value) {
1228
+ if (value === undefined)
1229
+ return 8787;
1230
+ if (!Number.isSafeInteger(value) || value < 1 || value > 65_535) {
1231
+ throw new Error('port must be an integer from 1 to 65535');
1232
+ }
1233
+ return value;
1234
+ }
1235
+ async function readBoundedLogTail(root, lines) {
1236
+ const { constants } = await import('node:fs');
1237
+ const { open, realpath } = await import('node:fs/promises');
1238
+ const paths = await resolveDevStatePaths(root, false);
1239
+ if (!paths.stateDirectoryExists)
1240
+ return [];
1241
+ let canonicalLogPath;
1242
+ try {
1243
+ canonicalLogPath = await realpath(paths.logPath);
1244
+ }
1245
+ catch (error) {
1246
+ if (isNodeError(error) && error.code === 'ENOENT')
1247
+ return [];
1248
+ throw error;
1249
+ }
1250
+ if (!isWithin(canonicalLogPath, paths.stateDirectory)) {
1251
+ throw new Error('The dev log resolves outside .hs-x; refusing access.');
1252
+ }
1253
+ const handle = await open(canonicalLogPath, constants.O_RDONLY | constants.O_NOFOLLOW);
1254
+ try {
1255
+ const metadata = await handle.stat();
1256
+ if (!metadata.isFile()) {
1257
+ throw new Error('The dev log is not a regular file; refusing access.');
1258
+ }
1259
+ const bytes = Math.min(metadata.size, MAX_LOG_TAIL_BYTES);
1260
+ const start = metadata.size - bytes;
1261
+ const buffer = Buffer.alloc(bytes);
1262
+ const { bytesRead } = await handle.read(buffer, 0, bytes, start);
1263
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
1264
+ if (start > 0)
1265
+ text = text.slice(Math.max(0, text.indexOf('\n') + 1));
1266
+ return text.split('\n').filter(Boolean).slice(-lines);
1267
+ }
1268
+ finally {
1269
+ await handle.close();
1270
+ }
1271
+ }
1272
+ async function openDevLogForAppend(root) {
1273
+ const { constants } = await import('node:fs');
1274
+ const { open } = await import('node:fs/promises');
1275
+ const paths = await resolveDevStatePaths(root, true);
1276
+ const handle = await open(paths.logPath, constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
1277
+ try {
1278
+ const metadata = await handle.stat();
1279
+ if (!metadata.isFile()) {
1280
+ throw new Error('The dev log is not a regular file; refusing access.');
1281
+ }
1282
+ await handle.chmod(0o600);
1283
+ return { logPath: paths.logPath, stream: handle.createWriteStream() };
1284
+ }
1285
+ catch (error) {
1286
+ await handle.close();
1287
+ throw error;
1288
+ }
1289
+ }
1290
+ function isNodeError(error) {
1291
+ return error instanceof Error && 'code' in error;
654
1292
  }
655
1293
  async function devSessionHealthy(port) {
656
1294
  try {