@botcord/daemon 0.2.74 → 0.2.76
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/cloud-auth.d.ts +47 -0
- package/dist/cloud-auth.js +51 -0
- package/dist/cloud-daemon.d.ts +43 -0
- package/dist/cloud-daemon.js +252 -0
- package/dist/cloud-mode.d.ts +45 -0
- package/dist/cloud-mode.js +55 -0
- package/dist/cloud-settle.d.ts +81 -0
- package/dist/cloud-settle.js +100 -0
- package/dist/daemon-singleton.d.ts +26 -0
- package/dist/daemon-singleton.js +91 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +15 -6
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +15 -4
- package/dist/gateway/channels/botcord.d.ts +1 -1
- package/dist/gateway/channels/botcord.js +48 -5
- package/dist/gateway/dispatcher.d.ts +34 -1
- package/dist/gateway/dispatcher.js +277 -20
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +4 -1
- package/dist/gateway/runtime-errors.d.ts +6 -0
- package/dist/gateway/runtime-errors.js +14 -0
- package/dist/gateway/runtimes/claude-code.d.ts +8 -0
- package/dist/gateway/runtimes/claude-code.js +92 -4
- package/dist/gateway/runtimes/deepseek-tui.js +19 -5
- package/dist/gateway/transcript.d.ts +1 -1
- package/dist/gateway/types.d.ts +33 -0
- package/dist/index.js +71 -80
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +39 -1
- package/dist/status-render.js +17 -0
- package/package.json +2 -2
- package/src/__tests__/cloud-auth.test.ts +42 -0
- package/src/__tests__/cloud-daemon.test.ts +237 -0
- package/src/__tests__/cloud-mode.test.ts +65 -0
- package/src/__tests__/cloud-settle.test.ts +287 -0
- package/src/__tests__/daemon-singleton.test.ts +89 -0
- package/src/__tests__/doctor.test.ts +34 -0
- package/src/__tests__/runtime-discovery.test.ts +90 -0
- package/src/__tests__/status-render.test.ts +34 -0
- package/src/cloud-auth.ts +78 -0
- package/src/cloud-daemon.ts +338 -0
- package/src/cloud-mode.ts +70 -0
- package/src/cloud-settle.ts +182 -0
- package/src/daemon-singleton.ts +122 -0
- package/src/daemon.ts +18 -5
- package/src/doctor.ts +18 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +74 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
- package/src/gateway/__tests__/dispatcher.test.ts +120 -0
- package/src/gateway/channels/botcord.ts +54 -7
- package/src/gateway/dispatcher.ts +354 -21
- package/src/gateway/gateway.ts +16 -1
- package/src/gateway/runtime-errors.ts +15 -0
- package/src/gateway/runtimes/claude-code.ts +98 -2
- package/src/gateway/runtimes/deepseek-tui.ts +23 -5
- package/src/gateway/transcript.ts +1 -1
- package/src/gateway/types.ts +34 -0
- package/src/index.ts +83 -74
- package/src/provision.ts +45 -1
- package/src/status-render.ts +24 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud Agent run settle helper.
|
|
3
|
+
*
|
|
4
|
+
* After a `cloud_run` envelope completes (or fails), the daemon POSTs the
|
|
5
|
+
* observed usage back to the Hub so the wallet ledger can finalize the
|
|
6
|
+
* reservation. The Hub-side endpoint is
|
|
7
|
+
* `POST /internal/cloud-agents/runs/{run_id}/settle` — see
|
|
8
|
+
* ``backend/hub/services/cloud_agent_usage.py``.
|
|
9
|
+
*
|
|
10
|
+
* Auth: the cloud daemon authenticates with its cloud-daemon-access JWT
|
|
11
|
+
* (the same token used for the WS upgrade). The Hub-side endpoint accepts
|
|
12
|
+
* either that JWT (scoped to the daemon's bound agents) or the operator
|
|
13
|
+
* `INTERNAL_API_SECRET` header — see ``hub/routers/cloud_agent_internal.py``.
|
|
14
|
+
*/
|
|
15
|
+
import { normalizeAndValidateHubUrl } from "@botcord/protocol-core";
|
|
16
|
+
|
|
17
|
+
/** Token usage breakdown reported alongside the wall-clock time. */
|
|
18
|
+
export interface CloudRunSettleUsage {
|
|
19
|
+
provider: string;
|
|
20
|
+
model: string;
|
|
21
|
+
inputCacheHitTokens: number;
|
|
22
|
+
inputCacheMissTokens: number;
|
|
23
|
+
outputTokens: number;
|
|
24
|
+
sandboxSeconds: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Inputs accepted by {@link postCloudRunSettle}. */
|
|
28
|
+
export interface CloudRunSettleInput extends CloudRunSettleUsage {
|
|
29
|
+
hubUrl: string;
|
|
30
|
+
accessToken: string;
|
|
31
|
+
runId: string;
|
|
32
|
+
/** Override the idempotency key. Defaults to `<run_id>:settle`. */
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
/** Test seam — override `fetch`. */
|
|
35
|
+
fetchFn?: typeof fetch;
|
|
36
|
+
/** Override timeout for the POST. Defaults to 10s. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Outcome of {@link postCloudRunSettle}. */
|
|
41
|
+
export interface CloudRunSettleResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
status: number;
|
|
44
|
+
/** Raw response body, when the Hub returned one. */
|
|
45
|
+
body?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* POST the settle payload to the Hub. Resolves with `ok=false` for non-2xx
|
|
50
|
+
* responses instead of throwing so the caller can decide whether to retry
|
|
51
|
+
* or surface the failure into the runtime log; throws only on transport
|
|
52
|
+
* errors (timeout / DNS) which are fully outside the daemon's control.
|
|
53
|
+
*
|
|
54
|
+
* Body shape matches the Hub-side `UsageEventCreate`-like contract:
|
|
55
|
+
*
|
|
56
|
+
* {
|
|
57
|
+
* "provider": <str>,
|
|
58
|
+
* "model": <str>,
|
|
59
|
+
* "input_cache_hit_tokens": <int>,
|
|
60
|
+
* "input_cache_miss_tokens": <int>,
|
|
61
|
+
* "output_tokens": <int>,
|
|
62
|
+
* "sandbox_seconds": <int>,
|
|
63
|
+
* "idempotency_key": "<run_id>:settle"
|
|
64
|
+
* }
|
|
65
|
+
*
|
|
66
|
+
* Snake_case is intentional: the Hub's Pydantic models use snake_case for
|
|
67
|
+
* the public surface even when the surrounding daemon control plane uses
|
|
68
|
+
* camelCase.
|
|
69
|
+
*/
|
|
70
|
+
export interface CloudRunSettleHookDeps {
|
|
71
|
+
hubUrl: string;
|
|
72
|
+
accessToken: string;
|
|
73
|
+
/** Fallback model name when the envelope doesn't carry one. */
|
|
74
|
+
defaultModel?: string;
|
|
75
|
+
/** Logger surface — only `warn` / `info` used. */
|
|
76
|
+
log?: {
|
|
77
|
+
info(msg: string, ctx?: Record<string, unknown>): void;
|
|
78
|
+
warn(msg: string, ctx?: Record<string, unknown>): void;
|
|
79
|
+
};
|
|
80
|
+
/** Test seam. */
|
|
81
|
+
fetchFn?: typeof fetch;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Minimal subset of an inbound message needed by the settle hook. */
|
|
85
|
+
export interface CloudRunSettleHookEvent {
|
|
86
|
+
envelopeType?: string | undefined;
|
|
87
|
+
runId?: string | undefined;
|
|
88
|
+
wallTimeMs: number;
|
|
89
|
+
tokens?: {
|
|
90
|
+
inputCacheHitTokens?: number;
|
|
91
|
+
inputCacheMissTokens?: number;
|
|
92
|
+
outputTokens?: number;
|
|
93
|
+
};
|
|
94
|
+
messageId?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the settle hook used by ``startCloudDaemon``. Extracted so unit
|
|
99
|
+
* tests can drive it directly without standing up the full gateway.
|
|
100
|
+
*/
|
|
101
|
+
export function buildCloudRunSettleHook(
|
|
102
|
+
deps: CloudRunSettleHookDeps,
|
|
103
|
+
): (event: CloudRunSettleHookEvent) => Promise<void> {
|
|
104
|
+
const log = deps.log;
|
|
105
|
+
const model = deps.defaultModel ?? "deepseek-v4-flash";
|
|
106
|
+
return async (event) => {
|
|
107
|
+
if (event.envelopeType !== "cloud_run") return;
|
|
108
|
+
const runId = event.runId;
|
|
109
|
+
if (typeof runId !== "string" || runId.length === 0) {
|
|
110
|
+
log?.warn("cloud_run envelope missing run_id; skipping settle", {
|
|
111
|
+
messageId: event.messageId ?? "<unknown>",
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const sandboxSeconds = Math.max(1, Math.round(event.wallTimeMs / 1000));
|
|
116
|
+
try {
|
|
117
|
+
const settleResult = await postCloudRunSettle({
|
|
118
|
+
hubUrl: deps.hubUrl,
|
|
119
|
+
accessToken: deps.accessToken,
|
|
120
|
+
runId,
|
|
121
|
+
provider: "deepseek",
|
|
122
|
+
model,
|
|
123
|
+
// The deepseek-tui adapter does not yet surface token counts;
|
|
124
|
+
// ``UsageService`` charges by sandbox-seconds when tokens are
|
|
125
|
+
// zero. Filling these in is the next runtime-adapter PR.
|
|
126
|
+
inputCacheHitTokens: event.tokens?.inputCacheHitTokens ?? 0,
|
|
127
|
+
inputCacheMissTokens: event.tokens?.inputCacheMissTokens ?? 0,
|
|
128
|
+
outputTokens: event.tokens?.outputTokens ?? 0,
|
|
129
|
+
sandboxSeconds,
|
|
130
|
+
...(deps.fetchFn ? { fetchFn: deps.fetchFn } : {}),
|
|
131
|
+
});
|
|
132
|
+
if (!settleResult.ok) {
|
|
133
|
+
log?.warn("cloud_run settle returned non-2xx", {
|
|
134
|
+
runId,
|
|
135
|
+
status: settleResult.status,
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
log?.info("cloud_run settled", { runId, sandboxSeconds });
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// Transport errors only — Hub-side rejections surface as ok=false.
|
|
142
|
+
log?.warn("cloud_run settle threw — continuing", {
|
|
143
|
+
runId,
|
|
144
|
+
error: err instanceof Error ? err.message : String(err),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function postCloudRunSettle(
|
|
151
|
+
input: CloudRunSettleInput,
|
|
152
|
+
): Promise<CloudRunSettleResult> {
|
|
153
|
+
const base = normalizeAndValidateHubUrl(input.hubUrl);
|
|
154
|
+
const url = `${base}/internal/cloud-agents/runs/${encodeURIComponent(input.runId)}/settle`;
|
|
155
|
+
const idempotencyKey = input.idempotencyKey ?? `${input.runId}:settle`;
|
|
156
|
+
const body = {
|
|
157
|
+
provider: input.provider,
|
|
158
|
+
model: input.model,
|
|
159
|
+
input_cache_hit_tokens: Math.max(0, Math.floor(input.inputCacheHitTokens)),
|
|
160
|
+
input_cache_miss_tokens: Math.max(0, Math.floor(input.inputCacheMissTokens)),
|
|
161
|
+
output_tokens: Math.max(0, Math.floor(input.outputTokens)),
|
|
162
|
+
sandbox_seconds: Math.max(0, Math.floor(input.sandboxSeconds)),
|
|
163
|
+
idempotency_key: idempotencyKey,
|
|
164
|
+
};
|
|
165
|
+
const doFetch = input.fetchFn ?? fetch;
|
|
166
|
+
const resp = await doFetch(url, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"Content-Type": "application/json",
|
|
170
|
+
Authorization: `Bearer ${input.accessToken}`,
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify(body),
|
|
173
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 10_000),
|
|
174
|
+
});
|
|
175
|
+
let parsed: unknown = undefined;
|
|
176
|
+
try {
|
|
177
|
+
parsed = await resp.json();
|
|
178
|
+
} catch {
|
|
179
|
+
// Empty body on 204, etc. — leave parsed as undefined.
|
|
180
|
+
}
|
|
181
|
+
return { ok: resp.ok, status: resp.status, body: parsed };
|
|
182
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { PID_PATH } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export interface SingletonLogger {
|
|
5
|
+
info(message: string, meta?: Record<string, unknown>): void;
|
|
6
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const noopLogger: SingletonLogger = {
|
|
10
|
+
info() {
|
|
11
|
+
// noop
|
|
12
|
+
},
|
|
13
|
+
warn() {
|
|
14
|
+
// noop
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function readPid(pidPath = PID_PATH): number | null {
|
|
19
|
+
if (!existsSync(pidPath)) return null;
|
|
20
|
+
const raw = readFileSync(pidPath, "utf8").trim();
|
|
21
|
+
const pid = Number(raw);
|
|
22
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pidAlive(pid: number): boolean {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
35
|
+
const deadline = Date.now() + timeoutMs;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
if (!pidAlive(pid)) return true;
|
|
38
|
+
await delay(100);
|
|
39
|
+
}
|
|
40
|
+
return !pidAlive(pid);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function stopExistingDaemonForRestart(
|
|
44
|
+
pid: number,
|
|
45
|
+
opts: {
|
|
46
|
+
pidPath?: string;
|
|
47
|
+
currentPid?: number;
|
|
48
|
+
logger?: SingletonLogger;
|
|
49
|
+
} = {},
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const pidPath = opts.pidPath ?? PID_PATH;
|
|
52
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
53
|
+
const logger = opts.logger ?? noopLogger;
|
|
54
|
+
if (pid === currentPid) return;
|
|
55
|
+
logger.info("existing daemon found; restarting", { pid });
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, "SIGTERM");
|
|
58
|
+
} catch {
|
|
59
|
+
removePidFile(pidPath);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!(await waitForPidExit(pid, 5_000))) {
|
|
63
|
+
logger.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, "SIGKILL");
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
await waitForPidExit(pid, 2_000);
|
|
70
|
+
}
|
|
71
|
+
removePidFile(pidPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function stopDaemonFromPidFileForRestart(
|
|
75
|
+
opts: {
|
|
76
|
+
pidPath?: string;
|
|
77
|
+
currentPid?: number;
|
|
78
|
+
logger?: SingletonLogger;
|
|
79
|
+
} = {},
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const pidPath = opts.pidPath ?? PID_PATH;
|
|
82
|
+
const existing = readPid(pidPath);
|
|
83
|
+
if (existing && pidAlive(existing)) {
|
|
84
|
+
await stopExistingDaemonForRestart(existing, opts);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function ensureNoOtherDaemonFromPidFile(
|
|
89
|
+
opts: {
|
|
90
|
+
pidPath?: string;
|
|
91
|
+
currentPid?: number;
|
|
92
|
+
} = {},
|
|
93
|
+
): number | null {
|
|
94
|
+
const pidPath = opts.pidPath ?? PID_PATH;
|
|
95
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
96
|
+
const existing = readPid(pidPath);
|
|
97
|
+
if (existing && existing !== currentPid && pidAlive(existing)) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeCurrentPid(
|
|
104
|
+
opts: {
|
|
105
|
+
pidPath?: string;
|
|
106
|
+
currentPid?: number;
|
|
107
|
+
} = {},
|
|
108
|
+
): void {
|
|
109
|
+
writeFileSync(opts.pidPath ?? PID_PATH, String(opts.currentPid ?? process.pid), { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function removePidFile(pidPath = PID_PATH): void {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(pidPath);
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function delay(ms: number): Promise<void> {
|
|
121
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { toGatewayConfig } from "./daemon-config-map.js";
|
|
|
28
28
|
import { log as daemonLog } from "./log.js";
|
|
29
29
|
import {
|
|
30
30
|
adoptDiscoveredOpenclawAgents,
|
|
31
|
+
attachRuntimeHealth,
|
|
31
32
|
collectRuntimeSnapshot,
|
|
32
33
|
createProvisioner,
|
|
33
34
|
type OnAgentInstalledHook,
|
|
@@ -222,8 +223,12 @@ export interface RuntimeSnapshotSink {
|
|
|
222
223
|
* failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
|
|
223
224
|
* or wait for the next daemon restart). Exported for unit tests.
|
|
224
225
|
*/
|
|
225
|
-
export function pushRuntimeSnapshot(
|
|
226
|
-
|
|
226
|
+
export function pushRuntimeSnapshot(
|
|
227
|
+
sink: RuntimeSnapshotSink,
|
|
228
|
+
liveSnapshot?: GatewayRuntimeSnapshot,
|
|
229
|
+
): boolean {
|
|
230
|
+
const base = collectRuntimeSnapshot();
|
|
231
|
+
const snap = liveSnapshot ? attachRuntimeHealth(base, liveSnapshot) : base;
|
|
227
232
|
const ok = sink.send({
|
|
228
233
|
id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
229
234
|
type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
|
|
@@ -502,7 +507,15 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
502
507
|
}
|
|
503
508
|
};
|
|
504
509
|
|
|
505
|
-
|
|
510
|
+
let controlChannel: ControlChannel | null = null;
|
|
511
|
+
let gateway: Gateway;
|
|
512
|
+
const pushLiveRuntimeSnapshot = (): void => {
|
|
513
|
+
if (!controlChannel) return;
|
|
514
|
+
const pushed = pushRuntimeSnapshot(controlChannel, gateway.snapshot());
|
|
515
|
+
logger.info("control-channel: live runtime_snapshot push", { ok: pushed });
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
gateway = new Gateway({
|
|
506
519
|
config: gwConfig,
|
|
507
520
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
508
521
|
createChannel: (chCfg: GatewayChannelConfig): ChannelAdapter =>
|
|
@@ -517,6 +530,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
517
530
|
buildMemoryContext,
|
|
518
531
|
onInbound,
|
|
519
532
|
onOutbound,
|
|
533
|
+
onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
|
|
520
534
|
composeUserTurn: composeBotCordUserTurn,
|
|
521
535
|
attentionGate,
|
|
522
536
|
resolveHubUrl,
|
|
@@ -575,7 +589,6 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
575
589
|
// Control channel is optional — daemon still runs (data-plane only)
|
|
576
590
|
// when user-auth hasn't been set up yet. Operators can `login` later
|
|
577
591
|
// without restarting, but for P0 we require a restart to pick it up.
|
|
578
|
-
let controlChannel: ControlChannel | null = null;
|
|
579
592
|
if (userAuth?.current && !opts.disableControlChannel) {
|
|
580
593
|
logger.info("control-channel: enabling", {
|
|
581
594
|
userId: userAuth.current.userId,
|
|
@@ -614,7 +627,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
614
627
|
// Plan §8.5 P0 — push one runtime snapshot immediately after connect
|
|
615
628
|
// so Hub's `daemon_instances.runtimes_json` is populated for the
|
|
616
629
|
// dashboard even before any user action. No periodic refresh in P0.
|
|
617
|
-
const pushed = pushRuntimeSnapshot(controlChannel);
|
|
630
|
+
const pushed = pushRuntimeSnapshot(controlChannel, gateway.snapshot());
|
|
618
631
|
logger.info("control-channel: initial runtime_snapshot push", {
|
|
619
632
|
ok: pushed,
|
|
620
633
|
});
|
package/src/doctor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
|
|
2
2
|
import type { DaemonConfig } from "./config.js";
|
|
3
3
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
4
|
+
import { probeClaudeAuth, type ClaudeAuthProbeResult } from "./gateway/runtimes/claude-code.js";
|
|
4
5
|
|
|
5
6
|
/** Summary of a single channel's readiness, printable by the doctor command. */
|
|
6
7
|
export interface ChannelProbeResult {
|
|
@@ -54,6 +55,7 @@ export interface DoctorRuntimeEndpoint {
|
|
|
54
55
|
/** Augmented runtime entry that may carry endpoint probe results. */
|
|
55
56
|
export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
|
|
56
57
|
endpoints?: DoctorRuntimeEndpoint[];
|
|
58
|
+
auth?: ClaudeAuthProbeResult;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/** Input for the rendered doctor output. */
|
|
@@ -167,12 +169,11 @@ export async function probeChannel(
|
|
|
167
169
|
return result;
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
// Probe `/` — the hub is ASGI and
|
|
171
|
-
//
|
|
172
|
-
// through to hubOk=false.
|
|
172
|
+
// Probe `/` — the hub is ASGI and may answer 404 when the host is still
|
|
173
|
+
// reachable. Network errors fall through to hubOk=false.
|
|
173
174
|
const probeUrl = `${result.hubUrl.replace(/\/+$/, "")}/`;
|
|
174
175
|
const http = await opts.fetcher(probeUrl, opts.timeoutMs);
|
|
175
|
-
if (http.ok) {
|
|
176
|
+
if (http.ok || http.status === 404) {
|
|
176
177
|
result.hubOk = true;
|
|
177
178
|
result.hubMessage = `reachable (HTTP ${http.status})`;
|
|
178
179
|
} else if (http.status !== undefined) {
|
|
@@ -260,6 +261,10 @@ export function renderDoctor(input: DoctorInput): string {
|
|
|
260
261
|
if (!e.result.available && e.installHint) {
|
|
261
262
|
lines.push(` → ${e.installHint}`);
|
|
262
263
|
}
|
|
264
|
+
if (e.auth) {
|
|
265
|
+
const authStatus = e.auth.checked ? (e.auth.ok ? "ok" : "failed") : "skipped";
|
|
266
|
+
lines.push(` auth ${authStatus}: ${e.auth.message}`);
|
|
267
|
+
}
|
|
263
268
|
if (e.endpoints && e.endpoints.length > 0) {
|
|
264
269
|
for (const ep of e.endpoints) {
|
|
265
270
|
const mark = ep.reachable ? "✓" : "✗";
|
|
@@ -312,15 +317,23 @@ export function renderDoctor(input: DoctorInput): string {
|
|
|
312
317
|
* text. Keeps `index.ts` free of probe wiring.
|
|
313
318
|
*/
|
|
314
319
|
export async function runDoctor(
|
|
315
|
-
runtimes:
|
|
320
|
+
runtimes: DoctorRuntimeEntry[],
|
|
316
321
|
channels: ChannelProbeConfig[],
|
|
317
322
|
opts: {
|
|
318
323
|
credentialsPath: (accountId: string) => string;
|
|
319
324
|
fileReader: DoctorFileReader;
|
|
320
325
|
fetcher: DoctorHttpFetcher;
|
|
321
326
|
timeoutMs?: number;
|
|
327
|
+
authCheck?: boolean;
|
|
322
328
|
},
|
|
323
329
|
): Promise<DoctorInput> {
|
|
330
|
+
if (opts.authCheck) {
|
|
331
|
+
for (const runtime of runtimes) {
|
|
332
|
+
if (runtime.id === "claude-code" && runtime.result.available) {
|
|
333
|
+
runtime.auth = probeClaudeAuth();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
324
337
|
const channelResults = await probeChannels({
|
|
325
338
|
channels,
|
|
326
339
|
credentialsPath: opts.credentialsPath,
|
|
@@ -269,6 +269,39 @@ describe("createBotCordChannel — inbox normalization", () => {
|
|
|
269
269
|
}
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
it("polls inbox periodically as a fallback after websocket auth", async () => {
|
|
273
|
+
const server = await startAuthOkServer();
|
|
274
|
+
const pollInbox = vi.fn().mockResolvedValue({ messages: [], count: 0, has_more: false });
|
|
275
|
+
const client = makeClient({
|
|
276
|
+
pollInbox,
|
|
277
|
+
getHubUrl: vi.fn().mockReturnValue(server.url),
|
|
278
|
+
});
|
|
279
|
+
const channel = createBotCordChannel({
|
|
280
|
+
id: "botcord-main",
|
|
281
|
+
accountId: "ag_self",
|
|
282
|
+
agentId: "ag_self",
|
|
283
|
+
client,
|
|
284
|
+
hubBaseUrl: server.url,
|
|
285
|
+
pollIntervalMs: 10,
|
|
286
|
+
});
|
|
287
|
+
const abort = new AbortController();
|
|
288
|
+
const startPromise = channel.start({
|
|
289
|
+
config: stubConfig,
|
|
290
|
+
accountId: "ag_self",
|
|
291
|
+
abortSignal: abort.signal,
|
|
292
|
+
log: silentLog,
|
|
293
|
+
emit: async () => {},
|
|
294
|
+
setStatus: () => {},
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
await vi.waitFor(() => expect(pollInbox.mock.calls.length).toBeGreaterThanOrEqual(2));
|
|
298
|
+
} finally {
|
|
299
|
+
abort.abort();
|
|
300
|
+
await startPromise;
|
|
301
|
+
await server.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
272
305
|
it("maps a group-room InboxMessage to a GatewayInboundMessage", async () => {
|
|
273
306
|
const { emits, server } = await startWithInbox([
|
|
274
307
|
makeInbox({
|
|
@@ -868,6 +901,47 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
868
901
|
}
|
|
869
902
|
});
|
|
870
903
|
|
|
904
|
+
it("marks DeepSeek terminal events for owner-chat stream cleanup", async () => {
|
|
905
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
906
|
+
const realFetch = globalThis.fetch;
|
|
907
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
908
|
+
try {
|
|
909
|
+
const client = makeClient({
|
|
910
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
911
|
+
});
|
|
912
|
+
const channel = createBotCordChannel({
|
|
913
|
+
id: "botcord-main",
|
|
914
|
+
accountId: "ag_self",
|
|
915
|
+
agentId: "ag_self",
|
|
916
|
+
client,
|
|
917
|
+
hubBaseUrl: "https://hub.example.com",
|
|
918
|
+
});
|
|
919
|
+
await channel.streamBlock!({
|
|
920
|
+
traceId: "m_trace",
|
|
921
|
+
accountId: "ag_self",
|
|
922
|
+
conversationId: "rm_oc_1",
|
|
923
|
+
block: {
|
|
924
|
+
kind: "other",
|
|
925
|
+
seq: 6,
|
|
926
|
+
raw: {
|
|
927
|
+
event: "turn.finished",
|
|
928
|
+
payload: { thread_id: "thr_1", turn_id: "turn_1" },
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
log: silentLog,
|
|
932
|
+
});
|
|
933
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
934
|
+
const body = JSON.parse(init.body as string);
|
|
935
|
+
expect(body.block).toMatchObject({
|
|
936
|
+
kind: "other",
|
|
937
|
+
seq: 6,
|
|
938
|
+
payload: { terminal: true, event: "turn.finished" },
|
|
939
|
+
});
|
|
940
|
+
} finally {
|
|
941
|
+
globalThis.fetch = realFetch;
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
|
|
871
945
|
it("normalizes a thinking block with phase/label/source payload", async () => {
|
|
872
946
|
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
873
947
|
const realFetch = globalThis.fetch;
|
|
@@ -2,7 +2,8 @@ import { afterAll, describe, expect, it } from "vitest";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { ClaudeCodeAdapter } from "../runtimes/claude-code.js";
|
|
5
|
+
import { ClaudeCodeAdapter, probeClaudeAuth } from "../runtimes/claude-code.js";
|
|
6
|
+
import type { RuntimeRunOptions } from "../types.js";
|
|
6
7
|
|
|
7
8
|
// The adapter spawns whatever binary we point it at; we point it at a small
|
|
8
9
|
// Node script so we control stdout/stderr/exit precisely without needing the
|
|
@@ -33,6 +34,20 @@ function runAdapter(script: string, sessionId: string | null = null) {
|
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
class EnvProbeClaudeCodeAdapter extends ClaudeCodeAdapter {
|
|
38
|
+
env(opts: Partial<RuntimeRunOptions> = {}) {
|
|
39
|
+
return this.spawnEnv({
|
|
40
|
+
text: "hi",
|
|
41
|
+
sessionId: null,
|
|
42
|
+
accountId: "ag_test",
|
|
43
|
+
cwd: tmpRoot,
|
|
44
|
+
signal: new AbortController().signal,
|
|
45
|
+
trustLevel: "owner",
|
|
46
|
+
...opts,
|
|
47
|
+
} as RuntimeRunOptions);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
describe("ClaudeCodeAdapter", () => {
|
|
37
52
|
it("parses session_id from system init + concatenates assistant text", async () => {
|
|
38
53
|
const script = makeScript(
|
|
@@ -150,6 +165,91 @@ process.exit(2);
|
|
|
150
165
|
expect(res.error).toMatch(/auth failure/);
|
|
151
166
|
});
|
|
152
167
|
|
|
168
|
+
it("treats Claude Code auth failures emitted as success results as runtime errors", async () => {
|
|
169
|
+
const script = makeScript(
|
|
170
|
+
"auth-success.js",
|
|
171
|
+
`
|
|
172
|
+
process.stdout.write(JSON.stringify({type:"system", subtype:"init", session_id:"sid-auth"}) + "\\n");
|
|
173
|
+
process.stdout.write(JSON.stringify({type:"assistant", message:{content:[{type:"text", text:"partial"}]}}) + "\\n");
|
|
174
|
+
process.stdout.write(JSON.stringify({
|
|
175
|
+
type:"result",
|
|
176
|
+
subtype:"success",
|
|
177
|
+
session_id:"sid-bad",
|
|
178
|
+
total_cost_usd:0,
|
|
179
|
+
result:"Failed to authenticate. API Error: 403 Request not allowed"
|
|
180
|
+
}) + "\\n");
|
|
181
|
+
`,
|
|
182
|
+
);
|
|
183
|
+
const res = await runAdapter(script);
|
|
184
|
+
expect(res.text).toBe("");
|
|
185
|
+
expect(res.newSessionId).toBe("");
|
|
186
|
+
expect(res.error).toContain("Failed to authenticate");
|
|
187
|
+
expect(res.costUsd).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("scrubs Claude Code auth env that can override stored login credentials", () => {
|
|
191
|
+
const previous = {
|
|
192
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
193
|
+
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
194
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
|
195
|
+
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
|
|
196
|
+
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
process.env.ANTHROPIC_API_KEY = "stale-key";
|
|
200
|
+
process.env.ANTHROPIC_AUTH_TOKEN = "stale-token";
|
|
201
|
+
process.env.ANTHROPIC_BASE_URL = "https://wrong.example";
|
|
202
|
+
process.env.ANTHROPIC_CUSTOM_HEADERS = "Authorization: Bearer stale";
|
|
203
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = "stale-oauth";
|
|
204
|
+
|
|
205
|
+
const env = new EnvProbeClaudeCodeAdapter().env();
|
|
206
|
+
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
|
207
|
+
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
|
208
|
+
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
|
|
209
|
+
expect(env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined();
|
|
210
|
+
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
211
|
+
} finally {
|
|
212
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
213
|
+
if (value === undefined) delete process.env[key];
|
|
214
|
+
else process.env[key] = value;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("auth probe recognizes success-shaped Claude Code auth failures", () => {
|
|
220
|
+
const res = probeClaudeAuth({
|
|
221
|
+
execFileSyncFn: ((command: string, args: string[]) => {
|
|
222
|
+
if (command === "which") return "/usr/bin/claude\n";
|
|
223
|
+
expect(args).toEqual(["-p", "ping", "--output-format", "stream-json"]);
|
|
224
|
+
return `${JSON.stringify({
|
|
225
|
+
type: "result",
|
|
226
|
+
subtype: "success",
|
|
227
|
+
total_cost_usd: 0,
|
|
228
|
+
result: "Failed to authenticate. API Error: 403 Request not allowed",
|
|
229
|
+
})}\n`;
|
|
230
|
+
}) as never,
|
|
231
|
+
});
|
|
232
|
+
expect(res.checked).toBe(true);
|
|
233
|
+
expect(res.ok).toBe(false);
|
|
234
|
+
expect(res.message).toContain("Failed to authenticate");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("auth probe reports ok when Claude Code returns a normal result", () => {
|
|
238
|
+
const res = probeClaudeAuth({
|
|
239
|
+
execFileSyncFn: ((command: string, args: string[]) => {
|
|
240
|
+
if (command === "which") return "/usr/bin/claude\n";
|
|
241
|
+
expect(args).toEqual(["-p", "ping", "--output-format", "stream-json"]);
|
|
242
|
+
return `${JSON.stringify({
|
|
243
|
+
type: "result",
|
|
244
|
+
subtype: "success",
|
|
245
|
+
total_cost_usd: 0.001,
|
|
246
|
+
result: "pong",
|
|
247
|
+
})}\n`;
|
|
248
|
+
}) as never,
|
|
249
|
+
});
|
|
250
|
+
expect(res).toEqual({ checked: true, ok: true, message: "claude-code auth ok" });
|
|
251
|
+
});
|
|
252
|
+
|
|
153
253
|
it("wipes newSessionId on non-success result so dispatcher can drop the stale entry", async () => {
|
|
154
254
|
// Mirrors what Claude Code emits when `--resume <missing-uuid>` is used:
|
|
155
255
|
// a fresh `session_id` for the just-spawned empty session, plus a non-success
|
|
@@ -150,6 +150,25 @@ describe("DeepseekTuiAdapter", () => {
|
|
|
150
150
|
}
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
it("treats DeepSeek embedded terminal events as turn completion", async () => {
|
|
154
|
+
const server = await startMockDeepseekServer({
|
|
155
|
+
events: [
|
|
156
|
+
{ event: "status", data: { event: "turn.started", thread_id: "thr_test", turn_id: "turn_test" } },
|
|
157
|
+
{ event: "message.delta", data: { thread_id: "thr_test", turn_id: "turn_test", content: "done" } },
|
|
158
|
+
{ event: "status", data: { event: "turn.finished", thread_id: "thr_test", turn_id: "turn_test" } },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
|
|
163
|
+
const res = await result;
|
|
164
|
+
expect(res).toEqual({ text: "done", newSessionId: server.threadId });
|
|
165
|
+
expect(blocks).toContain("assistant_text");
|
|
166
|
+
expect(status.at(-1)).toEqual({ phase: "stopped", label: undefined });
|
|
167
|
+
} finally {
|
|
168
|
+
await server.close();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
153
172
|
it("reuses an existing DeepSeek thread id and patches per-turn system context", async () => {
|
|
154
173
|
const server = await startMockDeepseekServer({ threadId: "thr_existing" });
|
|
155
174
|
try {
|