@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.
- package/dist/apiKey.d.ts +9 -0
- package/dist/apiKey.d.ts.map +1 -0
- package/dist/apiKey.js +51 -0
- package/dist/apiKeyExchange.d.ts +9 -0
- package/dist/apiKeyExchange.d.ts.map +1 -0
- package/dist/apiKeyExchange.js +65 -0
- package/dist/oauthRequestAuth.d.ts +1 -1
- package/dist/oauthRequestAuth.d.ts.map +1 -1
- package/dist/oauthRequestAuth.js +16 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +83 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +52 -2
- package/package.json +1 -1
package/dist/apiKey.d.ts
ADDED
|
@@ -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
|
|
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":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAEnC,OAAO,EAAiC,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"oauthRequestAuth.d.ts","sourceRoot":"","sources":["../src/oauthRequestAuth.ts"],"names":[],"mappings":"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"}
|
package/dist/oauthRequestAuth.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/server.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
}
|
package/dist/state.d.ts.map
CHANGED
|
@@ -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":"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"}
|
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
|
|
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);
|