@hirey/hi-mcp-server 0.1.25 → 0.1.26

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.
@@ -0,0 +1,9 @@
1
+ export declare const HI_API_KEY_PREFIX = "hi_ak_";
2
+ export type HiApiKeyMaterial = {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ };
6
+ export declare function isHiApiKey(token: unknown): boolean;
7
+ export declare function encodeHiApiKey(clientId: string, clientSecret: string): string;
8
+ export declare function decodeHiApiKey(key: unknown): HiApiKeyMaterial | null;
9
+ //# sourceMappingURL=apiKey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiKey.d.ts","sourceRoot":"","sources":["../src/apiKey.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,iBAAiB,WAAW,CAAC;AAE1C,MAAM,MAAM,gBAAgB,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAElD;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAM7E;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAapE"}
package/dist/apiKey.js ADDED
@@ -0,0 +1,51 @@
1
+ // Portable, revocable Hi API key for hosts that send a STATIC bearer to the
2
+ // remote /mcp endpoint instead of doing OAuth — e.g. Codex's
3
+ // `codex mcp add hi --url https://mcp.hirey.ai/mcp --bearer-token-env-var HI_API_KEY`.
4
+ //
5
+ // Why this exists: Codex persists its remote-MCP OAuth token in its OWN local
6
+ // store (~/.codex/.credentials.json) with a non-atomic write + token rotation.
7
+ // A crash/kill mid-write loses the file → the credential "mysteriously vanishes"
8
+ // and the user is forced to re-login (and lands on a fresh anonymous agent). An
9
+ // API key sidesteps that entirely: the credential is a NON-rotating
10
+ // client_credentials pair that lives in a user-controlled env var (never in
11
+ // Codex's fragile store), and is revocable by revoking the underlying client.
12
+ //
13
+ // Format: "hi_ak_" + base64url(JSON({ v:1, id:<client_id>, secret:<client_secret> })).
14
+ // The /mcp edge detects the prefix, exchanges the pair for a short-lived JWT via
15
+ // the standard client_credentials grant, and replays THAT JWT to downstream Hi
16
+ // APIs — so nothing downstream needs to learn about API keys; they keep seeing
17
+ // an ordinary access token. Revocation is free: revoke the OAuth client and the
18
+ // next exchange fails closed.
19
+ //
20
+ // This module is intentionally dependency-free (no SDK / network) so the codec
21
+ // is trivially unit-testable; the network exchange + cache lives in server.ts.
22
+ export const HI_API_KEY_PREFIX = 'hi_ak_';
23
+ export function isHiApiKey(token) {
24
+ return typeof token === 'string' && token.startsWith(HI_API_KEY_PREFIX) && token.length > HI_API_KEY_PREFIX.length;
25
+ }
26
+ export function encodeHiApiKey(clientId, clientSecret) {
27
+ const id = String(clientId || '').trim();
28
+ const secret = String(clientSecret || '').trim();
29
+ if (!id || !secret)
30
+ throw new Error('encodeHiApiKey: client_id and client_secret are required');
31
+ const payload = JSON.stringify({ v: 1, id, secret });
32
+ return HI_API_KEY_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');
33
+ }
34
+ export function decodeHiApiKey(key) {
35
+ if (!isHiApiKey(key))
36
+ return null;
37
+ try {
38
+ const json = Buffer.from(key.slice(HI_API_KEY_PREFIX.length), 'base64url').toString('utf8');
39
+ const obj = JSON.parse(json);
40
+ if (!obj || typeof obj !== 'object')
41
+ return null;
42
+ const clientId = String(obj.id || '').trim();
43
+ const clientSecret = String(obj.secret || '').trim();
44
+ if (!clientId || !clientSecret)
45
+ return null;
46
+ return { clientId, clientSecret };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
@@ -0,0 +1,9 @@
1
+ export type ApiKeyExchanger = (apiKey: string) => Promise<string | null>;
2
+ export declare function createApiKeyExchanger(opts: {
3
+ tokenUrl: string;
4
+ resource: string[];
5
+ now?: () => number;
6
+ fetchImpl?: typeof fetch;
7
+ maxEntries?: number;
8
+ }): ApiKeyExchanger;
9
+ //# sourceMappingURL=apiKeyExchange.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiKeyExchange.d.ts","sourceRoot":"","sources":["../src/apiKeyExchange.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAIzE,wBAAgB,qBAAqB,CAAC,IAAI,EAAE;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,eAAe,CA4ClB"}
@@ -0,0 +1,65 @@
1
+ // /mcp edge exchanger for Hi API keys. When a host (e.g. Codex via
2
+ // `--bearer-token-env-var`) sends a static `hi_ak_...` bearer, the edge can't
3
+ // verify it as a JWT — it's a portable client_credentials pair. This module
4
+ // exchanges it for a SHORT-LIVED access token via the standard
5
+ // client_credentials grant (requesting the canonical MCP `resource` so the
6
+ // token's `aud` matches what the edge verifier enforces), caches the JWT until
7
+ // shortly before expiry, and hands it back so the edge can verify + replay it
8
+ // downstream exactly like a normal OAuth bearer.
9
+ //
10
+ // Fail-closed: any bad key / revoked client / auth outage returns null, which
11
+ // the caller turns into a 401 — never a partial / unauthenticated pass.
12
+ import { decodeHiApiKey, isHiApiKey } from './apiKey.js';
13
+ export function createApiKeyExchanger(opts) {
14
+ const cache = new Map();
15
+ const now = opts.now || (() => Date.now());
16
+ const doFetch = opts.fetchImpl || fetch;
17
+ const maxEntries = opts.maxEntries ?? 5000;
18
+ return async (apiKey) => {
19
+ if (!isHiApiKey(apiKey) || !opts.tokenUrl)
20
+ return null;
21
+ const cached = cache.get(apiKey);
22
+ if (cached && cached.expAt > now())
23
+ return cached.token;
24
+ const material = decodeHiApiKey(apiKey);
25
+ if (!material)
26
+ return null;
27
+ try {
28
+ const body = {
29
+ grant_type: 'client_credentials',
30
+ client_id: material.clientId,
31
+ client_secret: material.clientSecret,
32
+ };
33
+ // RFC 8707: bind the issued token's aud to the MCP resource so the edge
34
+ // verifier (which checks aud == its resource) accepts it.
35
+ if (opts.resource.length > 0)
36
+ body.resource = opts.resource;
37
+ const resp = await doFetch(opts.tokenUrl, {
38
+ method: 'POST',
39
+ headers: { 'content-type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+ if (!resp.ok)
43
+ return null;
44
+ const json = await resp.json().catch(() => null);
45
+ const token = String(json?.access_token || '').trim();
46
+ if (!token)
47
+ return null;
48
+ const expiresIn = Number(json?.expires_in || 3600);
49
+ // Bound memory: drop expired entries, then hard-clear if still at cap.
50
+ if (cache.size >= maxEntries) {
51
+ for (const [k, v] of cache) {
52
+ if (v.expAt <= now())
53
+ cache.delete(k);
54
+ }
55
+ if (cache.size >= maxEntries)
56
+ cache.clear();
57
+ }
58
+ cache.set(apiKey, { token, expAt: now() + Math.max(30, expiresIn - 60) * 1000 });
59
+ return token;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ };
65
+ }
@@ -63,5 +63,5 @@ export type BearerVerifyOutcome = {
63
63
  error: 'invalid_token';
64
64
  description: string;
65
65
  };
66
- export declare function verifyRequestBearer(req: express.Request, verifier: (token: string) => Promise<JWTVerifyResult<JWTPayload>>): Promise<BearerVerifyOutcome>;
66
+ export declare function verifyRequestBearer(req: express.Request, verifier: (token: string) => Promise<JWTVerifyResult<JWTPayload>>, apiKeyExchanger?: (apiKey: string) => Promise<string | null>): Promise<BearerVerifyOutcome>;
67
67
  //# sourceMappingURL=oauthRequestAuth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauthRequestAuth.d.ts","sourceRoot":"","sources":["../src/oauthRequestAuth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oauthRequestAuth.d.ts","sourceRoot":"","sources":["../src/oauthRequestAuth.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { createRemoteJWKSet, jwtVerify } from 'jose';
3
+ import { HI_API_KEY_PREFIX } from './apiKey.js';
3
4
  function normalizeText(input) {
4
5
  return String(input || '').trim();
5
6
  }
@@ -90,11 +91,24 @@ export function buildProtectedResourceMetadata(input) {
90
91
  ...(input.resourceDocumentationUrl ? { resource_documentation: input.resourceDocumentationUrl } : {}),
91
92
  };
92
93
  }
93
- export async function verifyRequestBearer(req, verifier) {
94
- const bearer = extractBearer(req);
94
+ export async function verifyRequestBearer(req, verifier,
95
+ // Optional: when the inbound bearer is a Hi API key (hi_ak_...) instead of a
96
+ // JWT, exchange it for a short-lived access token first, then verify + replay
97
+ // THAT downstream. Lets static-bearer hosts (Codex --bearer-token-env-var) use
98
+ // a non-rotating, revocable credential that never touches their fragile local
99
+ // OAuth store. When omitted, only JWT bearers are accepted (unchanged).
100
+ apiKeyExchanger) {
101
+ let bearer = extractBearer(req);
95
102
  if (!bearer) {
96
103
  return { ok: false, error: 'invalid_token', description: 'missing_bearer' };
97
104
  }
105
+ if (apiKeyExchanger && bearer.startsWith(HI_API_KEY_PREFIX)) {
106
+ const exchanged = await apiKeyExchanger(bearer);
107
+ if (!exchanged) {
108
+ return { ok: false, error: 'invalid_token', description: 'api_key_invalid_or_revoked' };
109
+ }
110
+ bearer = exchanged;
111
+ }
98
112
  try {
99
113
  const { payload } = await verifier(bearer);
100
114
  const subjectId = normalizeText(payload.sub);
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":";AAgDA,OAAO,EASL,KAAK,6BAA6B,EAMnC,MAAM,YAAY,CAAC;AAgqBpB,wBAAgB,oBAAoB,yCAEnC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":";AAgDA,OAAO,EASL,KAAK,6BAA6B,EAMnC,MAAM,YAAY,CAAC;AAsrBpB,wBAAgB,oBAAoB,yCAEnC"}
package/dist/server.js CHANGED
@@ -25,6 +25,8 @@ import { applyReceiverRuntimeSnapshot, receiverConfigMaterialEquals, } from './r
25
25
  import { resolveInstallDefaultReplyDeliveryContext, resolveInstallRouteMissingPolicy, } from './defaultReplyRoute.js';
26
26
  import { resolveInstallDisplayName } from './installDefaults.js';
27
27
  import { buildAuthorizationServerMetadataAlias, buildOAuthVerifier, buildProtectedResourceMetadata, buildWwwAuthenticateChallenge, getCurrentRequestAuth, requestAuthStorage, verifyRequestBearer, } from './oauthRequestAuth.js';
28
+ import { createApiKeyExchanger } from './apiKeyExchange.js';
29
+ import { encodeHiApiKey } from './apiKey.js';
28
30
  // Hosts that hi-mcp-server can claim a first-class identity for. Everything
29
31
  // outside this set normalizes down to `generic`. We carry the wider list here
30
32
  // (not the narrow openclaw/generic split) so install state + telemetry can
@@ -111,6 +113,15 @@ const config = {
111
113
  oauthIssuer: normalizeText(process.env.HI_MCP_OAUTH_ISSUER)
112
114
  || normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER),
113
115
  oauthScopesSupported: readListEnv('HI_MCP_OAUTH_SCOPES', ['hi.read', 'hi.write', 'hi.events']),
116
+ // Token endpoint used to exchange a Hi API key (hi_ak_...) for a short-lived
117
+ // access token at the /mcp edge. When unset, the API-key path is disabled and
118
+ // only OAuth bearers are accepted (zero behavior change for existing installs).
119
+ // In prod this points at hi-auth's /oauth/token; default-derived from the
120
+ // authorization server base for convenience.
121
+ apiKeyTokenUrl: normalizeText(process.env.HI_MCP_API_KEY_TOKEN_URL)
122
+ || (normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER)
123
+ ? `${normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER).replace(/\/+$/, '')}/oauth/token`
124
+ : ''),
114
125
  };
115
126
  let capabilityCache = null;
116
127
  function assertConfig() {
@@ -317,6 +328,16 @@ function controlTools() {
317
328
  required: ['claim_token'],
318
329
  },
319
330
  },
331
+ {
332
+ name: 'hi_api_key_create',
333
+ description: '铸一个**稳定、可吊销的 Hi API key**(hi_ak_… 形态),用于让 codex 等只发静态 bearer 的宿主走远程 MCP——不靠 codex 自己那个会轮换、会被写坏丢失的 OAuth 凭证。返回的 key 放进 HI_API_KEY 环境变量,再 `codex mcp add hi --url <mcp> --bearer-token-env-var HI_API_KEY` 即可;key 是不轮换的 client_credentials,永不进 codex 的本地凭证文件,所以不会"莫名消失"。新铸的 key 先属于一个全新 agent;codex 用上这个 key 后,再用 phone_binding 绑你的手机号,就并进你已有的 Hi 身份。',
334
+ inputSchema: {
335
+ type: 'object',
336
+ properties: {
337
+ display_name: { type: 'string', description: '可选;这个 key 对应设备的显示名,例如 "Codex (我的 MacBook)"。' },
338
+ },
339
+ },
340
+ },
320
341
  {
321
342
  name: 'hi_agent_installation_get',
322
343
  description: '读取当前 installation 的正式 persisted state,包括 delivery_capabilities。',
@@ -1415,6 +1436,56 @@ async function handleClaimRedeem(args) {
1415
1436
  // 自然解析到新 agent,无需改本地 state。
1416
1437
  return ok(data);
1417
1438
  }
1439
+ // Mint a portable, revocable Hi API key (hi_ak_...) by registering a FRESH
1440
+ // client_credentials installation and encoding it. Deliberately does NOT touch
1441
+ // the current install's local state — it's for handing a static bearer to a
1442
+ // different host (Codex --bearer-token-env-var). The fresh agent is converged
1443
+ // into the caller's real identity later via phone_binding (the same path every
1444
+ // client uses to onboard), so no new gateway/identity surgery is needed here.
1445
+ async function handleApiKeyCreate(args) {
1446
+ const displayName = normalizeText(args.display_name) || 'Codex (API key)';
1447
+ let registryBase = config.platformBaseUrl;
1448
+ try {
1449
+ const wellKnown = await loadWellKnown();
1450
+ registryBase = normalizeText(wellKnown?.platform?.registry_base_url) || registryBase;
1451
+ }
1452
+ catch {
1453
+ // fall back to platformBaseUrl
1454
+ }
1455
+ const res = await fetch(`${registryBase.replace(/\/+$/, '')}/v1/agents/register`, {
1456
+ method: 'POST',
1457
+ headers: { 'content-type': 'application/json' },
1458
+ body: JSON.stringify({ display_name: displayName }),
1459
+ });
1460
+ const data = (await res.json().catch(() => ({})));
1461
+ if (!res.ok)
1462
+ return fail(String(data?.error || 'api_key_create_failed'), data);
1463
+ const auth = (data?.auth || {});
1464
+ const clientId = normalizeText(auth.client_id);
1465
+ const clientSecret = normalizeText(auth.client_secret);
1466
+ if (!clientId || !clientSecret)
1467
+ return fail('api_key_create_missing_credentials', data);
1468
+ const apiKey = encodeHiApiKey(clientId, clientSecret);
1469
+ const mcpUrl = config.oauthMcpResources[0] || `${registryBase.replace(/\/+$/, '')}/mcp`;
1470
+ return ok({
1471
+ api_key: apiKey,
1472
+ agent_id: data?.agent_id ?? null,
1473
+ mcp_url: mcpUrl,
1474
+ setup: {
1475
+ // Preferred: a literal header in Codex's config.toml — a static file Codex
1476
+ // reads EVERY session (works for terminal codex AND the Codex.app GUI). The
1477
+ // key is written once at setup and never rewritten, which is exactly why it
1478
+ // can't vanish: the OAuth path vanished because Codex rewrites a ROTATING
1479
+ // token on every refresh and a crash mid-write loses it; a static key has
1480
+ // no rewrites. Same model as pasting a static key into Codex.app.
1481
+ codex_config_toml: `[mcp_servers.hi]\nurl = "${mcpUrl}"\n\n[mcp_servers.hi.http_headers]\nAuthorization = "Bearer ${apiKey}"`,
1482
+ // Alternative (terminal-launched codex): keep the key in an env var.
1483
+ codex_env_var: `export HI_API_KEY='${apiKey}' # then: codex mcp add hi --url ${mcpUrl} --bearer-token-env-var HI_API_KEY`,
1484
+ restart: 'Fully quit and relaunch Codex — it loads MCP servers only at session start.',
1485
+ },
1486
+ note: 'Stable, revocable, NON-rotating API key. Put it in Codex config.toml as a literal Authorization header (preferred — read every session, never rewritten, so it cannot vanish like the rotating OAuth token did) or in the HI_API_KEY env var. It currently belongs to a fresh agent; once Codex is running on this key, call phone_binding to merge it into your existing Hi identity (your phone number). Revoke by rotating/deleting that agent.',
1487
+ });
1488
+ }
1418
1489
  async function handleStatus(args) {
1419
1490
  const state = await loadPersistedState();
1420
1491
  const requestAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
@@ -2345,6 +2416,8 @@ async function handleControlTool(name, args) {
2345
2416
  return await handleClaimExport(args);
2346
2417
  case 'hi_agent_claim_redeem':
2347
2418
  return await handleClaimRedeem(args);
2419
+ case 'hi_api_key_create':
2420
+ return await handleApiKeyCreate(args);
2348
2421
  case 'hi_agent_installation_get':
2349
2422
  return await handleInstallationGet();
2350
2423
  case 'hi_agent_installation_update':
@@ -2407,6 +2480,7 @@ const CONTROL_TOOL_ANNOTATIONS = {
2407
2480
  hi_agent_activate: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent activate' },
2408
2481
  hi_agent_claim_export: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Claim export' },
2409
2482
  hi_agent_claim_redeem: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Claim redeem' },
2483
+ hi_api_key_create: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Create API key' },
2410
2484
  hi_agent_installation_get: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Installation get' },
2411
2485
  hi_agent_installation_update: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Installation update' },
2412
2486
  hi_agent_endpoints_list: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Endpoints list' },
@@ -2632,6 +2706,13 @@ async function runHttpServer() {
2632
2706
  audience: config.oauthMcpResources,
2633
2707
  })
2634
2708
  : null;
2709
+ // /mcp edge exchanger for Hi API keys (hi_ak_...). Built only in OAuth mode
2710
+ // with a token endpoint configured; otherwise undefined so only JWT bearers
2711
+ // are accepted (unchanged). The exchange requests the canonical MCP resource
2712
+ // so the issued token's aud satisfies the verifier above.
2713
+ const apiKeyExchanger = config.authMode === 'oauth' && config.apiKeyTokenUrl
2714
+ ? createApiKeyExchanger({ tokenUrl: config.apiKeyTokenUrl, resource: config.oauthMcpResources })
2715
+ : undefined;
2635
2716
  function send401(req, res, description, error = 'invalid_token') {
2636
2717
  if (config.authMode === 'oauth') {
2637
2718
  const entry = resolveOAuthResource(req);
@@ -2691,7 +2772,7 @@ async function runHttpServer() {
2691
2772
  // OAuth-mode bearer enforcement. Stdio path and 'local' HTTP path keep
2692
2773
  // the existing single-tenant disk identity model untouched.
2693
2774
  if (config.authMode === 'oauth' && oauthVerifier) {
2694
- const outcome = await verifyRequestBearer(req, oauthVerifier);
2775
+ const outcome = await verifyRequestBearer(req, oauthVerifier, apiKeyExchanger);
2695
2776
  if (!outcome.ok) {
2696
2777
  return send401(req, res, outcome.description);
2697
2778
  }
@@ -2728,7 +2809,7 @@ async function runHttpServer() {
2728
2809
  };
2729
2810
  const challengeOrMethodNotAllowed = async (req, res) => {
2730
2811
  if (config.authMode === 'oauth' && oauthVerifier) {
2731
- const outcome = await verifyRequestBearer(req, oauthVerifier);
2812
+ const outcome = await verifyRequestBearer(req, oauthVerifier, apiKeyExchanger);
2732
2813
  if (!outcome.ok) {
2733
2814
  return send401(req, res, outcome.description);
2734
2815
  }
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACvD,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,wBAAwB,EAAE,MAAM,CAAC;IACjC,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,OAAO,EAAE;QACP,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QACzB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;QACpC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,wBAAwB,EAAE,MAAM,GAAG,IAAI,CAAC;QACxC,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;KACpC,CAAC;IACF,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACtC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACtC,OAAO,EAAE,mBAAmB,CAAC;CAC9B,CAAC;AAeF,eAAO,MAAM,sBAAsB,YAAY,CAAC;AAEhD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAE1D;AAaD,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,OAAO,GAAG,MAAM,CAIrE;AAED,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAK5F;AAOD,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,OAAO,GAAG,MAAM,CAIlE;AAED,wBAAgB,gCAAgC,CAAC,UAAU,GAAE,OAAyB,GAAG,MAAM,CAG9F;AAED,MAAM,MAAM,0BAA0B,GAAG;IACvC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,eAAe,CAAC;CAChE,CAAC;AAEF,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,OAAO,EACpB,UAAU,GAAE,OAAyB,GACpC,0BAA0B,CA2B5B;AAED,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,OAAO,GAAG,MAAM,EAAE,CAMrE;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,CAOxE;AAID,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CAUlE;AAED,MAAM,MAAM,6BAA6B,GAAG;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,4BAA4B,CAAC;IACrC,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,wBAAwB,EAAE,MAAM,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG;IAC1C,KAAK,EAAE,qBAAqB,CAAC;IAC7B,WAAW,EAAE,6BAA6B,GAAG,IAAI,CAAC;CACnD,CAAC;AAcF,wBAAsB,+BAA+B,CAAC,IAAI,EAAE;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,KAAK,EAAE,qBAAqB,CAAC;IAC7B,EAAE,CAAC,EAAE;QAAE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IACjE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB,GAAG,OAAO,CAAC,6BAA6B,CAAC,CA+BzC;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,UAG3E;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA8CjC;AA+BD,wBAAsB,UAAU,CAAC,IAAI,EAAE;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,qBAAqB,CAAC;CAC9B,iBAGA;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,CAAC,OAAO,EAAE,qBAAqB,KAAK,qBAAqB,CAAC;CACpE,kCASA"}
package/dist/state.js CHANGED
@@ -204,13 +204,63 @@ export async function readState(args) {
204
204
  catch (error) {
205
205
  if (error?.code === 'ENOENT')
206
206
  return buildDefaultState(args.profile);
207
+ if (error instanceof SyntaxError) {
208
+ // File exists but isn't valid JSON — a legacy torn/truncated write (from
209
+ // before atomicWriteFile) or external corruption. Don't hard-fail every
210
+ // tool call; preserve the bad file for forensics and fall back to a fresh
211
+ // default so the agent can re-provision instead of bricking.
212
+ const corruptPath = `${filePath}.corrupt-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
213
+ try {
214
+ await fs.rename(filePath, corruptPath);
215
+ }
216
+ catch { }
217
+ try {
218
+ console.error(`[hi-mcp] state file was corrupt; quarantined to ${corruptPath} and reset to default`);
219
+ }
220
+ catch { }
221
+ return buildDefaultState(args.profile);
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+ // Crash-safe atomic file write. A plain fs.writeFile truncates the target up
227
+ // front and streams bytes in — a SIGKILL / OOM / power loss mid-write leaves a
228
+ // 0-byte or half-written file. For the credential/identity file that means the
229
+ // agent's client_id/client_secret silently vanish and the user is forced to
230
+ // re-login (the "codex creds mysteriously disappeared" class of bug). Instead:
231
+ // write to a unique temp file, fsync its contents to disk, then atomically
232
+ // rename it over the target. A concurrent reader always sees either the old
233
+ // complete file or the new complete file — never a torn one. mode 0o600 because
234
+ // the payload carries client_secret.
235
+ async function atomicWriteFile(filePath, data) {
236
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
237
+ const tmpPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
238
+ let fh;
239
+ try {
240
+ fh = await fs.open(tmpPath, 'w', 0o600);
241
+ await fh.writeFile(data, 'utf8');
242
+ await fh.sync();
243
+ await fh.close();
244
+ fh = undefined;
245
+ await fs.rename(tmpPath, filePath);
246
+ }
247
+ catch (error) {
248
+ if (fh) {
249
+ try {
250
+ await fh.close();
251
+ }
252
+ catch { }
253
+ }
254
+ try {
255
+ await fs.unlink(tmpPath);
256
+ }
257
+ catch { }
207
258
  throw error;
208
259
  }
209
260
  }
210
261
  export async function writeState(args) {
211
262
  const filePath = resolveStateFile(args);
212
- await fs.mkdir(path.dirname(filePath), { recursive: true });
213
- await fs.writeFile(filePath, `${JSON.stringify(args.state, null, 2)}\n`, 'utf8');
263
+ await atomicWriteFile(filePath, `${JSON.stringify(args.state, null, 2)}\n`);
214
264
  }
215
265
  export async function updateState(args) {
216
266
  const current = await readState(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hirey/hi-mcp-server",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "dist/server.js",