@botcord/daemon 0.2.9 → 0.2.10
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/control-channel.js +20 -3
- package/dist/doctor.js +3 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +5 -1
- package/dist/gateway/runtimes/hermes-agent.js +27 -3
- package/dist/gateway/runtimes/registry.d.ts +6 -0
- package/dist/gateway/runtimes/registry.js +2 -0
- package/dist/user-auth.d.ts +9 -0
- package/dist/user-auth.js +53 -4
- package/package.json +1 -1
- package/src/control-channel.ts +19 -2
- package/src/doctor.ts +3 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +29 -1
- package/src/gateway/runtimes/hermes-agent.ts +36 -3
- package/src/gateway/runtimes/registry.ts +9 -0
- package/src/user-auth.ts +53 -4
package/dist/control-channel.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import WebSocket from "ws";
|
|
10
10
|
import { buildDaemonWebSocketUrl, CONTROL_FRAME_TYPES, jcsCanonicalize, resolveHubControlPublicKey, verifyEd25519, } from "@botcord/protocol-core";
|
|
11
11
|
import { log as daemonLog } from "./log.js";
|
|
12
|
-
import { writeAuthExpiredFlag, } from "./user-auth.js";
|
|
12
|
+
import { AuthRefreshRejectedError, writeAuthExpiredFlag, } from "./user-auth.js";
|
|
13
13
|
/** Exponential backoff plan for transient disconnects. */
|
|
14
14
|
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
15
15
|
const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
@@ -91,8 +91,18 @@ export class ControlChannel {
|
|
|
91
91
|
});
|
|
92
92
|
this.connectInflight = this.connect().catch((err) => {
|
|
93
93
|
// Initial connect failure surfaces to the caller; subsequent
|
|
94
|
-
// reconnects are handled opaquely inside onClose.
|
|
95
|
-
|
|
94
|
+
// reconnects are handled opaquely inside onClose. A refresh-rejected
|
|
95
|
+
// error means the refresh token itself is dead — no point retrying;
|
|
96
|
+
// writeAuthExpiredFlag was already called in user-auth.refresh().
|
|
97
|
+
if (err instanceof AuthRefreshRejectedError) {
|
|
98
|
+
this.stopRequested = true;
|
|
99
|
+
daemonLog.warn("control-channel: refresh rejected; stopping (re-login required)", {
|
|
100
|
+
status: err.status,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this.scheduleReconnect(err);
|
|
105
|
+
}
|
|
96
106
|
throw err;
|
|
97
107
|
});
|
|
98
108
|
try {
|
|
@@ -223,6 +233,13 @@ export class ControlChannel {
|
|
|
223
233
|
scheduleReconnect(err) {
|
|
224
234
|
if (this.stopRequested)
|
|
225
235
|
return;
|
|
236
|
+
if (err instanceof AuthRefreshRejectedError) {
|
|
237
|
+
this.stopRequested = true;
|
|
238
|
+
daemonLog.warn("control-channel: refresh rejected; halting reconnect (re-login required)", {
|
|
239
|
+
status: err.status,
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
226
243
|
const attempt = this.reconnectAttempts;
|
|
227
244
|
this.reconnectAttempts = attempt + 1;
|
|
228
245
|
const delay = this.backoff[Math.min(attempt, this.backoff.length - 1)];
|
package/dist/doctor.js
CHANGED
|
@@ -156,6 +156,9 @@ export function renderDoctor(input) {
|
|
|
156
156
|
const r = rows[i];
|
|
157
157
|
const e = input.runtimes[i];
|
|
158
158
|
lines.push(`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`);
|
|
159
|
+
if (!e.result.available && e.installHint) {
|
|
160
|
+
lines.push(` → ${e.installHint}`);
|
|
161
|
+
}
|
|
159
162
|
if (e.endpoints && e.endpoints.length > 0) {
|
|
160
163
|
for (const ep of e.endpoints) {
|
|
161
164
|
const mark = ep.reachable ? "✓" : "✗";
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { AcpRuntimeAdapter, type AcpPermissionRequest, type AcpPermissionResponse, type AcpUpdateCtx, type AcpUpdateParams } from "./acp-stream.js";
|
|
2
2
|
import { type ProbeDeps } from "./probe.js";
|
|
3
3
|
import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the `hermes-acp` executable. Tries PATH first, then falls back to
|
|
6
|
+
* the upstream install.sh's private venv location (`~/.hermes/...`) before
|
|
7
|
+
* giving up. `BOTCORD_HERMES_AGENT_BIN` always wins via the adapter override.
|
|
8
|
+
*/
|
|
5
9
|
export declare function resolveHermesAcpCommand(deps?: ProbeDeps): string | null;
|
|
6
10
|
/** Probe whether `hermes-acp` is installed and report its version. */
|
|
7
11
|
export declare function probeHermesAgent(deps?: ProbeDeps): RuntimeProbeResult;
|
|
@@ -3,10 +3,34 @@ import path from "node:path";
|
|
|
3
3
|
import { agentHermesHomeDir, agentHermesWorkspaceDir, ensureAgentHermesWorkspace, } from "../../agent-workspace.js";
|
|
4
4
|
import { buildCliEnv } from "../cli-resolver.js";
|
|
5
5
|
import { AcpRuntimeAdapter, } from "./acp-stream.js";
|
|
6
|
-
import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
|
|
7
|
-
/**
|
|
6
|
+
import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
|
|
7
|
+
/**
|
|
8
|
+
* Known absolute locations of the `hermes-acp` entry point when it is not on
|
|
9
|
+
* PATH. The upstream `scripts/install.sh` (curl|bash installer) installs a
|
|
10
|
+
* private virtualenv under `~/.hermes/hermes-agent/venv/` and only symlinks
|
|
11
|
+
* the user-facing `hermes` command into `~/.local/bin/` — the `hermes-acp`
|
|
12
|
+
* entry point stays inside the venv. Without a fallback, daemon's PATH-only
|
|
13
|
+
* probe misses every user who installed via the README-recommended script.
|
|
14
|
+
*/
|
|
15
|
+
const HERMES_ACP_FALLBACK_RELATIVE_PATHS = [
|
|
16
|
+
path.join(".hermes", "hermes-agent", "venv", "bin", "hermes-acp"),
|
|
17
|
+
];
|
|
18
|
+
const HERMES_ACP_FALLBACK_SYSTEM_PATHS = [
|
|
19
|
+
"/opt/hermes/hermes-agent/venv/bin/hermes-acp",
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the `hermes-acp` executable. Tries PATH first, then falls back to
|
|
23
|
+
* the upstream install.sh's private venv location (`~/.hermes/...`) before
|
|
24
|
+
* giving up. `BOTCORD_HERMES_AGENT_BIN` always wins via the adapter override.
|
|
25
|
+
*/
|
|
8
26
|
export function resolveHermesAcpCommand(deps = {}) {
|
|
9
|
-
|
|
27
|
+
const onPath = resolveCommandOnPath("hermes-acp", deps);
|
|
28
|
+
if (onPath)
|
|
29
|
+
return onPath;
|
|
30
|
+
return firstExistingPath([
|
|
31
|
+
...HERMES_ACP_FALLBACK_RELATIVE_PATHS.map((p) => resolveHomePath(p, deps)),
|
|
32
|
+
...HERMES_ACP_FALLBACK_SYSTEM_PATHS,
|
|
33
|
+
], deps);
|
|
10
34
|
}
|
|
11
35
|
/** Probe whether `hermes-acp` is installed and report its version. */
|
|
12
36
|
export function probeHermesAgent(deps = {}) {
|
|
@@ -23,6 +23,11 @@ export interface RuntimeModule {
|
|
|
23
23
|
* config loader rejects routing turns to this adapter.
|
|
24
24
|
*/
|
|
25
25
|
supportsRun?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Short, single-line install hint shown by `doctor` when the runtime
|
|
28
|
+
* probes as unavailable. Helps users recover without reading source.
|
|
29
|
+
*/
|
|
30
|
+
installHint?: string;
|
|
26
31
|
}
|
|
27
32
|
/** Built-in runtime module entry for Claude Code. */
|
|
28
33
|
export declare const claudeCodeModule: RuntimeModule;
|
|
@@ -58,6 +63,7 @@ export interface RuntimeProbeEntry {
|
|
|
58
63
|
binary: string;
|
|
59
64
|
supportsRun: boolean;
|
|
60
65
|
result: RuntimeProbeResult;
|
|
66
|
+
installHint?: string;
|
|
61
67
|
}
|
|
62
68
|
/** Probe every registered runtime and report installation status. */
|
|
63
69
|
export declare function detectRuntimes(): RuntimeProbeEntry[];
|
|
@@ -28,6 +28,7 @@ export const hermesAgentModule = {
|
|
|
28
28
|
envVar: "BOTCORD_HERMES_AGENT_BIN",
|
|
29
29
|
probe: () => probeHermesAgent(),
|
|
30
30
|
create: () => new HermesAgentAdapter(),
|
|
31
|
+
installHint: 'Install: pip install "hermes-agent[acp]" (or set BOTCORD_HERMES_AGENT_BIN to the absolute path of hermes-acp)',
|
|
31
32
|
};
|
|
32
33
|
/** Built-in runtime module entry for Gemini (probe-only stub). */
|
|
33
34
|
export const geminiModule = {
|
|
@@ -110,6 +111,7 @@ export function detectRuntimes() {
|
|
|
110
111
|
binary: m.binary,
|
|
111
112
|
supportsRun: m.supportsRun !== false,
|
|
112
113
|
result,
|
|
114
|
+
installHint: m.installHint,
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
return out;
|
package/dist/user-auth.d.ts
CHANGED
|
@@ -40,6 +40,15 @@ export declare function writeAuthExpiredFlag(file?: string): void;
|
|
|
40
40
|
export declare function clearAuthExpiredFlag(file?: string): void;
|
|
41
41
|
/** Returns true if the stored access token is within `windowMs` of expiry. */
|
|
42
42
|
export declare function isTokenNearExpiry(record: UserAuthRecord, windowMs?: number): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when the Hub rejects a refresh token (401/403). Signals that the
|
|
45
|
+
* user must re-login — reconnect loops should stop instead of hammering
|
|
46
|
+
* the refresh endpoint forever with a known-bad token.
|
|
47
|
+
*/
|
|
48
|
+
export declare class AuthRefreshRejectedError extends Error {
|
|
49
|
+
readonly status: number;
|
|
50
|
+
constructor(status: number, message: string);
|
|
51
|
+
}
|
|
43
52
|
/**
|
|
44
53
|
* Stateful helper that owns the in-memory copy of user-auth and knows how
|
|
45
54
|
* to refresh it. Used by the control channel so reconnects always carry
|
package/dist/user-auth.js
CHANGED
|
@@ -144,6 +144,19 @@ export function clearAuthExpiredFlag(file = AUTH_EXPIRED_FLAG_PATH) {
|
|
|
144
144
|
export function isTokenNearExpiry(record, windowMs = 60_000) {
|
|
145
145
|
return record.expiresAt - Date.now() <= windowMs;
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Thrown when the Hub rejects a refresh token (401/403). Signals that the
|
|
149
|
+
* user must re-login — reconnect loops should stop instead of hammering
|
|
150
|
+
* the refresh endpoint forever with a known-bad token.
|
|
151
|
+
*/
|
|
152
|
+
export class AuthRefreshRejectedError extends Error {
|
|
153
|
+
status;
|
|
154
|
+
constructor(status, message) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = "AuthRefreshRejectedError";
|
|
157
|
+
this.status = status;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
147
160
|
/**
|
|
148
161
|
* Stateful helper that owns the in-memory copy of user-auth and knows how
|
|
149
162
|
* to refresh it. Used by the control channel so reconnects always carry
|
|
@@ -197,13 +210,37 @@ export class UserAuthManager {
|
|
|
197
210
|
expiresInMs: current.expiresAt - Date.now(),
|
|
198
211
|
});
|
|
199
212
|
this.refreshInflight = (async () => {
|
|
200
|
-
|
|
213
|
+
// Refresh tokens rotate server-side. If another local process (e.g. a
|
|
214
|
+
// second daemon racing on the same user-auth.json) refreshed in the
|
|
215
|
+
// meantime, the on-disk refreshToken now differs from our in-memory
|
|
216
|
+
// copy — using the in-memory one would 401 because the server already
|
|
217
|
+
// invalidated it. Re-read disk first and adopt any newer record.
|
|
218
|
+
let basis = current;
|
|
219
|
+
try {
|
|
220
|
+
const onDisk = loadUserAuth(this.file);
|
|
221
|
+
if (onDisk && onDisk.refreshToken !== current.refreshToken) {
|
|
222
|
+
daemonLog.info("user-auth refresh: adopting newer on-disk token", {
|
|
223
|
+
userId: onDisk.userId,
|
|
224
|
+
expiresAt: onDisk.expiresAt,
|
|
225
|
+
});
|
|
226
|
+
this.record = onDisk;
|
|
227
|
+
if (!isTokenNearExpiry(onDisk))
|
|
228
|
+
return onDisk;
|
|
229
|
+
basis = onDisk;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
daemonLog.debug("user-auth refresh: disk reread failed (ignored)", {
|
|
234
|
+
error: err instanceof Error ? err.message : String(err),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const tok = await refreshDaemonToken(basis.hubUrl, basis.refreshToken);
|
|
201
238
|
const next = {
|
|
202
|
-
...
|
|
239
|
+
...basis,
|
|
203
240
|
accessToken: tok.accessToken,
|
|
204
241
|
refreshToken: tok.refreshToken,
|
|
205
242
|
expiresAt: Date.now() + tok.expiresIn * 1000,
|
|
206
|
-
hubUrl: tok.hubUrl ||
|
|
243
|
+
hubUrl: tok.hubUrl || basis.hubUrl,
|
|
207
244
|
};
|
|
208
245
|
saveUserAuth(next, this.file);
|
|
209
246
|
this.record = next;
|
|
@@ -213,10 +250,22 @@ export class UserAuthManager {
|
|
|
213
250
|
});
|
|
214
251
|
return next;
|
|
215
252
|
})().catch((err) => {
|
|
253
|
+
const status = typeof err.status === "number"
|
|
254
|
+
? (err.status)
|
|
255
|
+
: null;
|
|
256
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
216
257
|
daemonLog.warn("user-auth refresh: failed", {
|
|
217
258
|
userId: current.userId,
|
|
218
|
-
|
|
259
|
+
status,
|
|
260
|
+
error: message,
|
|
219
261
|
});
|
|
262
|
+
if (status === 401 || status === 403) {
|
|
263
|
+
// Refresh token is permanently dead — write the expired flag so
|
|
264
|
+
// `status` surfaces it and re-throw a typed error so the control
|
|
265
|
+
// channel can stop reconnect loops instead of hammering the Hub.
|
|
266
|
+
writeAuthExpiredFlag();
|
|
267
|
+
throw new AuthRefreshRejectedError(status, message);
|
|
268
|
+
}
|
|
220
269
|
throw err;
|
|
221
270
|
}).finally(() => {
|
|
222
271
|
this.refreshInflight = null;
|
package/package.json
CHANGED
package/src/control-channel.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "@botcord/protocol-core";
|
|
19
19
|
import { log as daemonLog } from "./log.js";
|
|
20
20
|
import {
|
|
21
|
+
AuthRefreshRejectedError,
|
|
21
22
|
writeAuthExpiredFlag,
|
|
22
23
|
type UserAuthManager,
|
|
23
24
|
} from "./user-auth.js";
|
|
@@ -142,8 +143,17 @@ export class ControlChannel {
|
|
|
142
143
|
});
|
|
143
144
|
this.connectInflight = this.connect().catch((err) => {
|
|
144
145
|
// Initial connect failure surfaces to the caller; subsequent
|
|
145
|
-
// reconnects are handled opaquely inside onClose.
|
|
146
|
-
|
|
146
|
+
// reconnects are handled opaquely inside onClose. A refresh-rejected
|
|
147
|
+
// error means the refresh token itself is dead — no point retrying;
|
|
148
|
+
// writeAuthExpiredFlag was already called in user-auth.refresh().
|
|
149
|
+
if (err instanceof AuthRefreshRejectedError) {
|
|
150
|
+
this.stopRequested = true;
|
|
151
|
+
daemonLog.warn("control-channel: refresh rejected; stopping (re-login required)", {
|
|
152
|
+
status: err.status,
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
this.scheduleReconnect(err);
|
|
156
|
+
}
|
|
147
157
|
throw err;
|
|
148
158
|
});
|
|
149
159
|
try {
|
|
@@ -285,6 +295,13 @@ export class ControlChannel {
|
|
|
285
295
|
|
|
286
296
|
private scheduleReconnect(err?: unknown): void {
|
|
287
297
|
if (this.stopRequested) return;
|
|
298
|
+
if (err instanceof AuthRefreshRejectedError) {
|
|
299
|
+
this.stopRequested = true;
|
|
300
|
+
daemonLog.warn("control-channel: refresh rejected; halting reconnect (re-login required)", {
|
|
301
|
+
status: err.status,
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
288
305
|
const attempt = this.reconnectAttempts;
|
|
289
306
|
this.reconnectAttempts = attempt + 1;
|
|
290
307
|
const delay = this.backoff[Math.min(attempt, this.backoff.length - 1)];
|
package/src/doctor.ts
CHANGED
|
@@ -257,6 +257,9 @@ export function renderDoctor(input: DoctorInput): string {
|
|
|
257
257
|
lines.push(
|
|
258
258
|
`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`,
|
|
259
259
|
);
|
|
260
|
+
if (!e.result.available && e.installHint) {
|
|
261
|
+
lines.push(` → ${e.installHint}`);
|
|
262
|
+
}
|
|
260
263
|
if (e.endpoints && e.endpoints.length > 0) {
|
|
261
264
|
for (const ep of e.endpoints) {
|
|
262
265
|
const mark = ep.reachable ? "✓" : "✗";
|
|
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
chmodSync,
|
|
4
4
|
existsSync,
|
|
5
|
+
mkdirSync,
|
|
5
6
|
mkdtempSync,
|
|
6
7
|
readFileSync,
|
|
7
8
|
rmSync,
|
|
@@ -9,7 +10,10 @@ import {
|
|
|
9
10
|
} from "node:fs";
|
|
10
11
|
import os from "node:os";
|
|
11
12
|
import path from "node:path";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
HermesAgentAdapter,
|
|
15
|
+
resolveHermesAcpCommand,
|
|
16
|
+
} from "../runtimes/hermes-agent.js";
|
|
13
17
|
import { agentHermesWorkspaceDir } from "../../agent-workspace.js";
|
|
14
18
|
|
|
15
19
|
// Spawn a tiny Node "ACP server" we control instead of the real hermes-acp.
|
|
@@ -288,6 +292,30 @@ describe("HermesAgentAdapter", () => {
|
|
|
288
292
|
expect(res.error).toMatch(/aborted before spawn/);
|
|
289
293
|
});
|
|
290
294
|
|
|
295
|
+
it("resolveHermesAcpCommand falls back to ~/.hermes venv when PATH lookup fails", () => {
|
|
296
|
+
// Upstream `scripts/install.sh` puts hermes-acp at
|
|
297
|
+
// ~/.hermes/hermes-agent/venv/bin/hermes-acp and only symlinks `hermes`
|
|
298
|
+
// into ~/.local/bin. Simulate that layout: `which hermes-acp` fails,
|
|
299
|
+
// but the venv path exists on disk.
|
|
300
|
+
const fakeHome = mkdtempSync(path.join(os.tmpdir(), "hermes-fallback-"));
|
|
301
|
+
const venvBin = path.join(fakeHome, ".hermes", "hermes-agent", "venv", "bin");
|
|
302
|
+
const target = path.join(venvBin, "hermes-acp");
|
|
303
|
+
mkdirSync(venvBin, { recursive: true });
|
|
304
|
+
writeFileSync(target, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
305
|
+
chmodSync(target, 0o755);
|
|
306
|
+
|
|
307
|
+
const resolved = resolveHermesAcpCommand({
|
|
308
|
+
env: { PATH: "/nonexistent" },
|
|
309
|
+
homeDir: fakeHome,
|
|
310
|
+
execFileSyncFn: (() => {
|
|
311
|
+
throw new Error("which: not found");
|
|
312
|
+
}) as never,
|
|
313
|
+
});
|
|
314
|
+
expect(resolved).toBe(target);
|
|
315
|
+
|
|
316
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
317
|
+
});
|
|
318
|
+
|
|
291
319
|
it("surfaces non-zero exit with stderr snippet", async () => {
|
|
292
320
|
const p = path.join(tmpRoot, "boom.js");
|
|
293
321
|
writeFileSync(
|
|
@@ -13,12 +13,45 @@ import {
|
|
|
13
13
|
type AcpUpdateCtx,
|
|
14
14
|
type AcpUpdateParams,
|
|
15
15
|
} from "./acp-stream.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
firstExistingPath,
|
|
18
|
+
readCommandVersion,
|
|
19
|
+
resolveCommandOnPath,
|
|
20
|
+
resolveHomePath,
|
|
21
|
+
type ProbeDeps,
|
|
22
|
+
} from "./probe.js";
|
|
17
23
|
import type { RuntimeProbeResult, RuntimeRunOptions, StreamBlock } from "../types.js";
|
|
18
24
|
|
|
19
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Known absolute locations of the `hermes-acp` entry point when it is not on
|
|
27
|
+
* PATH. The upstream `scripts/install.sh` (curl|bash installer) installs a
|
|
28
|
+
* private virtualenv under `~/.hermes/hermes-agent/venv/` and only symlinks
|
|
29
|
+
* the user-facing `hermes` command into `~/.local/bin/` — the `hermes-acp`
|
|
30
|
+
* entry point stays inside the venv. Without a fallback, daemon's PATH-only
|
|
31
|
+
* probe misses every user who installed via the README-recommended script.
|
|
32
|
+
*/
|
|
33
|
+
const HERMES_ACP_FALLBACK_RELATIVE_PATHS = [
|
|
34
|
+
path.join(".hermes", "hermes-agent", "venv", "bin", "hermes-acp"),
|
|
35
|
+
];
|
|
36
|
+
const HERMES_ACP_FALLBACK_SYSTEM_PATHS = [
|
|
37
|
+
"/opt/hermes/hermes-agent/venv/bin/hermes-acp",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the `hermes-acp` executable. Tries PATH first, then falls back to
|
|
42
|
+
* the upstream install.sh's private venv location (`~/.hermes/...`) before
|
|
43
|
+
* giving up. `BOTCORD_HERMES_AGENT_BIN` always wins via the adapter override.
|
|
44
|
+
*/
|
|
20
45
|
export function resolveHermesAcpCommand(deps: ProbeDeps = {}): string | null {
|
|
21
|
-
|
|
46
|
+
const onPath = resolveCommandOnPath("hermes-acp", deps);
|
|
47
|
+
if (onPath) return onPath;
|
|
48
|
+
return firstExistingPath(
|
|
49
|
+
[
|
|
50
|
+
...HERMES_ACP_FALLBACK_RELATIVE_PATHS.map((p) => resolveHomePath(p, deps)),
|
|
51
|
+
...HERMES_ACP_FALLBACK_SYSTEM_PATHS,
|
|
52
|
+
],
|
|
53
|
+
deps,
|
|
54
|
+
);
|
|
22
55
|
}
|
|
23
56
|
|
|
24
57
|
/** Probe whether `hermes-acp` is installed and report its version. */
|
|
@@ -29,6 +29,11 @@ export interface RuntimeModule {
|
|
|
29
29
|
* config loader rejects routing turns to this adapter.
|
|
30
30
|
*/
|
|
31
31
|
supportsRun?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Short, single-line install hint shown by `doctor` when the runtime
|
|
34
|
+
* probes as unavailable. Helps users recover without reading source.
|
|
35
|
+
*/
|
|
36
|
+
installHint?: string;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/** Built-in runtime module entry for Claude Code. */
|
|
@@ -58,6 +63,8 @@ export const hermesAgentModule: RuntimeModule = {
|
|
|
58
63
|
envVar: "BOTCORD_HERMES_AGENT_BIN",
|
|
59
64
|
probe: () => probeHermesAgent(),
|
|
60
65
|
create: () => new HermesAgentAdapter(),
|
|
66
|
+
installHint:
|
|
67
|
+
'Install: pip install "hermes-agent[acp]" (or set BOTCORD_HERMES_AGENT_BIN to the absolute path of hermes-acp)',
|
|
61
68
|
};
|
|
62
69
|
|
|
63
70
|
/** Built-in runtime module entry for Gemini (probe-only stub). */
|
|
@@ -143,6 +150,7 @@ export interface RuntimeProbeEntry {
|
|
|
143
150
|
binary: string;
|
|
144
151
|
supportsRun: boolean;
|
|
145
152
|
result: RuntimeProbeResult;
|
|
153
|
+
installHint?: string;
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
/** Probe every registered runtime and report installation status. */
|
|
@@ -161,6 +169,7 @@ export function detectRuntimes(): RuntimeProbeEntry[] {
|
|
|
161
169
|
binary: m.binary,
|
|
162
170
|
supportsRun: m.supportsRun !== false,
|
|
163
171
|
result,
|
|
172
|
+
installHint: m.installHint,
|
|
164
173
|
});
|
|
165
174
|
}
|
|
166
175
|
return out;
|
package/src/user-auth.ts
CHANGED
|
@@ -188,6 +188,20 @@ export function isTokenNearExpiry(record: UserAuthRecord, windowMs = 60_000): bo
|
|
|
188
188
|
return record.expiresAt - Date.now() <= windowMs;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Thrown when the Hub rejects a refresh token (401/403). Signals that the
|
|
193
|
+
* user must re-login — reconnect loops should stop instead of hammering
|
|
194
|
+
* the refresh endpoint forever with a known-bad token.
|
|
195
|
+
*/
|
|
196
|
+
export class AuthRefreshRejectedError extends Error {
|
|
197
|
+
readonly status: number;
|
|
198
|
+
constructor(status: number, message: string) {
|
|
199
|
+
super(message);
|
|
200
|
+
this.name = "AuthRefreshRejectedError";
|
|
201
|
+
this.status = status;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
191
205
|
/**
|
|
192
206
|
* Stateful helper that owns the in-memory copy of user-auth and knows how
|
|
193
207
|
* to refresh it. Used by the control channel so reconnects always carry
|
|
@@ -245,13 +259,35 @@ export class UserAuthManager {
|
|
|
245
259
|
expiresInMs: current.expiresAt - Date.now(),
|
|
246
260
|
});
|
|
247
261
|
this.refreshInflight = (async () => {
|
|
248
|
-
|
|
262
|
+
// Refresh tokens rotate server-side. If another local process (e.g. a
|
|
263
|
+
// second daemon racing on the same user-auth.json) refreshed in the
|
|
264
|
+
// meantime, the on-disk refreshToken now differs from our in-memory
|
|
265
|
+
// copy — using the in-memory one would 401 because the server already
|
|
266
|
+
// invalidated it. Re-read disk first and adopt any newer record.
|
|
267
|
+
let basis = current;
|
|
268
|
+
try {
|
|
269
|
+
const onDisk = loadUserAuth(this.file);
|
|
270
|
+
if (onDisk && onDisk.refreshToken !== current.refreshToken) {
|
|
271
|
+
daemonLog.info("user-auth refresh: adopting newer on-disk token", {
|
|
272
|
+
userId: onDisk.userId,
|
|
273
|
+
expiresAt: onDisk.expiresAt,
|
|
274
|
+
});
|
|
275
|
+
this.record = onDisk;
|
|
276
|
+
if (!isTokenNearExpiry(onDisk)) return onDisk;
|
|
277
|
+
basis = onDisk;
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
daemonLog.debug("user-auth refresh: disk reread failed (ignored)", {
|
|
281
|
+
error: err instanceof Error ? err.message : String(err),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const tok = await refreshDaemonToken(basis.hubUrl, basis.refreshToken);
|
|
249
285
|
const next: UserAuthRecord = {
|
|
250
|
-
...
|
|
286
|
+
...basis,
|
|
251
287
|
accessToken: tok.accessToken,
|
|
252
288
|
refreshToken: tok.refreshToken,
|
|
253
289
|
expiresAt: Date.now() + tok.expiresIn * 1000,
|
|
254
|
-
hubUrl: tok.hubUrl ||
|
|
290
|
+
hubUrl: tok.hubUrl || basis.hubUrl,
|
|
255
291
|
};
|
|
256
292
|
saveUserAuth(next, this.file);
|
|
257
293
|
this.record = next;
|
|
@@ -261,10 +297,23 @@ export class UserAuthManager {
|
|
|
261
297
|
});
|
|
262
298
|
return next;
|
|
263
299
|
})().catch((err) => {
|
|
300
|
+
const status =
|
|
301
|
+
typeof (err as { status?: unknown }).status === "number"
|
|
302
|
+
? ((err as { status: number }).status)
|
|
303
|
+
: null;
|
|
304
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
264
305
|
daemonLog.warn("user-auth refresh: failed", {
|
|
265
306
|
userId: current.userId,
|
|
266
|
-
|
|
307
|
+
status,
|
|
308
|
+
error: message,
|
|
267
309
|
});
|
|
310
|
+
if (status === 401 || status === 403) {
|
|
311
|
+
// Refresh token is permanently dead — write the expired flag so
|
|
312
|
+
// `status` surfaces it and re-throw a typed error so the control
|
|
313
|
+
// channel can stop reconnect loops instead of hammering the Hub.
|
|
314
|
+
writeAuthExpiredFlag();
|
|
315
|
+
throw new AuthRefreshRejectedError(status, message);
|
|
316
|
+
}
|
|
268
317
|
throw err;
|
|
269
318
|
}).finally(() => {
|
|
270
319
|
this.refreshInflight = null;
|