@botcord/daemon 0.2.75 → 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,47 @@
1
+ /**
2
+ * Cloud-daemon auth manager.
3
+ *
4
+ * Implements the subset of `UserAuthManager` surface that `ControlChannel`
5
+ * uses (`current`, `ensureAccessToken`) so the same channel implementation
6
+ * can be reused for `/cloud/daemon/ws`. Unlike the user variant there is
7
+ * no refresh token: the Hub-managed E2B provider rotates the access token
8
+ * by relaunching the daemon. When the embedded JWT expires, the WS server
9
+ * closes with 4401 and `ControlChannel.onClose` writes the auth-expired
10
+ * flag — at which point the provider would resume the sandbox with a
11
+ * fresh token.
12
+ *
13
+ * Plan §6.4: `auth-expired.flag` is still written so any external monitor
14
+ * watching the sandbox filesystem can detect the situation; the cloud
15
+ * provider doesn't read this file directly today (it relies on
16
+ * `daemon_instances.last_seen_at` going stale instead).
17
+ *
18
+ * Field names match `UserAuthRecord` for drop-in compatibility with
19
+ * `ControlChannel.start()` which reads `auth.current.{userId,hubUrl,label}`.
20
+ */
21
+ import type { UserAuthRecord, UserAuthManager } from "./user-auth.js";
22
+ import type { CloudModeConfig } from "./cloud-mode.js";
23
+ /**
24
+ * Minimal `UserAuthManager`-shaped wrapper backed by the cloud-mode env
25
+ * vars. Static-typed against `UserAuthManager` so `ControlChannel` accepts
26
+ * it without an interface change.
27
+ */
28
+ export declare class CloudAuthManager {
29
+ private record;
30
+ constructor(cfg: CloudModeConfig);
31
+ get current(): UserAuthRecord;
32
+ /**
33
+ * Cloud-mode access token never refreshes locally — it's baked into the
34
+ * JWT the provider injected at sandbox start. The provider rotates by
35
+ * relaunching the daemon, not by talking to a refresh endpoint.
36
+ */
37
+ ensureAccessToken(): Promise<string>;
38
+ }
39
+ /**
40
+ * Hand the cloud auth wrapper out as a `UserAuthManager` so `ControlChannel`
41
+ * (which only consults `current` and `ensureAccessToken`) accepts it.
42
+ *
43
+ * Cast-only — no runtime translation needed because `CloudAuthManager`
44
+ * implements the same shape. Kept as a single helper so the cast is
45
+ * documented in one place.
46
+ */
47
+ export declare function asUserAuthManager(mgr: CloudAuthManager): UserAuthManager;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Minimal `UserAuthManager`-shaped wrapper backed by the cloud-mode env
3
+ * vars. Static-typed against `UserAuthManager` so `ControlChannel` accepts
4
+ * it without an interface change.
5
+ */
6
+ export class CloudAuthManager {
7
+ record;
8
+ constructor(cfg) {
9
+ this.record = {
10
+ version: 1,
11
+ // The cloud daemon row is owned by a single user — but we don't get
12
+ // the user id in the env (the JWT carries it server-side). Surface
13
+ // the cloud daemon instance id as a stable "who am I" string for
14
+ // logs; the Hub already knows the binding.
15
+ userId: cfg.cloudDaemonInstanceId,
16
+ daemonInstanceId: cfg.daemonInstanceId,
17
+ hubUrl: cfg.hubUrl,
18
+ accessToken: cfg.accessToken,
19
+ // No refresh token in cloud mode. Stored as an empty string to keep
20
+ // the type intact; `ensureAccessToken` never reaches the refresh path
21
+ // because `expiresAt` is set to a date far in the future (the Hub
22
+ // closes the WS with 4401 when the embedded JWT expires).
23
+ refreshToken: "",
24
+ expiresAt: Number.MAX_SAFE_INTEGER,
25
+ loggedInAt: new Date().toISOString(),
26
+ label: `cloud:${cfg.cloudDaemonInstanceId}`,
27
+ };
28
+ }
29
+ get current() {
30
+ return this.record;
31
+ }
32
+ /**
33
+ * Cloud-mode access token never refreshes locally — it's baked into the
34
+ * JWT the provider injected at sandbox start. The provider rotates by
35
+ * relaunching the daemon, not by talking to a refresh endpoint.
36
+ */
37
+ async ensureAccessToken() {
38
+ return this.record.accessToken;
39
+ }
40
+ }
41
+ /**
42
+ * Hand the cloud auth wrapper out as a `UserAuthManager` so `ControlChannel`
43
+ * (which only consults `current` and `ensureAccessToken`) accepts it.
44
+ *
45
+ * Cast-only — no runtime translation needed because `CloudAuthManager`
46
+ * implements the same shape. Kept as a single helper so the cast is
47
+ * documented in one place.
48
+ */
49
+ export function asUserAuthManager(mgr) {
50
+ return mgr;
51
+ }
@@ -0,0 +1,43 @@
1
+ import { type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
2
+ import type { DaemonConfig } from "./config.js";
3
+ import { ControlChannel } from "./control-channel.js";
4
+ import { createProvisioner } from "./provision.js";
5
+ import type { CloudModeConfig } from "./cloud-mode.js";
6
+ /** Options accepted by {@link startCloudDaemon}. */
7
+ export interface CloudDaemonRuntimeOptions {
8
+ /** Resolved env-driven cloud config (see {@link loadCloudModeConfig}). */
9
+ cloudConfig: CloudModeConfig;
10
+ /**
11
+ * Empty/initial DaemonConfig. Cloud daemons start with zero agents and
12
+ * grow exclusively via `provision_agent` frames over the cloud control
13
+ * plane — `agents[]` / `routes[]` arrays are seeded empty.
14
+ */
15
+ config: DaemonConfig;
16
+ configPath: string;
17
+ sessionStorePath?: string;
18
+ snapshotPath?: string;
19
+ snapshotIntervalMs?: number;
20
+ log?: GatewayLogger;
21
+ /** Test hook — override the control-channel cstr. */
22
+ controlChannelFactory?: typeof ControlChannel;
23
+ /** Skip control channel entirely; for tests that exercise the gateway only. */
24
+ disableControlChannel?: boolean;
25
+ /**
26
+ * Test hook — inject a pre-built provisioner. Default uses
27
+ * `createProvisioner({ gateway, policyResolver, onAgentInstalled })`.
28
+ */
29
+ provisionerFactory?: typeof createProvisioner;
30
+ }
31
+ /** Handle returned by {@link startCloudDaemon}. */
32
+ export interface CloudDaemonHandle {
33
+ stop: (reason?: string) => Promise<void>;
34
+ snapshot: () => GatewayRuntimeSnapshot;
35
+ }
36
+ /**
37
+ * Boot the cloud daemon. The gateway starts with zero channels; every
38
+ * provisioned agent arrives via `provision_agent`, which calls into the
39
+ * shared `provision.ts` flow exactly like a local daemon does. The only
40
+ * difference is the control-channel auth, endpoint path, and the absence
41
+ * of a local user-auth file.
42
+ */
43
+ export declare function startCloudDaemon(opts: CloudDaemonRuntimeOptions): Promise<CloudDaemonHandle>;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Cloud-daemon mode runtime entrypoint.
3
+ *
4
+ * Equivalent to {@link startDaemon} for cloud-mode operation: skips local
5
+ * user-auth, skips local on-disk credentials, dials
6
+ * `${HUB_URL}/cloud/daemon/ws` with the env-injected JWT, and reuses the
7
+ * existing provisioner so `provision_agent` / `revoke_agent` frames work
8
+ * the same way they do for local daemons.
9
+ *
10
+ * See ``docs/cloud-agent-technical-design.md`` §4 + §6.
11
+ */
12
+ import { shouldWake } from "@botcord/protocol-core";
13
+ import { Gateway, resolveTranscriptEnabled, } from "./gateway/index.js";
14
+ import { ActivityTracker } from "./activity-tracker.js";
15
+ import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
16
+ import { ControlChannel } from "./control-channel.js";
17
+ import { toGatewayConfig } from "./daemon-config-map.js";
18
+ import { log as daemonLog } from "./log.js";
19
+ import { createProvisioner } from "./provision.js";
20
+ import { createDaemonChannel, pushRuntimeSnapshot } from "./daemon.js";
21
+ import { SnapshotWriter } from "./snapshot-writer.js";
22
+ import { createDaemonSystemContextBuilder } from "./system-context.js";
23
+ import { readWorkingMemorySnapshot } from "./working-memory.js";
24
+ import { createRoomStaticContextBuilder } from "./room-context.js";
25
+ import { createRoomContextFetcher } from "./room-context-fetcher.js";
26
+ import { composeBotCordUserTurn } from "./turn-text.js";
27
+ import { PolicyResolver } from "./gateway/policy-resolver.js";
28
+ import { scanMention } from "./mention-scan.js";
29
+ import { createActivityRecorder } from "./daemon.js";
30
+ import { CloudAuthManager, asUserAuthManager } from "./cloud-auth.js";
31
+ import { buildCloudRunSettleHook } from "./cloud-settle.js";
32
+ // Cloud daemons follow the same cadence as local — keeps dashboard
33
+ // "runtimes last detected" behavior identical across both kinds.
34
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
35
+ const DEFAULT_SNAPSHOT_INTERVAL_MS = 5_000;
36
+ function resolveSnapshotIntervalMs() {
37
+ const raw = process.env.BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS;
38
+ if (!raw)
39
+ return DEFAULT_SNAPSHOT_INTERVAL_MS;
40
+ const n = Number(raw);
41
+ if (!Number.isFinite(n) || n <= 0)
42
+ return DEFAULT_SNAPSHOT_INTERVAL_MS;
43
+ return n;
44
+ }
45
+ function buildLogger(opt) {
46
+ if (opt)
47
+ return opt;
48
+ return {
49
+ info: (msg, meta) => daemonLog.info(msg, meta),
50
+ warn: (msg, meta) => daemonLog.warn(msg, meta),
51
+ error: (msg, meta) => daemonLog.error(msg, meta),
52
+ debug: (msg, meta) => daemonLog.debug(msg, meta),
53
+ };
54
+ }
55
+ /**
56
+ * Boot the cloud daemon. The gateway starts with zero channels; every
57
+ * provisioned agent arrives via `provision_agent`, which calls into the
58
+ * shared `provision.ts` flow exactly like a local daemon does. The only
59
+ * difference is the control-channel auth, endpoint path, and the absence
60
+ * of a local user-auth file.
61
+ */
62
+ export async function startCloudDaemon(opts) {
63
+ const logger = buildLogger(opts.log);
64
+ const cloudCfg = opts.cloudConfig;
65
+ logger.info("cloud daemon starting", {
66
+ cloudDaemonInstanceId: cloudCfg.cloudDaemonInstanceId,
67
+ daemonInstanceId: cloudCfg.daemonInstanceId,
68
+ hubUrl: cloudCfg.hubUrl,
69
+ });
70
+ // ActivityTracker / policy resolver / per-agent caches — same as local
71
+ // daemon, but the caches start empty because no agents are bound at
72
+ // boot. `onAgentInstalled` populates them whenever provision_agent
73
+ // lands.
74
+ const activityTracker = new ActivityTracker();
75
+ const credentialPathByAgentId = new Map();
76
+ const hubUrlByAgentId = new Map();
77
+ const displayNameByAgent = new Map();
78
+ // Seed each per-agent hub URL with the cloud-mode value so that even
79
+ // before the first credential file is written the room-context fetcher
80
+ // has somewhere sensible to point.
81
+ const fallbackHubUrl = cloudCfg.hubUrl;
82
+ const resolveHubUrl = (accountId) => hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
83
+ // Same gateway-config translation as local — empty `agents` produces an
84
+ // empty `channels[]` initially, which is fine.
85
+ const gwConfig = toGatewayConfig(opts.config, { agentIds: [], agentRuntimes: {} });
86
+ const roomContextFetcher = createRoomContextFetcher({
87
+ credentialPathByAgentId,
88
+ hubBaseUrl: cloudCfg.hubUrl,
89
+ log: logger,
90
+ });
91
+ const roomContextBuilder = createRoomStaticContextBuilder({
92
+ fetchRoomInfo: roomContextFetcher,
93
+ log: logger,
94
+ });
95
+ const scBuilders = new Map();
96
+ const buildSystemContext = (message) => {
97
+ const b = scBuilders.get(message.accountId);
98
+ return b ? b(message) : undefined;
99
+ };
100
+ const buildMemoryContext = (message) => readWorkingMemorySnapshot(message.accountId);
101
+ const recordActivity = createActivityRecorder({ activityTracker });
102
+ const onInbound = (msg) => {
103
+ recordActivity(msg);
104
+ };
105
+ // Settle ``cloud_run`` envelopes against the Hub usage ledger once the
106
+ // runtime turn finishes. Pure adapter from the dispatcher's hook shape
107
+ // to the settle helper's input shape — the actual HTTP call lives in
108
+ // :func:`buildCloudRunSettleHook` so it's unit-testable.
109
+ const settleHook = buildCloudRunSettleHook({
110
+ hubUrl: cloudCfg.hubUrl,
111
+ accessToken: cloudCfg.accessToken,
112
+ log: logger,
113
+ });
114
+ const onTurnComplete = async (event) => {
115
+ const envelope = event.message.raw
116
+ ?.envelope;
117
+ const runId = envelope?.payload?.cloud_run?.run_id;
118
+ await settleHook({
119
+ envelopeType: envelope?.type,
120
+ runId: typeof runId === "string" ? runId : undefined,
121
+ wallTimeMs: event.wallTimeMs,
122
+ tokens: {
123
+ ...(event.result?.inputCacheHitTokens !== undefined
124
+ ? { inputCacheHitTokens: event.result.inputCacheHitTokens }
125
+ : {}),
126
+ ...(event.result?.inputCacheMissTokens !== undefined
127
+ ? { inputCacheMissTokens: event.result.inputCacheMissTokens }
128
+ : {}),
129
+ ...(event.result?.outputTokens !== undefined
130
+ ? { outputTokens: event.result.outputTokens }
131
+ : {}),
132
+ },
133
+ messageId: event.message.id,
134
+ });
135
+ };
136
+ const policyResolver = new PolicyResolver({
137
+ fetchGlobal: async (_agentId) => undefined,
138
+ });
139
+ const attentionGate = async (msg) => {
140
+ const policy = await policyResolver.resolve(msg.accountId, msg.conversation.id);
141
+ if (policy.mode === "allowed_senders") {
142
+ return (policy.allowedSenderIds ?? []).includes(msg.sender.id);
143
+ }
144
+ const localMention = scanMention(msg.text, {
145
+ agentId: msg.accountId,
146
+ displayName: displayNameByAgent.get(msg.accountId),
147
+ });
148
+ return shouldWake(policy, {
149
+ mentioned: msg.mentioned === true || localMention,
150
+ text: msg.text,
151
+ });
152
+ };
153
+ const onAgentInstalled = (info) => {
154
+ credentialPathByAgentId.set(info.agentId, info.credentialsFile);
155
+ if (info.hubUrl)
156
+ hubUrlByAgentId.set(info.agentId, info.hubUrl);
157
+ if (info.displayName)
158
+ displayNameByAgent.set(info.agentId, info.displayName);
159
+ if (!scBuilders.has(info.agentId)) {
160
+ scBuilders.set(info.agentId, createDaemonSystemContextBuilder({
161
+ agentId: info.agentId,
162
+ activityTracker,
163
+ roomContextBuilder,
164
+ // Cloud daemons run isolated — no loop-risk guard wired in PR1;
165
+ // the runtime adapter's wall-time budget enforces the equivalent.
166
+ loopRiskBuilder: () => null,
167
+ }));
168
+ }
169
+ };
170
+ const gateway = new Gateway({
171
+ config: gwConfig,
172
+ sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
173
+ createChannel: (chCfg) => {
174
+ return createDaemonChannel(chCfg, {
175
+ credentialPathByAgentId,
176
+ hubBaseUrl: cloudCfg.hubUrl,
177
+ });
178
+ },
179
+ log: logger,
180
+ turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
181
+ buildSystemContext,
182
+ buildMemoryContext,
183
+ onInbound,
184
+ onTurnComplete,
185
+ composeUserTurn: composeBotCordUserTurn,
186
+ attentionGate,
187
+ resolveHubUrl,
188
+ transcriptEnabled: resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, opts.config.transcript?.enabled),
189
+ });
190
+ await gateway.start();
191
+ logger.info("cloud daemon gateway started (zero agents at boot)");
192
+ let controlChannel = null;
193
+ if (!opts.disableControlChannel) {
194
+ const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
195
+ const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
196
+ const provisioner = provisionerFactory({
197
+ gateway,
198
+ policyResolver,
199
+ onAgentInstalled,
200
+ });
201
+ const ControlChannelCtor = opts.controlChannelFactory ?? ControlChannel;
202
+ controlChannel = new ControlChannelCtor({
203
+ auth,
204
+ // The cloud WS endpoint differs from the local daemon WS — same
205
+ // frame schema, different bearer-token kind on the Hub side.
206
+ path: "/cloud/daemon/ws",
207
+ handle: async (frame) => provisioner(frame),
208
+ label: `cloud:${cloudCfg.cloudDaemonInstanceId}`,
209
+ });
210
+ try {
211
+ await controlChannel.start();
212
+ // Same `runtime_snapshot` push as local — keeps the dashboard's
213
+ // "what's installed" view accurate the moment the daemon comes up.
214
+ const pushed = pushRuntimeSnapshot(controlChannel);
215
+ logger.info("cloud control-channel started; runtime_snapshot pushed", {
216
+ ok: pushed,
217
+ });
218
+ }
219
+ catch (err) {
220
+ logger.warn("cloud control-channel start failed; daemon will retry", {
221
+ error: err instanceof Error ? err.message : String(err),
222
+ });
223
+ }
224
+ }
225
+ const snapshotWriter = new SnapshotWriter({
226
+ path: opts.snapshotPath ?? SNAPSHOT_PATH,
227
+ intervalMs: opts.snapshotIntervalMs ?? resolveSnapshotIntervalMs(),
228
+ snapshot: () => gateway.snapshot(),
229
+ log: logger,
230
+ });
231
+ snapshotWriter.start();
232
+ let stopping = null;
233
+ const stop = (reason) => {
234
+ if (stopping)
235
+ return stopping;
236
+ logger.info("cloud daemon stopping", { reason: reason ?? null });
237
+ snapshotWriter.stop();
238
+ snapshotWriter.writeFinal();
239
+ const controlStopP = controlChannel
240
+ ? controlChannel.stop().catch(() => undefined)
241
+ : Promise.resolve();
242
+ stopping = Promise.all([controlStopP, gateway.stop(reason)]).then(() => undefined).finally(() => {
243
+ snapshotWriter.remove();
244
+ logger.info("cloud daemon stopped", { reason: reason ?? null });
245
+ });
246
+ return stopping;
247
+ };
248
+ return {
249
+ stop,
250
+ snapshot: () => gateway.snapshot(),
251
+ };
252
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Cloud-daemon mode detection + env-driven configuration.
3
+ *
4
+ * A "cloud daemon" is a `botcord-daemon` process running inside a Hub-managed
5
+ * E2B sandbox. It is configured exclusively through environment variables
6
+ * (no on-disk `user-auth.json`) and connects to `/cloud/daemon/ws` with a
7
+ * `cloud-daemon-access` JWT instead of the device-code-issued user token.
8
+ *
9
+ * The Hub-side provider that launches the daemon is
10
+ * `backend/hub/services/cloud_daemon_provider_e2b.py` — keep the env-var
11
+ * names below in sync with `_build_env` there.
12
+ *
13
+ * See ``docs/cloud-agent-technical-design.md`` §3-4.
14
+ */
15
+ /** Names of the environment variables the cloud provider injects. */
16
+ export declare const CLOUD_ENV_VARS: {
17
+ readonly HUB_URL: "BOTCORD_HUB_URL";
18
+ readonly CLOUD_DAEMON_INSTANCE_ID: "BOTCORD_CLOUD_DAEMON_INSTANCE_ID";
19
+ readonly DAEMON_INSTANCE_ID: "BOTCORD_DAEMON_INSTANCE_ID";
20
+ readonly ACCESS_TOKEN: "BOTCORD_CLOUD_DAEMON_ACCESS_TOKEN";
21
+ };
22
+ /** Resolved cloud-mode configuration. All fields are required when present. */
23
+ export interface CloudModeConfig {
24
+ hubUrl: string;
25
+ cloudDaemonInstanceId: string;
26
+ daemonInstanceId: string;
27
+ accessToken: string;
28
+ }
29
+ /**
30
+ * Detection signal — true when `BOTCORD_CLOUD_DAEMON_ACCESS_TOKEN` is set.
31
+ *
32
+ * The access-token presence is the canonical mode switch (matches the
33
+ * provider contract — the token is the one piece the sandbox can't forge).
34
+ * Other env vars may be set during development without flipping mode.
35
+ */
36
+ export declare function isCloudMode(env?: NodeJS.ProcessEnv): boolean;
37
+ /**
38
+ * Resolve the cloud-mode configuration from env vars. Throws when a required
39
+ * variable is missing — the daemon must fail fast instead of falling through
40
+ * to the local-mode codepath with partial cloud config.
41
+ *
42
+ * `BOTCORD_DAEMON_INSTANCE_ID` is allowed to fall back to the cloud daemon
43
+ * id when omitted in tests, but in production the provider always sets it.
44
+ */
45
+ export declare function loadCloudModeConfig(env?: NodeJS.ProcessEnv): CloudModeConfig;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Cloud-daemon mode detection + env-driven configuration.
3
+ *
4
+ * A "cloud daemon" is a `botcord-daemon` process running inside a Hub-managed
5
+ * E2B sandbox. It is configured exclusively through environment variables
6
+ * (no on-disk `user-auth.json`) and connects to `/cloud/daemon/ws` with a
7
+ * `cloud-daemon-access` JWT instead of the device-code-issued user token.
8
+ *
9
+ * The Hub-side provider that launches the daemon is
10
+ * `backend/hub/services/cloud_daemon_provider_e2b.py` — keep the env-var
11
+ * names below in sync with `_build_env` there.
12
+ *
13
+ * See ``docs/cloud-agent-technical-design.md`` §3-4.
14
+ */
15
+ /** Names of the environment variables the cloud provider injects. */
16
+ export const CLOUD_ENV_VARS = {
17
+ HUB_URL: "BOTCORD_HUB_URL",
18
+ CLOUD_DAEMON_INSTANCE_ID: "BOTCORD_CLOUD_DAEMON_INSTANCE_ID",
19
+ DAEMON_INSTANCE_ID: "BOTCORD_DAEMON_INSTANCE_ID",
20
+ ACCESS_TOKEN: "BOTCORD_CLOUD_DAEMON_ACCESS_TOKEN",
21
+ };
22
+ /**
23
+ * Detection signal — true when `BOTCORD_CLOUD_DAEMON_ACCESS_TOKEN` is set.
24
+ *
25
+ * The access-token presence is the canonical mode switch (matches the
26
+ * provider contract — the token is the one piece the sandbox can't forge).
27
+ * Other env vars may be set during development without flipping mode.
28
+ */
29
+ export function isCloudMode(env = process.env) {
30
+ const token = env[CLOUD_ENV_VARS.ACCESS_TOKEN];
31
+ return typeof token === "string" && token.length > 0;
32
+ }
33
+ /**
34
+ * Resolve the cloud-mode configuration from env vars. Throws when a required
35
+ * variable is missing — the daemon must fail fast instead of falling through
36
+ * to the local-mode codepath with partial cloud config.
37
+ *
38
+ * `BOTCORD_DAEMON_INSTANCE_ID` is allowed to fall back to the cloud daemon
39
+ * id when omitted in tests, but in production the provider always sets it.
40
+ */
41
+ export function loadCloudModeConfig(env = process.env) {
42
+ const requireString = (name) => {
43
+ const v = env[name];
44
+ if (typeof v !== "string" || v.length === 0) {
45
+ throw new Error(`cloud-daemon mode: required env var "${name}" is missing or empty`);
46
+ }
47
+ return v;
48
+ };
49
+ return {
50
+ hubUrl: requireString(CLOUD_ENV_VARS.HUB_URL),
51
+ cloudDaemonInstanceId: requireString(CLOUD_ENV_VARS.CLOUD_DAEMON_INSTANCE_ID),
52
+ daemonInstanceId: requireString(CLOUD_ENV_VARS.DAEMON_INSTANCE_ID),
53
+ accessToken: requireString(CLOUD_ENV_VARS.ACCESS_TOKEN),
54
+ };
55
+ }
@@ -0,0 +1,81 @@
1
+ /** Token usage breakdown reported alongside the wall-clock time. */
2
+ export interface CloudRunSettleUsage {
3
+ provider: string;
4
+ model: string;
5
+ inputCacheHitTokens: number;
6
+ inputCacheMissTokens: number;
7
+ outputTokens: number;
8
+ sandboxSeconds: number;
9
+ }
10
+ /** Inputs accepted by {@link postCloudRunSettle}. */
11
+ export interface CloudRunSettleInput extends CloudRunSettleUsage {
12
+ hubUrl: string;
13
+ accessToken: string;
14
+ runId: string;
15
+ /** Override the idempotency key. Defaults to `<run_id>:settle`. */
16
+ idempotencyKey?: string;
17
+ /** Test seam — override `fetch`. */
18
+ fetchFn?: typeof fetch;
19
+ /** Override timeout for the POST. Defaults to 10s. */
20
+ timeoutMs?: number;
21
+ }
22
+ /** Outcome of {@link postCloudRunSettle}. */
23
+ export interface CloudRunSettleResult {
24
+ ok: boolean;
25
+ status: number;
26
+ /** Raw response body, when the Hub returned one. */
27
+ body?: unknown;
28
+ }
29
+ /**
30
+ * POST the settle payload to the Hub. Resolves with `ok=false` for non-2xx
31
+ * responses instead of throwing so the caller can decide whether to retry
32
+ * or surface the failure into the runtime log; throws only on transport
33
+ * errors (timeout / DNS) which are fully outside the daemon's control.
34
+ *
35
+ * Body shape matches the Hub-side `UsageEventCreate`-like contract:
36
+ *
37
+ * {
38
+ * "provider": <str>,
39
+ * "model": <str>,
40
+ * "input_cache_hit_tokens": <int>,
41
+ * "input_cache_miss_tokens": <int>,
42
+ * "output_tokens": <int>,
43
+ * "sandbox_seconds": <int>,
44
+ * "idempotency_key": "<run_id>:settle"
45
+ * }
46
+ *
47
+ * Snake_case is intentional: the Hub's Pydantic models use snake_case for
48
+ * the public surface even when the surrounding daemon control plane uses
49
+ * camelCase.
50
+ */
51
+ export interface CloudRunSettleHookDeps {
52
+ hubUrl: string;
53
+ accessToken: string;
54
+ /** Fallback model name when the envelope doesn't carry one. */
55
+ defaultModel?: string;
56
+ /** Logger surface — only `warn` / `info` used. */
57
+ log?: {
58
+ info(msg: string, ctx?: Record<string, unknown>): void;
59
+ warn(msg: string, ctx?: Record<string, unknown>): void;
60
+ };
61
+ /** Test seam. */
62
+ fetchFn?: typeof fetch;
63
+ }
64
+ /** Minimal subset of an inbound message needed by the settle hook. */
65
+ export interface CloudRunSettleHookEvent {
66
+ envelopeType?: string | undefined;
67
+ runId?: string | undefined;
68
+ wallTimeMs: number;
69
+ tokens?: {
70
+ inputCacheHitTokens?: number;
71
+ inputCacheMissTokens?: number;
72
+ outputTokens?: number;
73
+ };
74
+ messageId?: string;
75
+ }
76
+ /**
77
+ * Build the settle hook used by ``startCloudDaemon``. Extracted so unit
78
+ * tests can drive it directly without standing up the full gateway.
79
+ */
80
+ export declare function buildCloudRunSettleHook(deps: CloudRunSettleHookDeps): (event: CloudRunSettleHookEvent) => Promise<void>;
81
+ export declare function postCloudRunSettle(input: CloudRunSettleInput): Promise<CloudRunSettleResult>;
@@ -0,0 +1,100 @@
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
+ * Build the settle hook used by ``startCloudDaemon``. Extracted so unit
18
+ * tests can drive it directly without standing up the full gateway.
19
+ */
20
+ export function buildCloudRunSettleHook(deps) {
21
+ const log = deps.log;
22
+ const model = deps.defaultModel ?? "deepseek-v4-flash";
23
+ return async (event) => {
24
+ if (event.envelopeType !== "cloud_run")
25
+ return;
26
+ const runId = event.runId;
27
+ if (typeof runId !== "string" || runId.length === 0) {
28
+ log?.warn("cloud_run envelope missing run_id; skipping settle", {
29
+ messageId: event.messageId ?? "<unknown>",
30
+ });
31
+ return;
32
+ }
33
+ const sandboxSeconds = Math.max(1, Math.round(event.wallTimeMs / 1000));
34
+ try {
35
+ const settleResult = await postCloudRunSettle({
36
+ hubUrl: deps.hubUrl,
37
+ accessToken: deps.accessToken,
38
+ runId,
39
+ provider: "deepseek",
40
+ model,
41
+ // The deepseek-tui adapter does not yet surface token counts;
42
+ // ``UsageService`` charges by sandbox-seconds when tokens are
43
+ // zero. Filling these in is the next runtime-adapter PR.
44
+ inputCacheHitTokens: event.tokens?.inputCacheHitTokens ?? 0,
45
+ inputCacheMissTokens: event.tokens?.inputCacheMissTokens ?? 0,
46
+ outputTokens: event.tokens?.outputTokens ?? 0,
47
+ sandboxSeconds,
48
+ ...(deps.fetchFn ? { fetchFn: deps.fetchFn } : {}),
49
+ });
50
+ if (!settleResult.ok) {
51
+ log?.warn("cloud_run settle returned non-2xx", {
52
+ runId,
53
+ status: settleResult.status,
54
+ });
55
+ }
56
+ else {
57
+ log?.info("cloud_run settled", { runId, sandboxSeconds });
58
+ }
59
+ }
60
+ catch (err) {
61
+ // Transport errors only — Hub-side rejections surface as ok=false.
62
+ log?.warn("cloud_run settle threw — continuing", {
63
+ runId,
64
+ error: err instanceof Error ? err.message : String(err),
65
+ });
66
+ }
67
+ };
68
+ }
69
+ export async function postCloudRunSettle(input) {
70
+ const base = normalizeAndValidateHubUrl(input.hubUrl);
71
+ const url = `${base}/internal/cloud-agents/runs/${encodeURIComponent(input.runId)}/settle`;
72
+ const idempotencyKey = input.idempotencyKey ?? `${input.runId}:settle`;
73
+ const body = {
74
+ provider: input.provider,
75
+ model: input.model,
76
+ input_cache_hit_tokens: Math.max(0, Math.floor(input.inputCacheHitTokens)),
77
+ input_cache_miss_tokens: Math.max(0, Math.floor(input.inputCacheMissTokens)),
78
+ output_tokens: Math.max(0, Math.floor(input.outputTokens)),
79
+ sandbox_seconds: Math.max(0, Math.floor(input.sandboxSeconds)),
80
+ idempotency_key: idempotencyKey,
81
+ };
82
+ const doFetch = input.fetchFn ?? fetch;
83
+ const resp = await doFetch(url, {
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ Authorization: `Bearer ${input.accessToken}`,
88
+ },
89
+ body: JSON.stringify(body),
90
+ signal: AbortSignal.timeout(input.timeoutMs ?? 10_000),
91
+ });
92
+ let parsed = undefined;
93
+ try {
94
+ parsed = await resp.json();
95
+ }
96
+ catch {
97
+ // Empty body on 204, etc. — leave parsed as undefined.
98
+ }
99
+ return { ok: resp.ok, status: resp.status, body: parsed };
100
+ }