@gethmy/mcp 2.9.4 → 2.9.5
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/README.md +15 -15
- package/dist/cli.js +674 -275
- package/dist/index.js +530 -205
- package/dist/lib/api-client.js +352 -182
- package/dist/lib/config.js +37 -26
- package/package.json +2 -2
- package/src/api-client.ts +50 -4
- package/src/cli.ts +13 -2
- package/src/config.ts +53 -25
- package/src/graph-expansion.ts +7 -4
- package/src/http.ts +3 -14
- package/src/memory-floor.ts +2 -1
- package/src/oauth-login.ts +288 -0
- package/src/oauth-refresh.ts +209 -0
- package/src/server.ts +6 -6
- package/src/skills.ts +4 -2
- package/src/tui/setup.ts +106 -16
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Loopback + PKCE OAuth login for `npx @gethmy/mcp setup` (card #364).
|
|
2
|
+
//
|
|
3
|
+
// Replaces passing a long-lived, unscoped API key through argv. The CLI binds
|
|
4
|
+
// an ephemeral loopback port, dynamically registers a public OAuth client whose
|
|
5
|
+
// redirect_uri is that exact `http://127.0.0.1:{port}/callback`, opens the
|
|
6
|
+
// browser to the consent page, and exchanges the returned code (+ PKCE verifier)
|
|
7
|
+
// for a workspace-scoped, expiring access/refresh token pair.
|
|
8
|
+
//
|
|
9
|
+
// No backend change: the OAuth authorization server already requires PKCE S256,
|
|
10
|
+
// already permits loopback redirect URIs (RFC 8252), and `harmony-api` already
|
|
11
|
+
// accepts `hmy_at_` access tokens on the `X-API-Key` header. See card #364.
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
import type { AddressInfo } from "node:net";
|
|
17
|
+
|
|
18
|
+
// The MCP resource the token is bound to (RFC 8707). harmony-api does not check
|
|
19
|
+
// audience, so a token bound to the MCP resource authenticates against the REST
|
|
20
|
+
// API too. Overridable for self-host / staging.
|
|
21
|
+
const MCP_RESOURCE_URL =
|
|
22
|
+
process.env.HARMONY_MCP_RESOURCE || "https://mcp.gethmy.com";
|
|
23
|
+
|
|
24
|
+
// How long to wait for the user to complete browser consent before giving up.
|
|
25
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export interface OAuthTokens {
|
|
28
|
+
accessToken: string;
|
|
29
|
+
refreshToken: string;
|
|
30
|
+
/** Epoch ms at which the access token expires. */
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
clientId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function base64Url(buf: Buffer): string {
|
|
36
|
+
return buf
|
|
37
|
+
.toString("base64")
|
|
38
|
+
.replace(/\+/g, "-")
|
|
39
|
+
.replace(/\//g, "_")
|
|
40
|
+
.replace(/=+$/, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* RFC 7636 PKCE pair. The verifier is 43 chars of base64url (32 random bytes),
|
|
45
|
+
* which is within the spec's 43–128 unreserved-char range.
|
|
46
|
+
*/
|
|
47
|
+
export function generatePkce(): { verifier: string; challenge: string } {
|
|
48
|
+
const verifier = base64Url(randomBytes(32));
|
|
49
|
+
const challenge = base64Url(createHash("sha256").update(verifier).digest());
|
|
50
|
+
return { verifier, challenge };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Derive the OAuth endpoint base (`https://host/oauth`) from the REST apiUrl. */
|
|
54
|
+
export function oauthBaseFromApiUrl(apiUrl: string): string {
|
|
55
|
+
return `${new URL(apiUrl).origin}/oauth`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Best-effort cross-platform "open this URL in the default browser". */
|
|
59
|
+
function openBrowser(url: string): void {
|
|
60
|
+
const platform = process.platform;
|
|
61
|
+
const cmd =
|
|
62
|
+
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
63
|
+
// cmd.exe treats `&` as a command separator, so the authorize URL's
|
|
64
|
+
// `&`-joined query params (PKCE challenge, state, …) must be caret-escaped;
|
|
65
|
+
// otherwise it opens a URL truncated at the first param and runs the rest as
|
|
66
|
+
// bogus commands, silently breaking auto-open (the printed URL still works).
|
|
67
|
+
const args =
|
|
68
|
+
platform === "win32" ? ["/c", "start", "", url.replace(/&/g, "^&")] : [url];
|
|
69
|
+
try {
|
|
70
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
71
|
+
child.on("error", () => {
|
|
72
|
+
/* swallow — the printed URL is the fallback */
|
|
73
|
+
});
|
|
74
|
+
child.unref();
|
|
75
|
+
} catch {
|
|
76
|
+
// Headless / no browser — the caller already printed the URL.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface DcrResponse {
|
|
81
|
+
client_id: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface TokenResponse {
|
|
85
|
+
access_token: string;
|
|
86
|
+
refresh_token: string;
|
|
87
|
+
expires_in: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Harmony connected</title></head>
|
|
91
|
+
<body style="font-family:system-ui,sans-serif;background:#0f1729;color:#dce4ef;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
92
|
+
<div style="text-align:center"><h1 style="color:#57b8a5">✓ Connected to Harmony</h1>
|
|
93
|
+
<p>You can close this tab and return to your terminal.</p></div></body></html>`;
|
|
94
|
+
|
|
95
|
+
const FAILURE_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Harmony</title></head>
|
|
96
|
+
<body style="font-family:system-ui,sans-serif;background:#0f1729;color:#dce4ef;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
97
|
+
<div style="text-align:center"><h1 style="color:#ff6b6b">Authorization failed</h1>
|
|
98
|
+
<p>Return to your terminal for details.</p></div></body></html>`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run the full loopback + PKCE browser login. Resolves with the token pair, or
|
|
102
|
+
* rejects on timeout / consent failure. `onUrl` receives the authorize URL so
|
|
103
|
+
* the caller can print it as a no-browser fallback.
|
|
104
|
+
*/
|
|
105
|
+
export async function loginWithBrowser(opts: {
|
|
106
|
+
apiUrl: string;
|
|
107
|
+
workspaceId?: string;
|
|
108
|
+
onUrl?: (url: string) => void;
|
|
109
|
+
}): Promise<OAuthTokens> {
|
|
110
|
+
const base = oauthBaseFromApiUrl(opts.apiUrl);
|
|
111
|
+
const { verifier, challenge } = generatePkce();
|
|
112
|
+
const state = base64Url(randomBytes(16));
|
|
113
|
+
|
|
114
|
+
// 1. Bind an ephemeral loopback port BEFORE registering, so the redirect_uri
|
|
115
|
+
// we register is the exact one the server will receive.
|
|
116
|
+
const { server, port, waitForCode } = await startLoopbackServer(state);
|
|
117
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// 2. Dynamically register a throwaway public client for this exact redirect.
|
|
121
|
+
const clientId = await registerClient(base, redirectUri);
|
|
122
|
+
|
|
123
|
+
// 3. Build the authorize URL and open the browser.
|
|
124
|
+
const authorizeUrl = new URL(`${base}/authorize`);
|
|
125
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
126
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
127
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
128
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
129
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
130
|
+
authorizeUrl.searchParams.set("state", state);
|
|
131
|
+
authorizeUrl.searchParams.set("scope", "mcp");
|
|
132
|
+
authorizeUrl.searchParams.set("resource", MCP_RESOURCE_URL);
|
|
133
|
+
if (opts.workspaceId) {
|
|
134
|
+
authorizeUrl.searchParams.set("workspace_id", opts.workspaceId);
|
|
135
|
+
}
|
|
136
|
+
const authUrlStr = authorizeUrl.toString();
|
|
137
|
+
opts.onUrl?.(authUrlStr);
|
|
138
|
+
openBrowser(authUrlStr);
|
|
139
|
+
|
|
140
|
+
// 4. Wait for the browser redirect to our loopback listener.
|
|
141
|
+
const code = await waitForCode;
|
|
142
|
+
|
|
143
|
+
// 5. Exchange the code (+ verifier) for tokens.
|
|
144
|
+
return await exchangeCode(base, {
|
|
145
|
+
code,
|
|
146
|
+
redirectUri,
|
|
147
|
+
clientId,
|
|
148
|
+
verifier,
|
|
149
|
+
});
|
|
150
|
+
} finally {
|
|
151
|
+
server.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function registerClient(
|
|
156
|
+
base: string,
|
|
157
|
+
redirectUri: string,
|
|
158
|
+
): Promise<string> {
|
|
159
|
+
const res = await fetch(`${base}/register`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
client_name: "Harmony CLI (setup)",
|
|
164
|
+
redirect_uris: [redirectUri],
|
|
165
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
166
|
+
response_types: ["code"],
|
|
167
|
+
token_endpoint_auth_method: "none",
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
const body = (await res.json().catch(() => ({}))) as Partial<DcrResponse> & {
|
|
171
|
+
error_description?: string;
|
|
172
|
+
};
|
|
173
|
+
if (!res.ok || !body.client_id) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
body.error_description ||
|
|
176
|
+
`Could not register OAuth client (HTTP ${res.status})`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return body.client_id;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function exchangeCode(
|
|
183
|
+
base: string,
|
|
184
|
+
params: {
|
|
185
|
+
code: string;
|
|
186
|
+
redirectUri: string;
|
|
187
|
+
clientId: string;
|
|
188
|
+
verifier: string;
|
|
189
|
+
},
|
|
190
|
+
): Promise<OAuthTokens> {
|
|
191
|
+
const res = await fetch(`${base}/token`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
194
|
+
body: new URLSearchParams({
|
|
195
|
+
grant_type: "authorization_code",
|
|
196
|
+
code: params.code,
|
|
197
|
+
redirect_uri: params.redirectUri,
|
|
198
|
+
client_id: params.clientId,
|
|
199
|
+
code_verifier: params.verifier,
|
|
200
|
+
}).toString(),
|
|
201
|
+
});
|
|
202
|
+
const body = (await res
|
|
203
|
+
.json()
|
|
204
|
+
.catch(() => ({}))) as Partial<TokenResponse> & {
|
|
205
|
+
error_description?: string;
|
|
206
|
+
};
|
|
207
|
+
if (!res.ok || !body.access_token || !body.refresh_token) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
body.error_description || `Token exchange failed (HTTP ${res.status})`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
accessToken: body.access_token,
|
|
214
|
+
refreshToken: body.refresh_token,
|
|
215
|
+
expiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
|
|
216
|
+
clientId: params.clientId,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Start a one-shot loopback HTTP server. Resolves the `waitForCode` promise when
|
|
222
|
+
* `/callback?code=…&state=…` arrives with a matching `state`. Rejects on an
|
|
223
|
+
* OAuth error redirect, a state mismatch, or timeout.
|
|
224
|
+
*/
|
|
225
|
+
function startLoopbackServer(expectedState: string): Promise<{
|
|
226
|
+
server: ReturnType<typeof createServer>;
|
|
227
|
+
port: number;
|
|
228
|
+
waitForCode: Promise<string>;
|
|
229
|
+
}> {
|
|
230
|
+
return new Promise((resolveOuter, rejectOuter) => {
|
|
231
|
+
let resolveCode: (code: string) => void;
|
|
232
|
+
let rejectCode: (err: Error) => void;
|
|
233
|
+
const waitForCode = new Promise<string>((res, rej) => {
|
|
234
|
+
resolveCode = res;
|
|
235
|
+
rejectCode = rej;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const timeout = setTimeout(() => {
|
|
239
|
+
rejectCode(
|
|
240
|
+
new Error("Timed out waiting for browser authorization (5 min)."),
|
|
241
|
+
);
|
|
242
|
+
}, LOGIN_TIMEOUT_MS);
|
|
243
|
+
// Don't let the pending timer keep the process alive on its own.
|
|
244
|
+
timeout.unref?.();
|
|
245
|
+
|
|
246
|
+
const server = createServer((req, res) => {
|
|
247
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
248
|
+
if (url.pathname !== "/callback") {
|
|
249
|
+
res.writeHead(404).end();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
const error = url.searchParams.get("error");
|
|
254
|
+
const code = url.searchParams.get("code");
|
|
255
|
+
const state = url.searchParams.get("state");
|
|
256
|
+
|
|
257
|
+
const fail = (msg: string) => {
|
|
258
|
+
res.writeHead(400, { "Content-Type": "text/html" }).end(FAILURE_HTML);
|
|
259
|
+
rejectCode(new Error(msg));
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (error) {
|
|
263
|
+
return fail(
|
|
264
|
+
`Authorization denied: ${url.searchParams.get("error_description") || error}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (!state || state !== expectedState) {
|
|
268
|
+
return fail("State mismatch — possible CSRF, aborting.");
|
|
269
|
+
}
|
|
270
|
+
if (!code) {
|
|
271
|
+
return fail("No authorization code returned.");
|
|
272
|
+
}
|
|
273
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end(SUCCESS_HTML);
|
|
274
|
+
resolveCode(code);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
server.on("error", (err) => {
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
rejectOuter(err);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Bind to loopback on an ephemeral port (0 → OS assigns a free one).
|
|
283
|
+
server.listen(0, "127.0.0.1", () => {
|
|
284
|
+
const addr = server.address() as AddressInfo;
|
|
285
|
+
resolveOuter({ server, port: addr.port, waitForCode });
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// OAuth access-token refresh for the running MCP server (card #364).
|
|
2
|
+
//
|
|
3
|
+
// The stdio server owns its credential (unlike the remote server, which gets a
|
|
4
|
+
// fresh Bearer per request). Refresh is reactive: when harmony-api returns 401
|
|
5
|
+
// the api-client calls this to exchange the rotating refresh token for a new
|
|
6
|
+
// pair, persist it, and retry. There is no proactive near-expiry refresh — the
|
|
7
|
+
// stored access token is sent until a 401 proves it stale, which is why
|
|
8
|
+
// `oauthExpiresAt` is persisted but not consulted here. Returns the new access
|
|
9
|
+
// token, or null if refresh is impossible (no refresh token) or the grant was
|
|
10
|
+
// rejected (token revoked / expired — the user must re-run `setup`).
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
closeSync,
|
|
14
|
+
openSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { getConfigDir, loadConfig, saveConfig } from "./config.js";
|
|
22
|
+
import { oauthBaseFromApiUrl } from "./oauth-login.js";
|
|
23
|
+
|
|
24
|
+
interface RefreshResponse {
|
|
25
|
+
access_token: string;
|
|
26
|
+
refresh_token: string;
|
|
27
|
+
expires_in: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// In-process dedup — a burst of in-flight requests all hitting 401 should
|
|
31
|
+
// trigger one refresh, not N (the first consumes the refresh token; the rest
|
|
32
|
+
// would trip reuse detection and revoke the whole family).
|
|
33
|
+
let inFlight: Promise<string | null> | null = null;
|
|
34
|
+
|
|
35
|
+
// Cross-process serialization. Every Claude Code session spawns its own stdio
|
|
36
|
+
// MCP process, and they all share ~/.harmony-mcp/config.json. The refresh
|
|
37
|
+
// token rotates on use, so two processes refreshing concurrently would each
|
|
38
|
+
// POST a refresh: the first consumes the token, the second replays a now-
|
|
39
|
+
// consumed token and trips the server's reuse detection, revoking the whole
|
|
40
|
+
// grant family and logging every session out. A lockfile mutex serializes the
|
|
41
|
+
// refresh across processes, and the holder re-reads config inside the lock so a
|
|
42
|
+
// process that lost the race adopts the freshly-rotated token instead of
|
|
43
|
+
// replaying its stale one.
|
|
44
|
+
const LOCK_FILENAME = "refresh.lock";
|
|
45
|
+
// A live holder releases in well under a second; this only bounds how long we
|
|
46
|
+
// wait on a peer that crashed mid-refresh before we treat its lock as stale.
|
|
47
|
+
const LOCK_STALE_MS = 30_000;
|
|
48
|
+
// Acquire timeout sits above the stale threshold so a dead holder's lock is
|
|
49
|
+
// always taken over rather than falling through unlocked.
|
|
50
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 35_000;
|
|
51
|
+
const LOCK_RETRY_MS = 100;
|
|
52
|
+
|
|
53
|
+
function lockPath(): string {
|
|
54
|
+
return join(getConfigDir(), LOCK_FILENAME);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sleep(ms: number): Promise<void> {
|
|
58
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Acquire the cross-process refresh lock, run `fn`, and release. If the lock
|
|
63
|
+
* can't be acquired within the timeout (a peer wedged without crashing), `fn`
|
|
64
|
+
* runs unlocked anyway — a stuck peer must not permanently block refresh, and
|
|
65
|
+
* the in-lock re-read in `doRefresh` still guards the common race.
|
|
66
|
+
*/
|
|
67
|
+
async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
68
|
+
const path = lockPath();
|
|
69
|
+
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
|
|
70
|
+
let held = false;
|
|
71
|
+
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
try {
|
|
74
|
+
// wx: exclusive create — fails with EEXIST if another process holds it.
|
|
75
|
+
const fd = openSync(path, "wx");
|
|
76
|
+
writeSync(fd, String(process.pid));
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
held = true;
|
|
79
|
+
break;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
82
|
+
// Can't even attempt the lock (e.g. dir perms) — proceed unlocked.
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
// Lock exists. Take it over if it's stale (holder crashed without
|
|
86
|
+
// releasing); otherwise back off and retry.
|
|
87
|
+
try {
|
|
88
|
+
const age = Date.now() - statSync(path).mtimeMs;
|
|
89
|
+
if (age > LOCK_STALE_MS) {
|
|
90
|
+
// Claim the stale lock by atomically renaming it aside, rather than
|
|
91
|
+
// stat-then-rmSync: that gap let two processes both delete a stale
|
|
92
|
+
// lock and both proceed, re-introducing the concurrent refresh this
|
|
93
|
+
// mutex exists to prevent. rename is atomic, so only the process
|
|
94
|
+
// whose rename wins owns the takeover; a loser's rename throws
|
|
95
|
+
// (the file is already gone) and it falls back to the retry loop.
|
|
96
|
+
const claim = `${path}.stale.${process.pid}`;
|
|
97
|
+
try {
|
|
98
|
+
renameSync(path, claim);
|
|
99
|
+
rmSync(claim, { force: true });
|
|
100
|
+
} catch {
|
|
101
|
+
// Lost the takeover race — retry against the winner's fresh lock.
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Lock vanished between open and stat — retry immediately.
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
await sleep(LOCK_RETRY_MS);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
return await fn();
|
|
115
|
+
} finally {
|
|
116
|
+
if (held) {
|
|
117
|
+
try {
|
|
118
|
+
rmSync(path, { force: true });
|
|
119
|
+
} catch {
|
|
120
|
+
// Best-effort release; stale takeover covers a missed unlink.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Refresh the stored OAuth tokens and persist the new pair. Concurrent callers
|
|
128
|
+
* share a single in-flight refresh in-process and serialize across processes.
|
|
129
|
+
* Returns the new access token or null.
|
|
130
|
+
*/
|
|
131
|
+
export function refreshOAuthToken(): Promise<string | null> {
|
|
132
|
+
if (inFlight) return inFlight;
|
|
133
|
+
inFlight = doRefresh().finally(() => {
|
|
134
|
+
inFlight = null;
|
|
135
|
+
});
|
|
136
|
+
return inFlight;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function doRefresh(): Promise<string | null> {
|
|
140
|
+
// Snapshot the refresh token we intend to rotate before contending for the
|
|
141
|
+
// lock, so we can tell inside the lock whether a peer rotated ahead of us.
|
|
142
|
+
const before = loadConfig();
|
|
143
|
+
if (!before.oauthRefreshToken || !before.oauthClientId) return null;
|
|
144
|
+
|
|
145
|
+
return withRefreshLock(async () => {
|
|
146
|
+
// Re-read inside the lock: a peer may have rotated while we waited. If the
|
|
147
|
+
// stored refresh token changed, our snapshot is now consumed — adopt the
|
|
148
|
+
// peer's fresh access token instead of replaying ours (which would trip
|
|
149
|
+
// reuse detection and revoke the whole family).
|
|
150
|
+
const config = loadConfig();
|
|
151
|
+
if (
|
|
152
|
+
config.oauthRefreshToken !== before.oauthRefreshToken &&
|
|
153
|
+
config.oauthAccessToken
|
|
154
|
+
) {
|
|
155
|
+
return config.oauthAccessToken;
|
|
156
|
+
}
|
|
157
|
+
if (!config.oauthRefreshToken || !config.oauthClientId) return null;
|
|
158
|
+
|
|
159
|
+
const base = oauthBaseFromApiUrl(config.apiUrl);
|
|
160
|
+
let res: Response;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetch(`${base}/token`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
165
|
+
body: new URLSearchParams({
|
|
166
|
+
grant_type: "refresh_token",
|
|
167
|
+
refresh_token: config.oauthRefreshToken,
|
|
168
|
+
client_id: config.oauthClientId,
|
|
169
|
+
}).toString(),
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// Network error — leave stored tokens intact so a later attempt can retry.
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
// Only invalid_grant (revoked / expired / rotated refresh token) is
|
|
178
|
+
// terminal: clear the dead OAuth credentials so the runtime stops retrying
|
|
179
|
+
// and surfaces a re-auth prompt. A transient 4xx (429 rate-limit, an
|
|
180
|
+
// intermediary 400) or 5xx must leave the stored tokens intact so a later
|
|
181
|
+
// attempt can retry — wiping on the broad 4xx class would force a full
|
|
182
|
+
// re-setup on a passing rate-limit. The token endpoint returns
|
|
183
|
+
// { error: "invalid_grant" } for every terminal case (oauth/index.ts).
|
|
184
|
+
const errorCode = await res
|
|
185
|
+
.json()
|
|
186
|
+
.then((b) => (b as { error?: string } | null)?.error ?? null)
|
|
187
|
+
.catch(() => null);
|
|
188
|
+
if (errorCode === "invalid_grant") {
|
|
189
|
+
saveConfig({
|
|
190
|
+
oauthAccessToken: null,
|
|
191
|
+
oauthRefreshToken: null,
|
|
192
|
+
oauthExpiresAt: null,
|
|
193
|
+
oauthClientId: null,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const body = (await res.json().catch(() => null)) as RefreshResponse | null;
|
|
200
|
+
if (!body?.access_token || !body.refresh_token) return null;
|
|
201
|
+
|
|
202
|
+
saveConfig({
|
|
203
|
+
oauthAccessToken: body.access_token,
|
|
204
|
+
oauthRefreshToken: body.refresh_token,
|
|
205
|
+
oauthExpiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
|
|
206
|
+
});
|
|
207
|
+
return body.access_token;
|
|
208
|
+
});
|
|
209
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -2287,7 +2287,7 @@ async function handleToolCall(
|
|
|
2287
2287
|
case "harmony_delete_label": {
|
|
2288
2288
|
const labelId = z.string().uuid().parse(args.labelId);
|
|
2289
2289
|
const result = await client.deleteLabel(labelId);
|
|
2290
|
-
return {
|
|
2290
|
+
return { ...result };
|
|
2291
2291
|
}
|
|
2292
2292
|
|
|
2293
2293
|
case "harmony_add_label_to_card": {
|
|
@@ -2612,7 +2612,7 @@ async function handleToolCall(
|
|
|
2612
2612
|
language: (args.language as string) || "en-US",
|
|
2613
2613
|
execute: args.execute === true,
|
|
2614
2614
|
});
|
|
2615
|
-
return { success: true, ...result };
|
|
2615
|
+
return { success: true, ...(result as Record<string, unknown>) };
|
|
2616
2616
|
}
|
|
2617
2617
|
|
|
2618
2618
|
// Agent context operations
|
|
@@ -2692,8 +2692,8 @@ async function handleToolCall(
|
|
|
2692
2692
|
const workspaceId = deps.getActiveWorkspaceId();
|
|
2693
2693
|
if (workspaceId) {
|
|
2694
2694
|
const { members } = await client.getWorkspaceMembers(workspaceId);
|
|
2695
|
-
const user = members.find(
|
|
2696
|
-
(m
|
|
2695
|
+
const user = (members as Array<{ id: string; email: string }>).find(
|
|
2696
|
+
(m) => m.email === userEmail,
|
|
2697
2697
|
);
|
|
2698
2698
|
if (user) {
|
|
2699
2699
|
await client.updateCard(cardId, { assigneeId: user.id });
|
|
@@ -3815,8 +3815,8 @@ async function handleToolCall(
|
|
|
3815
3815
|
content: summary,
|
|
3816
3816
|
type: "lesson",
|
|
3817
3817
|
scope: "project",
|
|
3818
|
-
workspaceId,
|
|
3819
|
-
|
|
3818
|
+
workspace_id: workspaceId,
|
|
3819
|
+
project_id: plan.project_id,
|
|
3820
3820
|
tags: ["plan", "archived"],
|
|
3821
3821
|
confidence: 0.8,
|
|
3822
3822
|
});
|
package/src/skills.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
-
import {
|
|
10
|
+
import { getClient } from "./api-client.js";
|
|
11
11
|
import { areSkillsInstalled, isConfigured } from "./config.js";
|
|
12
12
|
import { loadHmyConfig } from "./hmy-config.js";
|
|
13
13
|
|
|
@@ -286,7 +286,9 @@ export async function refreshSkills(
|
|
|
286
286
|
const status = areSkillsInstalled();
|
|
287
287
|
if (!status.installed) return { updated: false };
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
// Shared singleton so the startup skills check inherits the refresh-on-401
|
|
290
|
+
// path; a bare client couldn't refresh a stale OAuth token (card #364).
|
|
291
|
+
const client = getClient();
|
|
290
292
|
const versionInfo = await client.fetchSkillsVersion();
|
|
291
293
|
// We hit the network — reset the TTL window regardless of outcome.
|
|
292
294
|
recordCheck();
|