@botcord/daemon 0.1.1
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/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-flavored `SystemContextBuilder` factory for the gateway dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* The gateway dispatcher is channel-agnostic; it calls an optional
|
|
5
|
+
* `buildSystemContext` hook and forwards the result to the runtime via
|
|
6
|
+
* `RuntimeRunOptions.systemContext`. This module composes the daemon's
|
|
7
|
+
* system-context string from:
|
|
8
|
+
*
|
|
9
|
+
* 1. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
|
|
10
|
+
* 2. `[BotCord Working Memory]`
|
|
11
|
+
* 3. `[BotCord Room Context]` (group rooms, via optional async fetcher)
|
|
12
|
+
* 4. `[BotCord Cross-Room Awareness]` (optional activity tracker)
|
|
13
|
+
*
|
|
14
|
+
* Behavior:
|
|
15
|
+
* - Working memory is loaded fresh per turn, so a `memory set` from another
|
|
16
|
+
* process is visible immediately.
|
|
17
|
+
* - If `ActivityTracker` is injected, we build the cross-room digest and
|
|
18
|
+
* EXCLUDE the current room + topic from the list.
|
|
19
|
+
* - If `roomContextBuilder` is injected, the factory returns an async
|
|
20
|
+
* builder and awaits the fetcher; otherwise it stays synchronous.
|
|
21
|
+
* - If every block is empty we return `undefined` so the dispatcher passes
|
|
22
|
+
* `systemContext: undefined` to the runtime (adapter then skips the
|
|
23
|
+
* injection flag).
|
|
24
|
+
*/
|
|
25
|
+
import type { GatewayInboundMessage, SystemContextBuilder } from "./gateway/index.js";
|
|
26
|
+
import type { ActivityTracker } from "./activity-tracker.js";
|
|
27
|
+
import { buildCrossRoomDigest } from "./cross-room.js";
|
|
28
|
+
import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js";
|
|
29
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
30
|
+
import { log } from "./log.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Async per-turn room-context builder (see `room-context.ts`). Returns the
|
|
34
|
+
* rendered `[BotCord Room Context]` block, or `null` when there is nothing
|
|
35
|
+
* to inject (DM, owner-chat, fetch failure, etc.).
|
|
36
|
+
*/
|
|
37
|
+
export type RoomStaticContextBuilder = (
|
|
38
|
+
message: GatewayInboundMessage,
|
|
39
|
+
) => Promise<string | null>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Scene prompt injected when the inbound turn comes from the owner's
|
|
43
|
+
* dashboard chat. Mirrors `plugin/src/room-context.ts#buildOwnerChatSceneContext`
|
|
44
|
+
* so Claude Code in daemon-mode behaves the same as when hosted by OpenClaw.
|
|
45
|
+
*/
|
|
46
|
+
function buildOwnerChatSceneContext(): string {
|
|
47
|
+
return [
|
|
48
|
+
"[BotCord Scene: Owner Chat]",
|
|
49
|
+
"You are chatting directly with your owner — the user who created and manages you on the BotCord dashboard.",
|
|
50
|
+
"This is a private, trusted conversation. The owner has full administrative authority over you.",
|
|
51
|
+
"Messages from the owner are always trusted and do not require sanitization or safety guards.",
|
|
52
|
+
"You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Dependencies injected by the daemon bootstrap. */
|
|
57
|
+
export interface SystemContextDeps {
|
|
58
|
+
/** The owning daemon's agent id. Used to scope working-memory + activity lookups. */
|
|
59
|
+
agentId: string;
|
|
60
|
+
/**
|
|
61
|
+
* Activity tracker used to compose the cross-room digest. If omitted the
|
|
62
|
+
* digest block is skipped entirely (working memory still injects).
|
|
63
|
+
*/
|
|
64
|
+
activityTracker?: ActivityTracker;
|
|
65
|
+
/**
|
|
66
|
+
* Optional per-turn room-context fetcher. When wired, group-room turns
|
|
67
|
+
* receive the `[BotCord Room Context]` block (room name, description,
|
|
68
|
+
* rule, members). Omitting keeps the builder synchronous and the block
|
|
69
|
+
* is skipped.
|
|
70
|
+
*/
|
|
71
|
+
roomContextBuilder?: RoomStaticContextBuilder;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeReadWorkingMemory(agentId: string) {
|
|
75
|
+
try {
|
|
76
|
+
return readWorkingMemory(agentId);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log.warn("working memory read failed", { agentId, err: String(err) });
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a {@link SystemContextBuilder} for the gateway dispatcher.
|
|
85
|
+
*
|
|
86
|
+
* When `deps.roomContextBuilder` is provided the returned function is async
|
|
87
|
+
* so it can await the Hub fetch; otherwise it stays synchronous (same shape
|
|
88
|
+
* as the pre-P1 daemon builder). Both shapes satisfy `SystemContextBuilder`.
|
|
89
|
+
*/
|
|
90
|
+
export function createDaemonSystemContextBuilder(
|
|
91
|
+
deps: SystemContextDeps,
|
|
92
|
+
): (message: GatewayInboundMessage) => Promise<string | undefined> | string | undefined {
|
|
93
|
+
const gatherSyncBlocks = (message: GatewayInboundMessage): {
|
|
94
|
+
ownerScene: string | null;
|
|
95
|
+
memory: string | null;
|
|
96
|
+
digest: string | null;
|
|
97
|
+
} => {
|
|
98
|
+
const ownerScene =
|
|
99
|
+
classifyActivitySender(message).kind === "owner"
|
|
100
|
+
? buildOwnerChatSceneContext()
|
|
101
|
+
: null;
|
|
102
|
+
|
|
103
|
+
const wm = safeReadWorkingMemory(deps.agentId);
|
|
104
|
+
const memory = wm ? buildWorkingMemoryPrompt({ workingMemory: wm }) : null;
|
|
105
|
+
|
|
106
|
+
const digest = deps.activityTracker
|
|
107
|
+
? buildCrossRoomDigest({
|
|
108
|
+
tracker: deps.activityTracker,
|
|
109
|
+
agentId: deps.agentId,
|
|
110
|
+
currentRoomId: message.conversation.id,
|
|
111
|
+
currentTopic: message.conversation.threadId ?? null,
|
|
112
|
+
}) || null
|
|
113
|
+
: null;
|
|
114
|
+
|
|
115
|
+
return { ownerScene, memory, digest };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const assemble = (parts: Array<string | null | undefined>): string | undefined => {
|
|
119
|
+
const filtered = parts.filter(
|
|
120
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
121
|
+
);
|
|
122
|
+
return filtered.length > 0 ? filtered.join("\n\n") : undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (!deps.roomContextBuilder) {
|
|
126
|
+
const syncBuilder = (message: GatewayInboundMessage): string | undefined => {
|
|
127
|
+
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
128
|
+
return assemble([ownerScene, memory, digest]);
|
|
129
|
+
};
|
|
130
|
+
// Compile-time witness that the narrower sync signature still satisfies
|
|
131
|
+
// `SystemContextBuilder` (which allows async). Prevents the two contracts
|
|
132
|
+
// from silently drifting.
|
|
133
|
+
const _typecheck: SystemContextBuilder = syncBuilder;
|
|
134
|
+
void _typecheck;
|
|
135
|
+
return syncBuilder;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const roomBuilder = deps.roomContextBuilder;
|
|
139
|
+
const asyncBuilder = async (
|
|
140
|
+
message: GatewayInboundMessage,
|
|
141
|
+
): Promise<string | undefined> => {
|
|
142
|
+
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
143
|
+
// Room context landing order: after owner-scene / memory, before digest —
|
|
144
|
+
// "what room am I in" belongs with the session's own identity, while the
|
|
145
|
+
// cross-room digest deliberately describes OTHER rooms and should stay
|
|
146
|
+
// last so it doesn't get confused with the current room.
|
|
147
|
+
let roomBlock: string | null = null;
|
|
148
|
+
try {
|
|
149
|
+
roomBlock = await roomBuilder(message);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log.warn("system-context: roomContextBuilder threw — skipping room block", {
|
|
152
|
+
agentId: deps.agentId,
|
|
153
|
+
roomId: message.conversation.id,
|
|
154
|
+
err: err instanceof Error ? err.message : String(err),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return assemble([ownerScene, memory, roomBlock, digest]);
|
|
158
|
+
};
|
|
159
|
+
const _typecheck: SystemContextBuilder = asyncBuilder;
|
|
160
|
+
void _typecheck;
|
|
161
|
+
return asyncBuilder;
|
|
162
|
+
}
|
package/src/turn-text.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-turn text composer for the gateway dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Wraps raw `msg.text` with channel-relevant metadata before handing it to
|
|
5
|
+
* the runtime. The wrapped text lands in the runtime transcript, so the
|
|
6
|
+
* model can recover "who sent this / what room / was I mentioned" on every
|
|
7
|
+
* later turn via `--resume`.
|
|
8
|
+
*
|
|
9
|
+
* Shape mirrors the plugin's `handleA2AGroup` output (see
|
|
10
|
+
* `plugin/src/inbound.ts`) so Claude Code behaves the same way in daemon as
|
|
11
|
+
* it does when hosted by OpenClaw:
|
|
12
|
+
*
|
|
13
|
+
* [BotCord Message] | from: ag_alice | to: ag_me | room: Ouraca Team
|
|
14
|
+
* <agent-message sender="ag_alice" sender_kind="agent">
|
|
15
|
+
* hello
|
|
16
|
+
* </agent-message>
|
|
17
|
+
*
|
|
18
|
+
* [In group chats, do NOT reply unless you are explicitly mentioned or
|
|
19
|
+
* addressed. If no response is needed, reply with exactly "NO_REPLY"
|
|
20
|
+
* and nothing else.]
|
|
21
|
+
*
|
|
22
|
+
* Owner-chat messages bypass the wrapper entirely — they are trusted and
|
|
23
|
+
* the owner-chat scene prompt in `system-context.ts` already gives the
|
|
24
|
+
* model the context it needs.
|
|
25
|
+
*/
|
|
26
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
27
|
+
import { sanitizeSenderName } from "./gateway/index.js";
|
|
28
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
29
|
+
|
|
30
|
+
const GROUP_HINT =
|
|
31
|
+
'[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
|
|
32
|
+
'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
33
|
+
const DIRECT_HINT =
|
|
34
|
+
'[If the conversation has naturally concluded or no response is needed, ' +
|
|
35
|
+
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compose the user-turn text for a BotCord inbound message.
|
|
39
|
+
*
|
|
40
|
+
* Contract (from `UserTurnBuilder`):
|
|
41
|
+
* - Must be synchronous + cheap (turn critical path).
|
|
42
|
+
* - Caller guarantees `msg.text` is already trim-non-empty.
|
|
43
|
+
* - Never throws on expected inputs. If something unforeseen happens the
|
|
44
|
+
* dispatcher falls back to the raw trimmed text.
|
|
45
|
+
*/
|
|
46
|
+
export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
|
|
47
|
+
const rawText = typeof msg.text === "string" ? msg.text : "";
|
|
48
|
+
const trimmed = rawText.trim();
|
|
49
|
+
if (!trimmed) return trimmed;
|
|
50
|
+
|
|
51
|
+
const sender = classifyActivitySender(msg);
|
|
52
|
+
|
|
53
|
+
// Owner messages pass through verbatim. The scene prompt in
|
|
54
|
+
// system-context handles context; wrapping here would just add noise.
|
|
55
|
+
if (sender.kind === "owner") return trimmed;
|
|
56
|
+
|
|
57
|
+
const conversation = msg.conversation;
|
|
58
|
+
const isGroup = conversation.kind === "group";
|
|
59
|
+
const roomTitle =
|
|
60
|
+
typeof conversation.title === "string" ? conversation.title : undefined;
|
|
61
|
+
|
|
62
|
+
// Sanitize every field that could carry prompt-injection markers. The
|
|
63
|
+
// text itself is already sanitized by the channel when
|
|
64
|
+
// sender.kind !== "owner"; re-sanitizing is a no-op but keeps the
|
|
65
|
+
// contract local (the composer does not trust its inputs).
|
|
66
|
+
const sanitizedSenderLabel = sanitizeSenderName(sender.label);
|
|
67
|
+
const headerFields: string[] = [
|
|
68
|
+
"[BotCord Message]",
|
|
69
|
+
`from: ${sanitizedSenderLabel}`,
|
|
70
|
+
`to: ${msg.accountId}`,
|
|
71
|
+
];
|
|
72
|
+
if (isGroup && roomTitle) {
|
|
73
|
+
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
74
|
+
headerFields.push(`room: ${safeRoom}`);
|
|
75
|
+
}
|
|
76
|
+
if (msg.mentioned) {
|
|
77
|
+
headerFields.push("mentioned: true");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tag = sender.kind === "human" ? "human-message" : "agent-message";
|
|
81
|
+
const senderKindAttr = sender.kind === "human" ? "human" : "agent";
|
|
82
|
+
|
|
83
|
+
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
headerFields.join(" | "),
|
|
87
|
+
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
88
|
+
trimmed,
|
|
89
|
+
`</${tag}>`,
|
|
90
|
+
"",
|
|
91
|
+
hint,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
package/src/user-auth.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-identity credentials for the daemon control plane.
|
|
3
|
+
*
|
|
4
|
+
* Unlike agent credentials (one file per agent under
|
|
5
|
+
* `~/.botcord/credentials/*.json`), the user-auth record is singular —
|
|
6
|
+
* the daemon only logs in as *one* user at a time. Stored at
|
|
7
|
+
* `~/.botcord/daemon/user-auth.json` with `0600` permissions.
|
|
8
|
+
*
|
|
9
|
+
* See `docs/daemon-control-plane-plan.md` §6–§7.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
statSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
refreshDaemonToken,
|
|
24
|
+
type DaemonTokenResponse,
|
|
25
|
+
} from "@botcord/protocol-core";
|
|
26
|
+
import { DAEMON_DIR_PATH } from "./config.js";
|
|
27
|
+
import { log as daemonLog } from "./log.js";
|
|
28
|
+
|
|
29
|
+
export const USER_AUTH_PATH = path.join(DAEMON_DIR_PATH, "user-auth.json");
|
|
30
|
+
export const AUTH_EXPIRED_FLAG_PATH = path.join(DAEMON_DIR_PATH, "auth-expired.flag");
|
|
31
|
+
|
|
32
|
+
/** Persisted user-auth shape. Versioned so future fields can be added safely. */
|
|
33
|
+
export interface UserAuthRecord {
|
|
34
|
+
version: 1;
|
|
35
|
+
userId: string;
|
|
36
|
+
daemonInstanceId: string;
|
|
37
|
+
hubUrl: string;
|
|
38
|
+
accessToken: string;
|
|
39
|
+
refreshToken: string;
|
|
40
|
+
/** Absolute unix millis when the access token expires. */
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
/** ISO timestamp of initial token issuance — informational only. */
|
|
43
|
+
loggedInAt: string;
|
|
44
|
+
/** Optional human label (e.g. "MacBook Pro") set at login. */
|
|
45
|
+
label?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensureDaemonDir(): void {
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(DAEMON_DIR_PATH, { recursive: true, mode: 0o700 });
|
|
51
|
+
} catch {
|
|
52
|
+
// best-effort
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Refuse to load user-auth if the file is group/world readable. This is a
|
|
58
|
+
* hard stop — a leaked refresh token gives an attacker permanent control
|
|
59
|
+
* over the daemon. Returns `true` when permissions are acceptable; throws
|
|
60
|
+
* otherwise so callers surface a clear remediation hint.
|
|
61
|
+
*/
|
|
62
|
+
function assertSecurePermissions(file: string): void {
|
|
63
|
+
const st = statSync(file);
|
|
64
|
+
// mode is packaged as unix bits — mask off everything except owner to
|
|
65
|
+
// detect any group/world bits.
|
|
66
|
+
if ((st.mode & 0o077) !== 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`daemon user-auth file ${file} has insecure permissions (mode ${(st.mode & 0o777).toString(8)}); run \`chmod 600 ${file}\``,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read and return the on-disk user-auth record, or `null` if no login has
|
|
75
|
+
* happened yet. Throws on malformed content / insecure permissions.
|
|
76
|
+
*/
|
|
77
|
+
export function loadUserAuth(file: string = USER_AUTH_PATH): UserAuthRecord | null {
|
|
78
|
+
if (!existsSync(file)) return null;
|
|
79
|
+
assertSecurePermissions(file);
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`daemon user-auth file ${file} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!parsed || typeof parsed !== "object") {
|
|
89
|
+
throw new Error(`daemon user-auth file ${file} must be a JSON object`);
|
|
90
|
+
}
|
|
91
|
+
const obj = parsed as Record<string, unknown>;
|
|
92
|
+
const str = (k: string): string => {
|
|
93
|
+
const v = obj[k];
|
|
94
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
95
|
+
throw new Error(`daemon user-auth file ${file} missing "${k}"`);
|
|
96
|
+
}
|
|
97
|
+
return v;
|
|
98
|
+
};
|
|
99
|
+
const num = (k: string): number => {
|
|
100
|
+
const v = obj[k];
|
|
101
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
102
|
+
throw new Error(`daemon user-auth file ${file} missing numeric "${k}"`);
|
|
103
|
+
}
|
|
104
|
+
return v;
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
version: 1,
|
|
108
|
+
userId: str("userId"),
|
|
109
|
+
daemonInstanceId: str("daemonInstanceId"),
|
|
110
|
+
hubUrl: str("hubUrl"),
|
|
111
|
+
accessToken: str("accessToken"),
|
|
112
|
+
refreshToken: str("refreshToken"),
|
|
113
|
+
expiresAt: num("expiresAt"),
|
|
114
|
+
loggedInAt: typeof obj.loggedInAt === "string" ? obj.loggedInAt : new Date().toISOString(),
|
|
115
|
+
...(typeof obj.label === "string" ? { label: obj.label } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Atomically persist a user-auth record with mode 0600. */
|
|
120
|
+
export function saveUserAuth(
|
|
121
|
+
record: UserAuthRecord,
|
|
122
|
+
file: string = USER_AUTH_PATH,
|
|
123
|
+
): void {
|
|
124
|
+
ensureDaemonDir();
|
|
125
|
+
const tmp = file + ".tmp";
|
|
126
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
127
|
+
chmodSync(tmp, 0o600);
|
|
128
|
+
renameSync(tmp, file);
|
|
129
|
+
chmodSync(file, 0o600);
|
|
130
|
+
daemonLog.debug("user-auth saved", {
|
|
131
|
+
file,
|
|
132
|
+
userId: record.userId,
|
|
133
|
+
expiresAt: record.expiresAt,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Remove user-auth (e.g. after a hard revoke). Safe on missing file. */
|
|
138
|
+
export function clearUserAuth(file: string = USER_AUTH_PATH): void {
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(file);
|
|
141
|
+
daemonLog.info("user-auth cleared", { file });
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a {@link UserAuthRecord} from a freshly issued daemon token
|
|
149
|
+
* envelope. Shared helper so login and refresh stay in sync about what
|
|
150
|
+
* fields the on-disk shape carries.
|
|
151
|
+
*/
|
|
152
|
+
export function userAuthFromTokenResponse(
|
|
153
|
+
tok: DaemonTokenResponse,
|
|
154
|
+
opts?: { label?: string; loggedInAt?: string },
|
|
155
|
+
): UserAuthRecord {
|
|
156
|
+
return {
|
|
157
|
+
version: 1,
|
|
158
|
+
userId: tok.userId,
|
|
159
|
+
daemonInstanceId: tok.daemonInstanceId,
|
|
160
|
+
hubUrl: tok.hubUrl,
|
|
161
|
+
accessToken: tok.accessToken,
|
|
162
|
+
refreshToken: tok.refreshToken,
|
|
163
|
+
expiresAt: Date.now() + tok.expiresIn * 1000,
|
|
164
|
+
loggedInAt: opts?.loggedInAt ?? new Date().toISOString(),
|
|
165
|
+
...(opts?.label ? { label: opts.label } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Write the `auth-expired.flag` stamp. Used by the control channel when a refresh 401s. */
|
|
170
|
+
export function writeAuthExpiredFlag(file: string = AUTH_EXPIRED_FLAG_PATH): void {
|
|
171
|
+
ensureDaemonDir();
|
|
172
|
+
writeFileSync(file, JSON.stringify({ expiredAt: new Date().toISOString() }), {
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
});
|
|
175
|
+
daemonLog.warn("user-auth expired flag written", { file });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Remove the auth-expired flag (e.g. after a successful re-login). */
|
|
179
|
+
export function clearAuthExpiredFlag(file: string = AUTH_EXPIRED_FLAG_PATH): void {
|
|
180
|
+
try {
|
|
181
|
+
unlinkSync(file);
|
|
182
|
+
daemonLog.debug("user-auth expired flag cleared", { file });
|
|
183
|
+
} catch {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Returns true if the stored access token is within `windowMs` of expiry. */
|
|
189
|
+
export function isTokenNearExpiry(record: UserAuthRecord, windowMs = 60_000): boolean {
|
|
190
|
+
return record.expiresAt - Date.now() <= windowMs;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Stateful helper that owns the in-memory copy of user-auth and knows how
|
|
195
|
+
* to refresh it. Used by the control channel so reconnects always carry
|
|
196
|
+
* a fresh access token.
|
|
197
|
+
*/
|
|
198
|
+
export class UserAuthManager {
|
|
199
|
+
private record: UserAuthRecord | null;
|
|
200
|
+
private readonly file: string;
|
|
201
|
+
private refreshInflight: Promise<UserAuthRecord> | null = null;
|
|
202
|
+
|
|
203
|
+
constructor(opts: { record: UserAuthRecord | null; file?: string } = { record: null }) {
|
|
204
|
+
this.record = opts.record;
|
|
205
|
+
this.file = opts.file ?? USER_AUTH_PATH;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Load user-auth from disk; static convenience that wraps the ctor. */
|
|
209
|
+
static load(file: string = USER_AUTH_PATH): UserAuthManager {
|
|
210
|
+
return new UserAuthManager({ record: loadUserAuth(file), file });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The current (possibly stale) record, or `null` if not logged in. */
|
|
214
|
+
get current(): UserAuthRecord | null {
|
|
215
|
+
return this.record;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Return a valid access token, refreshing transparently if near expiry.
|
|
220
|
+
* Callers must treat the returned string as short-lived — re-invoke on
|
|
221
|
+
* 401 responses.
|
|
222
|
+
*/
|
|
223
|
+
async ensureAccessToken(): Promise<string> {
|
|
224
|
+
if (!this.record) {
|
|
225
|
+
throw new Error("daemon not logged in (no user-auth)");
|
|
226
|
+
}
|
|
227
|
+
if (!isTokenNearExpiry(this.record)) {
|
|
228
|
+
return this.record.accessToken;
|
|
229
|
+
}
|
|
230
|
+
const refreshed = await this.refresh();
|
|
231
|
+
return refreshed.accessToken;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Force-refresh the access token. Deduplicates concurrent callers so
|
|
236
|
+
* a single network request settles them all.
|
|
237
|
+
*/
|
|
238
|
+
async refresh(): Promise<UserAuthRecord> {
|
|
239
|
+
if (!this.record) {
|
|
240
|
+
throw new Error("daemon not logged in (no user-auth)");
|
|
241
|
+
}
|
|
242
|
+
if (this.refreshInflight) return this.refreshInflight;
|
|
243
|
+
const current = this.record;
|
|
244
|
+
daemonLog.info("user-auth refresh: start", {
|
|
245
|
+
userId: current.userId,
|
|
246
|
+
hubUrl: current.hubUrl,
|
|
247
|
+
expiresInMs: current.expiresAt - Date.now(),
|
|
248
|
+
});
|
|
249
|
+
this.refreshInflight = (async () => {
|
|
250
|
+
const tok = await refreshDaemonToken(current.hubUrl, current.refreshToken);
|
|
251
|
+
const next: UserAuthRecord = {
|
|
252
|
+
...current,
|
|
253
|
+
accessToken: tok.accessToken,
|
|
254
|
+
refreshToken: tok.refreshToken,
|
|
255
|
+
expiresAt: Date.now() + tok.expiresIn * 1000,
|
|
256
|
+
hubUrl: tok.hubUrl || current.hubUrl,
|
|
257
|
+
};
|
|
258
|
+
saveUserAuth(next, this.file);
|
|
259
|
+
this.record = next;
|
|
260
|
+
daemonLog.info("user-auth refresh: ok", {
|
|
261
|
+
userId: next.userId,
|
|
262
|
+
expiresAt: next.expiresAt,
|
|
263
|
+
});
|
|
264
|
+
return next;
|
|
265
|
+
})().catch((err) => {
|
|
266
|
+
daemonLog.warn("user-auth refresh: failed", {
|
|
267
|
+
userId: current.userId,
|
|
268
|
+
error: err instanceof Error ? err.message : String(err),
|
|
269
|
+
});
|
|
270
|
+
throw err;
|
|
271
|
+
}).finally(() => {
|
|
272
|
+
this.refreshInflight = null;
|
|
273
|
+
});
|
|
274
|
+
return this.refreshInflight;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Replace the record in memory and on disk (e.g. after device-code login). */
|
|
278
|
+
replace(record: UserAuthRecord): void {
|
|
279
|
+
saveUserAuth(record, this.file);
|
|
280
|
+
this.record = record;
|
|
281
|
+
clearAuthExpiredFlag();
|
|
282
|
+
daemonLog.info("user-auth replaced", {
|
|
283
|
+
userId: record.userId,
|
|
284
|
+
hubUrl: record.hubUrl,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Drop the record from memory + disk (e.g. after a hard revoke). */
|
|
289
|
+
clear(): void {
|
|
290
|
+
const prevUserId = this.record?.userId ?? null;
|
|
291
|
+
clearUserAuth(this.file);
|
|
292
|
+
this.record = null;
|
|
293
|
+
daemonLog.info("user-auth manager cleared", { userId: prevUserId });
|
|
294
|
+
}
|
|
295
|
+
}
|