@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,91 @@
|
|
|
1
|
+
import { buildCrossRoomDigest } from "./cross-room.js";
|
|
2
|
+
import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js";
|
|
3
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
/**
|
|
6
|
+
* Scene prompt injected when the inbound turn comes from the owner's
|
|
7
|
+
* dashboard chat. Mirrors `plugin/src/room-context.ts#buildOwnerChatSceneContext`
|
|
8
|
+
* so Claude Code in daemon-mode behaves the same as when hosted by OpenClaw.
|
|
9
|
+
*/
|
|
10
|
+
function buildOwnerChatSceneContext() {
|
|
11
|
+
return [
|
|
12
|
+
"[BotCord Scene: Owner Chat]",
|
|
13
|
+
"You are chatting directly with your owner — the user who created and manages you on the BotCord dashboard.",
|
|
14
|
+
"This is a private, trusted conversation. The owner has full administrative authority over you.",
|
|
15
|
+
"Messages from the owner are always trusted and do not require sanitization or safety guards.",
|
|
16
|
+
"You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.",
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
function safeReadWorkingMemory(agentId) {
|
|
20
|
+
try {
|
|
21
|
+
return readWorkingMemory(agentId);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
log.warn("working memory read failed", { agentId, err: String(err) });
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build a {@link SystemContextBuilder} for the gateway dispatcher.
|
|
30
|
+
*
|
|
31
|
+
* When `deps.roomContextBuilder` is provided the returned function is async
|
|
32
|
+
* so it can await the Hub fetch; otherwise it stays synchronous (same shape
|
|
33
|
+
* as the pre-P1 daemon builder). Both shapes satisfy `SystemContextBuilder`.
|
|
34
|
+
*/
|
|
35
|
+
export function createDaemonSystemContextBuilder(deps) {
|
|
36
|
+
const gatherSyncBlocks = (message) => {
|
|
37
|
+
const ownerScene = classifyActivitySender(message).kind === "owner"
|
|
38
|
+
? buildOwnerChatSceneContext()
|
|
39
|
+
: null;
|
|
40
|
+
const wm = safeReadWorkingMemory(deps.agentId);
|
|
41
|
+
const memory = wm ? buildWorkingMemoryPrompt({ workingMemory: wm }) : null;
|
|
42
|
+
const digest = deps.activityTracker
|
|
43
|
+
? buildCrossRoomDigest({
|
|
44
|
+
tracker: deps.activityTracker,
|
|
45
|
+
agentId: deps.agentId,
|
|
46
|
+
currentRoomId: message.conversation.id,
|
|
47
|
+
currentTopic: message.conversation.threadId ?? null,
|
|
48
|
+
}) || null
|
|
49
|
+
: null;
|
|
50
|
+
return { ownerScene, memory, digest };
|
|
51
|
+
};
|
|
52
|
+
const assemble = (parts) => {
|
|
53
|
+
const filtered = parts.filter((p) => typeof p === "string" && p.length > 0);
|
|
54
|
+
return filtered.length > 0 ? filtered.join("\n\n") : undefined;
|
|
55
|
+
};
|
|
56
|
+
if (!deps.roomContextBuilder) {
|
|
57
|
+
const syncBuilder = (message) => {
|
|
58
|
+
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
59
|
+
return assemble([ownerScene, memory, digest]);
|
|
60
|
+
};
|
|
61
|
+
// Compile-time witness that the narrower sync signature still satisfies
|
|
62
|
+
// `SystemContextBuilder` (which allows async). Prevents the two contracts
|
|
63
|
+
// from silently drifting.
|
|
64
|
+
const _typecheck = syncBuilder;
|
|
65
|
+
void _typecheck;
|
|
66
|
+
return syncBuilder;
|
|
67
|
+
}
|
|
68
|
+
const roomBuilder = deps.roomContextBuilder;
|
|
69
|
+
const asyncBuilder = async (message) => {
|
|
70
|
+
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
71
|
+
// Room context landing order: after owner-scene / memory, before digest —
|
|
72
|
+
// "what room am I in" belongs with the session's own identity, while the
|
|
73
|
+
// cross-room digest deliberately describes OTHER rooms and should stay
|
|
74
|
+
// last so it doesn't get confused with the current room.
|
|
75
|
+
let roomBlock = null;
|
|
76
|
+
try {
|
|
77
|
+
roomBlock = await roomBuilder(message);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
log.warn("system-context: roomContextBuilder threw — skipping room block", {
|
|
81
|
+
agentId: deps.agentId,
|
|
82
|
+
roomId: message.conversation.id,
|
|
83
|
+
err: err instanceof Error ? err.message : String(err),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return assemble([ownerScene, memory, roomBlock, digest]);
|
|
87
|
+
};
|
|
88
|
+
const _typecheck = asyncBuilder;
|
|
89
|
+
void _typecheck;
|
|
90
|
+
return asyncBuilder;
|
|
91
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/**
|
|
28
|
+
* Compose the user-turn text for a BotCord inbound message.
|
|
29
|
+
*
|
|
30
|
+
* Contract (from `UserTurnBuilder`):
|
|
31
|
+
* - Must be synchronous + cheap (turn critical path).
|
|
32
|
+
* - Caller guarantees `msg.text` is already trim-non-empty.
|
|
33
|
+
* - Never throws on expected inputs. If something unforeseen happens the
|
|
34
|
+
* dispatcher falls back to the raw trimmed text.
|
|
35
|
+
*/
|
|
36
|
+
export declare function composeBotCordUserTurn(msg: GatewayInboundMessage): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { sanitizeSenderName } from "./gateway/index.js";
|
|
2
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
3
|
+
const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
|
|
4
|
+
'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
5
|
+
const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
|
|
6
|
+
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
7
|
+
/**
|
|
8
|
+
* Compose the user-turn text for a BotCord inbound message.
|
|
9
|
+
*
|
|
10
|
+
* Contract (from `UserTurnBuilder`):
|
|
11
|
+
* - Must be synchronous + cheap (turn critical path).
|
|
12
|
+
* - Caller guarantees `msg.text` is already trim-non-empty.
|
|
13
|
+
* - Never throws on expected inputs. If something unforeseen happens the
|
|
14
|
+
* dispatcher falls back to the raw trimmed text.
|
|
15
|
+
*/
|
|
16
|
+
export function composeBotCordUserTurn(msg) {
|
|
17
|
+
const rawText = typeof msg.text === "string" ? msg.text : "";
|
|
18
|
+
const trimmed = rawText.trim();
|
|
19
|
+
if (!trimmed)
|
|
20
|
+
return trimmed;
|
|
21
|
+
const sender = classifyActivitySender(msg);
|
|
22
|
+
// Owner messages pass through verbatim. The scene prompt in
|
|
23
|
+
// system-context handles context; wrapping here would just add noise.
|
|
24
|
+
if (sender.kind === "owner")
|
|
25
|
+
return trimmed;
|
|
26
|
+
const conversation = msg.conversation;
|
|
27
|
+
const isGroup = conversation.kind === "group";
|
|
28
|
+
const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
|
|
29
|
+
// Sanitize every field that could carry prompt-injection markers. The
|
|
30
|
+
// text itself is already sanitized by the channel when
|
|
31
|
+
// sender.kind !== "owner"; re-sanitizing is a no-op but keeps the
|
|
32
|
+
// contract local (the composer does not trust its inputs).
|
|
33
|
+
const sanitizedSenderLabel = sanitizeSenderName(sender.label);
|
|
34
|
+
const headerFields = [
|
|
35
|
+
"[BotCord Message]",
|
|
36
|
+
`from: ${sanitizedSenderLabel}`,
|
|
37
|
+
`to: ${msg.accountId}`,
|
|
38
|
+
];
|
|
39
|
+
if (isGroup && roomTitle) {
|
|
40
|
+
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
41
|
+
headerFields.push(`room: ${safeRoom}`);
|
|
42
|
+
}
|
|
43
|
+
if (msg.mentioned) {
|
|
44
|
+
headerFields.push("mentioned: true");
|
|
45
|
+
}
|
|
46
|
+
const tag = sender.kind === "human" ? "human-message" : "agent-message";
|
|
47
|
+
const senderKindAttr = sender.kind === "human" ? "human" : "agent";
|
|
48
|
+
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
49
|
+
return [
|
|
50
|
+
headerFields.join(" | "),
|
|
51
|
+
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
52
|
+
trimmed,
|
|
53
|
+
`</${tag}>`,
|
|
54
|
+
"",
|
|
55
|
+
hint,
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type DaemonTokenResponse } from "@botcord/protocol-core";
|
|
2
|
+
export declare const USER_AUTH_PATH: string;
|
|
3
|
+
export declare const AUTH_EXPIRED_FLAG_PATH: string;
|
|
4
|
+
/** Persisted user-auth shape. Versioned so future fields can be added safely. */
|
|
5
|
+
export interface UserAuthRecord {
|
|
6
|
+
version: 1;
|
|
7
|
+
userId: string;
|
|
8
|
+
daemonInstanceId: string;
|
|
9
|
+
hubUrl: string;
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
/** Absolute unix millis when the access token expires. */
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
/** ISO timestamp of initial token issuance — informational only. */
|
|
15
|
+
loggedInAt: string;
|
|
16
|
+
/** Optional human label (e.g. "MacBook Pro") set at login. */
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Read and return the on-disk user-auth record, or `null` if no login has
|
|
21
|
+
* happened yet. Throws on malformed content / insecure permissions.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadUserAuth(file?: string): UserAuthRecord | null;
|
|
24
|
+
/** Atomically persist a user-auth record with mode 0600. */
|
|
25
|
+
export declare function saveUserAuth(record: UserAuthRecord, file?: string): void;
|
|
26
|
+
/** Remove user-auth (e.g. after a hard revoke). Safe on missing file. */
|
|
27
|
+
export declare function clearUserAuth(file?: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Build a {@link UserAuthRecord} from a freshly issued daemon token
|
|
30
|
+
* envelope. Shared helper so login and refresh stay in sync about what
|
|
31
|
+
* fields the on-disk shape carries.
|
|
32
|
+
*/
|
|
33
|
+
export declare function userAuthFromTokenResponse(tok: DaemonTokenResponse, opts?: {
|
|
34
|
+
label?: string;
|
|
35
|
+
loggedInAt?: string;
|
|
36
|
+
}): UserAuthRecord;
|
|
37
|
+
/** Write the `auth-expired.flag` stamp. Used by the control channel when a refresh 401s. */
|
|
38
|
+
export declare function writeAuthExpiredFlag(file?: string): void;
|
|
39
|
+
/** Remove the auth-expired flag (e.g. after a successful re-login). */
|
|
40
|
+
export declare function clearAuthExpiredFlag(file?: string): void;
|
|
41
|
+
/** Returns true if the stored access token is within `windowMs` of expiry. */
|
|
42
|
+
export declare function isTokenNearExpiry(record: UserAuthRecord, windowMs?: number): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Stateful helper that owns the in-memory copy of user-auth and knows how
|
|
45
|
+
* to refresh it. Used by the control channel so reconnects always carry
|
|
46
|
+
* a fresh access token.
|
|
47
|
+
*/
|
|
48
|
+
export declare class UserAuthManager {
|
|
49
|
+
private record;
|
|
50
|
+
private readonly file;
|
|
51
|
+
private refreshInflight;
|
|
52
|
+
constructor(opts?: {
|
|
53
|
+
record: UserAuthRecord | null;
|
|
54
|
+
file?: string;
|
|
55
|
+
});
|
|
56
|
+
/** Load user-auth from disk; static convenience that wraps the ctor. */
|
|
57
|
+
static load(file?: string): UserAuthManager;
|
|
58
|
+
/** The current (possibly stale) record, or `null` if not logged in. */
|
|
59
|
+
get current(): UserAuthRecord | null;
|
|
60
|
+
/**
|
|
61
|
+
* Return a valid access token, refreshing transparently if near expiry.
|
|
62
|
+
* Callers must treat the returned string as short-lived — re-invoke on
|
|
63
|
+
* 401 responses.
|
|
64
|
+
*/
|
|
65
|
+
ensureAccessToken(): Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Force-refresh the access token. Deduplicates concurrent callers so
|
|
68
|
+
* a single network request settles them all.
|
|
69
|
+
*/
|
|
70
|
+
refresh(): Promise<UserAuthRecord>;
|
|
71
|
+
/** Replace the record in memory and on disk (e.g. after device-code login). */
|
|
72
|
+
replace(record: UserAuthRecord): void;
|
|
73
|
+
/** Drop the record from memory + disk (e.g. after a hard revoke). */
|
|
74
|
+
clear(): void;
|
|
75
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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 { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { refreshDaemonToken, } from "@botcord/protocol-core";
|
|
14
|
+
import { DAEMON_DIR_PATH } from "./config.js";
|
|
15
|
+
import { log as daemonLog } from "./log.js";
|
|
16
|
+
export const USER_AUTH_PATH = path.join(DAEMON_DIR_PATH, "user-auth.json");
|
|
17
|
+
export const AUTH_EXPIRED_FLAG_PATH = path.join(DAEMON_DIR_PATH, "auth-expired.flag");
|
|
18
|
+
function ensureDaemonDir() {
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(DAEMON_DIR_PATH, { recursive: true, mode: 0o700 });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// best-effort
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Refuse to load user-auth if the file is group/world readable. This is a
|
|
28
|
+
* hard stop — a leaked refresh token gives an attacker permanent control
|
|
29
|
+
* over the daemon. Returns `true` when permissions are acceptable; throws
|
|
30
|
+
* otherwise so callers surface a clear remediation hint.
|
|
31
|
+
*/
|
|
32
|
+
function assertSecurePermissions(file) {
|
|
33
|
+
const st = statSync(file);
|
|
34
|
+
// mode is packaged as unix bits — mask off everything except owner to
|
|
35
|
+
// detect any group/world bits.
|
|
36
|
+
if ((st.mode & 0o077) !== 0) {
|
|
37
|
+
throw new Error(`daemon user-auth file ${file} has insecure permissions (mode ${(st.mode & 0o777).toString(8)}); run \`chmod 600 ${file}\``);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read and return the on-disk user-auth record, or `null` if no login has
|
|
42
|
+
* happened yet. Throws on malformed content / insecure permissions.
|
|
43
|
+
*/
|
|
44
|
+
export function loadUserAuth(file = USER_AUTH_PATH) {
|
|
45
|
+
if (!existsSync(file))
|
|
46
|
+
return null;
|
|
47
|
+
assertSecurePermissions(file);
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new Error(`daemon user-auth file ${file} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
+
}
|
|
55
|
+
if (!parsed || typeof parsed !== "object") {
|
|
56
|
+
throw new Error(`daemon user-auth file ${file} must be a JSON object`);
|
|
57
|
+
}
|
|
58
|
+
const obj = parsed;
|
|
59
|
+
const str = (k) => {
|
|
60
|
+
const v = obj[k];
|
|
61
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
62
|
+
throw new Error(`daemon user-auth file ${file} missing "${k}"`);
|
|
63
|
+
}
|
|
64
|
+
return v;
|
|
65
|
+
};
|
|
66
|
+
const num = (k) => {
|
|
67
|
+
const v = obj[k];
|
|
68
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
69
|
+
throw new Error(`daemon user-auth file ${file} missing numeric "${k}"`);
|
|
70
|
+
}
|
|
71
|
+
return v;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
version: 1,
|
|
75
|
+
userId: str("userId"),
|
|
76
|
+
daemonInstanceId: str("daemonInstanceId"),
|
|
77
|
+
hubUrl: str("hubUrl"),
|
|
78
|
+
accessToken: str("accessToken"),
|
|
79
|
+
refreshToken: str("refreshToken"),
|
|
80
|
+
expiresAt: num("expiresAt"),
|
|
81
|
+
loggedInAt: typeof obj.loggedInAt === "string" ? obj.loggedInAt : new Date().toISOString(),
|
|
82
|
+
...(typeof obj.label === "string" ? { label: obj.label } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Atomically persist a user-auth record with mode 0600. */
|
|
86
|
+
export function saveUserAuth(record, file = USER_AUTH_PATH) {
|
|
87
|
+
ensureDaemonDir();
|
|
88
|
+
const tmp = file + ".tmp";
|
|
89
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
90
|
+
chmodSync(tmp, 0o600);
|
|
91
|
+
renameSync(tmp, file);
|
|
92
|
+
chmodSync(file, 0o600);
|
|
93
|
+
daemonLog.debug("user-auth saved", {
|
|
94
|
+
file,
|
|
95
|
+
userId: record.userId,
|
|
96
|
+
expiresAt: record.expiresAt,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Remove user-auth (e.g. after a hard revoke). Safe on missing file. */
|
|
100
|
+
export function clearUserAuth(file = USER_AUTH_PATH) {
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(file);
|
|
103
|
+
daemonLog.info("user-auth cleared", { file });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build a {@link UserAuthRecord} from a freshly issued daemon token
|
|
111
|
+
* envelope. Shared helper so login and refresh stay in sync about what
|
|
112
|
+
* fields the on-disk shape carries.
|
|
113
|
+
*/
|
|
114
|
+
export function userAuthFromTokenResponse(tok, opts) {
|
|
115
|
+
return {
|
|
116
|
+
version: 1,
|
|
117
|
+
userId: tok.userId,
|
|
118
|
+
daemonInstanceId: tok.daemonInstanceId,
|
|
119
|
+
hubUrl: tok.hubUrl,
|
|
120
|
+
accessToken: tok.accessToken,
|
|
121
|
+
refreshToken: tok.refreshToken,
|
|
122
|
+
expiresAt: Date.now() + tok.expiresIn * 1000,
|
|
123
|
+
loggedInAt: opts?.loggedInAt ?? new Date().toISOString(),
|
|
124
|
+
...(opts?.label ? { label: opts.label } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Write the `auth-expired.flag` stamp. Used by the control channel when a refresh 401s. */
|
|
128
|
+
export function writeAuthExpiredFlag(file = AUTH_EXPIRED_FLAG_PATH) {
|
|
129
|
+
ensureDaemonDir();
|
|
130
|
+
writeFileSync(file, JSON.stringify({ expiredAt: new Date().toISOString() }), {
|
|
131
|
+
mode: 0o600,
|
|
132
|
+
});
|
|
133
|
+
daemonLog.warn("user-auth expired flag written", { file });
|
|
134
|
+
}
|
|
135
|
+
/** Remove the auth-expired flag (e.g. after a successful re-login). */
|
|
136
|
+
export function clearAuthExpiredFlag(file = AUTH_EXPIRED_FLAG_PATH) {
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(file);
|
|
139
|
+
daemonLog.debug("user-auth expired flag cleared", { file });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// ignore
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Returns true if the stored access token is within `windowMs` of expiry. */
|
|
146
|
+
export function isTokenNearExpiry(record, windowMs = 60_000) {
|
|
147
|
+
return record.expiresAt - Date.now() <= windowMs;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Stateful helper that owns the in-memory copy of user-auth and knows how
|
|
151
|
+
* to refresh it. Used by the control channel so reconnects always carry
|
|
152
|
+
* a fresh access token.
|
|
153
|
+
*/
|
|
154
|
+
export class UserAuthManager {
|
|
155
|
+
record;
|
|
156
|
+
file;
|
|
157
|
+
refreshInflight = null;
|
|
158
|
+
constructor(opts = { record: null }) {
|
|
159
|
+
this.record = opts.record;
|
|
160
|
+
this.file = opts.file ?? USER_AUTH_PATH;
|
|
161
|
+
}
|
|
162
|
+
/** Load user-auth from disk; static convenience that wraps the ctor. */
|
|
163
|
+
static load(file = USER_AUTH_PATH) {
|
|
164
|
+
return new UserAuthManager({ record: loadUserAuth(file), file });
|
|
165
|
+
}
|
|
166
|
+
/** The current (possibly stale) record, or `null` if not logged in. */
|
|
167
|
+
get current() {
|
|
168
|
+
return this.record;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Return a valid access token, refreshing transparently if near expiry.
|
|
172
|
+
* Callers must treat the returned string as short-lived — re-invoke on
|
|
173
|
+
* 401 responses.
|
|
174
|
+
*/
|
|
175
|
+
async ensureAccessToken() {
|
|
176
|
+
if (!this.record) {
|
|
177
|
+
throw new Error("daemon not logged in (no user-auth)");
|
|
178
|
+
}
|
|
179
|
+
if (!isTokenNearExpiry(this.record)) {
|
|
180
|
+
return this.record.accessToken;
|
|
181
|
+
}
|
|
182
|
+
const refreshed = await this.refresh();
|
|
183
|
+
return refreshed.accessToken;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Force-refresh the access token. Deduplicates concurrent callers so
|
|
187
|
+
* a single network request settles them all.
|
|
188
|
+
*/
|
|
189
|
+
async refresh() {
|
|
190
|
+
if (!this.record) {
|
|
191
|
+
throw new Error("daemon not logged in (no user-auth)");
|
|
192
|
+
}
|
|
193
|
+
if (this.refreshInflight)
|
|
194
|
+
return this.refreshInflight;
|
|
195
|
+
const current = this.record;
|
|
196
|
+
daemonLog.info("user-auth refresh: start", {
|
|
197
|
+
userId: current.userId,
|
|
198
|
+
hubUrl: current.hubUrl,
|
|
199
|
+
expiresInMs: current.expiresAt - Date.now(),
|
|
200
|
+
});
|
|
201
|
+
this.refreshInflight = (async () => {
|
|
202
|
+
const tok = await refreshDaemonToken(current.hubUrl, current.refreshToken);
|
|
203
|
+
const next = {
|
|
204
|
+
...current,
|
|
205
|
+
accessToken: tok.accessToken,
|
|
206
|
+
refreshToken: tok.refreshToken,
|
|
207
|
+
expiresAt: Date.now() + tok.expiresIn * 1000,
|
|
208
|
+
hubUrl: tok.hubUrl || current.hubUrl,
|
|
209
|
+
};
|
|
210
|
+
saveUserAuth(next, this.file);
|
|
211
|
+
this.record = next;
|
|
212
|
+
daemonLog.info("user-auth refresh: ok", {
|
|
213
|
+
userId: next.userId,
|
|
214
|
+
expiresAt: next.expiresAt,
|
|
215
|
+
});
|
|
216
|
+
return next;
|
|
217
|
+
})().catch((err) => {
|
|
218
|
+
daemonLog.warn("user-auth refresh: failed", {
|
|
219
|
+
userId: current.userId,
|
|
220
|
+
error: err instanceof Error ? err.message : String(err),
|
|
221
|
+
});
|
|
222
|
+
throw err;
|
|
223
|
+
}).finally(() => {
|
|
224
|
+
this.refreshInflight = null;
|
|
225
|
+
});
|
|
226
|
+
return this.refreshInflight;
|
|
227
|
+
}
|
|
228
|
+
/** Replace the record in memory and on disk (e.g. after device-code login). */
|
|
229
|
+
replace(record) {
|
|
230
|
+
saveUserAuth(record, this.file);
|
|
231
|
+
this.record = record;
|
|
232
|
+
clearAuthExpiredFlag();
|
|
233
|
+
daemonLog.info("user-auth replaced", {
|
|
234
|
+
userId: record.userId,
|
|
235
|
+
hubUrl: record.hubUrl,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** Drop the record from memory + disk (e.g. after a hard revoke). */
|
|
239
|
+
clear() {
|
|
240
|
+
const prevUserId = this.record?.userId ?? null;
|
|
241
|
+
clearUserAuth(this.file);
|
|
242
|
+
this.record = null;
|
|
243
|
+
daemonLog.info("user-auth manager cleared", { userId: prevUserId });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface WorkingMemory {
|
|
2
|
+
version: 2;
|
|
3
|
+
goal?: string;
|
|
4
|
+
sections: Record<string, string>;
|
|
5
|
+
updatedAt: string;
|
|
6
|
+
}
|
|
7
|
+
/** Characters per section; matches the plugin-side limit. */
|
|
8
|
+
export declare const MAX_SECTION_CHARS = 10000;
|
|
9
|
+
export declare const MAX_GOAL_CHARS = 500;
|
|
10
|
+
export declare const MAX_TOTAL_CHARS = 20000;
|
|
11
|
+
export declare const DEFAULT_SECTION = "notes";
|
|
12
|
+
/**
|
|
13
|
+
* Canonical per-agent state directory. Returns the new location
|
|
14
|
+
* (`~/.botcord/agents/{agentId}/state`). The legacy location under
|
|
15
|
+
* `~/.botcord/daemon/memory/{agentId}` is migrated lazily on first read —
|
|
16
|
+
* see §8 of the daemon-agent-workspace plan.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveMemoryDir(agentId: string): string;
|
|
19
|
+
export declare function readWorkingMemory(agentId: string): WorkingMemory | null;
|
|
20
|
+
export declare function writeWorkingMemory(agentId: string, data: WorkingMemory): void;
|
|
21
|
+
export interface SetSectionResult {
|
|
22
|
+
memory: WorkingMemory;
|
|
23
|
+
totalChars: number;
|
|
24
|
+
/** Whether the targeted section ended up present after the write. */
|
|
25
|
+
sectionPresent: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Upsert a section (or goal). Passing empty `content` with a `section`
|
|
29
|
+
* deletes that section. `goal === ""` clears the goal.
|
|
30
|
+
*/
|
|
31
|
+
export declare function updateWorkingMemory(agentId: string, update: {
|
|
32
|
+
goal?: string;
|
|
33
|
+
section?: string;
|
|
34
|
+
content?: string;
|
|
35
|
+
}): SetSectionResult;
|
|
36
|
+
/** Wipe the agent's working memory entirely (goal + all sections). */
|
|
37
|
+
export declare function clearWorkingMemory(agentId: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Render a system-prompt block describing the agent's working memory. The
|
|
40
|
+
* format intentionally mirrors the plugin's so a CLI-side agent sees the
|
|
41
|
+
* same shape as an OpenClaw-hosted one.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildWorkingMemoryPrompt(opts: {
|
|
44
|
+
workingMemory: WorkingMemory | null;
|
|
45
|
+
warnLarge?: boolean;
|
|
46
|
+
}): string;
|