@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.
Files changed (62) hide show
  1. package/dist/cloud-auth.d.ts +47 -0
  2. package/dist/cloud-auth.js +51 -0
  3. package/dist/cloud-daemon.d.ts +43 -0
  4. package/dist/cloud-daemon.js +252 -0
  5. package/dist/cloud-mode.d.ts +45 -0
  6. package/dist/cloud-mode.js +55 -0
  7. package/dist/cloud-settle.d.ts +81 -0
  8. package/dist/cloud-settle.js +100 -0
  9. package/dist/daemon-singleton.d.ts +26 -0
  10. package/dist/daemon-singleton.js +91 -0
  11. package/dist/daemon.d.ts +1 -1
  12. package/dist/daemon.js +15 -6
  13. package/dist/doctor.d.ts +4 -1
  14. package/dist/doctor.js +15 -4
  15. package/dist/gateway/channels/botcord.d.ts +1 -1
  16. package/dist/gateway/channels/botcord.js +48 -5
  17. package/dist/gateway/dispatcher.d.ts +34 -1
  18. package/dist/gateway/dispatcher.js +277 -20
  19. package/dist/gateway/gateway.d.ts +9 -1
  20. package/dist/gateway/gateway.js +4 -1
  21. package/dist/gateway/runtime-errors.d.ts +6 -0
  22. package/dist/gateway/runtime-errors.js +14 -0
  23. package/dist/gateway/runtimes/claude-code.d.ts +8 -0
  24. package/dist/gateway/runtimes/claude-code.js +92 -4
  25. package/dist/gateway/runtimes/deepseek-tui.js +19 -5
  26. package/dist/gateway/transcript.d.ts +1 -1
  27. package/dist/gateway/types.d.ts +33 -0
  28. package/dist/index.js +71 -80
  29. package/dist/provision.d.ts +2 -0
  30. package/dist/provision.js +39 -1
  31. package/dist/status-render.js +17 -0
  32. package/package.json +2 -2
  33. package/src/__tests__/cloud-auth.test.ts +42 -0
  34. package/src/__tests__/cloud-daemon.test.ts +237 -0
  35. package/src/__tests__/cloud-mode.test.ts +65 -0
  36. package/src/__tests__/cloud-settle.test.ts +287 -0
  37. package/src/__tests__/daemon-singleton.test.ts +89 -0
  38. package/src/__tests__/doctor.test.ts +34 -0
  39. package/src/__tests__/runtime-discovery.test.ts +90 -0
  40. package/src/__tests__/status-render.test.ts +34 -0
  41. package/src/cloud-auth.ts +78 -0
  42. package/src/cloud-daemon.ts +338 -0
  43. package/src/cloud-mode.ts +70 -0
  44. package/src/cloud-settle.ts +182 -0
  45. package/src/daemon-singleton.ts +122 -0
  46. package/src/daemon.ts +18 -5
  47. package/src/doctor.ts +18 -5
  48. package/src/gateway/__tests__/botcord-channel.test.ts +74 -0
  49. package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
  50. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
  51. package/src/gateway/__tests__/dispatcher.test.ts +120 -0
  52. package/src/gateway/channels/botcord.ts +54 -7
  53. package/src/gateway/dispatcher.ts +354 -21
  54. package/src/gateway/gateway.ts +16 -1
  55. package/src/gateway/runtime-errors.ts +15 -0
  56. package/src/gateway/runtimes/claude-code.ts +98 -2
  57. package/src/gateway/runtimes/deepseek-tui.ts +23 -5
  58. package/src/gateway/transcript.ts +1 -1
  59. package/src/gateway/types.ts +34 -0
  60. package/src/index.ts +83 -74
  61. package/src/provision.ts +45 -1
  62. 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(sink: RuntimeSnapshotSink): boolean {
226
- const snap = collectRuntimeSnapshot();
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
- const gateway = new Gateway({
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 responds 2xx/3xx/404 which is fine for
171
- // "reachable". We treat any response as reachable; network errors fall
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: RuntimeProbeEntry[],
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 {