@controlflow-ai/daemon 0.1.1 → 0.1.3
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/README.md +66 -24
- package/package.json +16 -3
- package/src/agent-avatar.ts +30 -0
- package/src/agent-key.ts +28 -0
- package/src/agent-permissions.ts +359 -0
- package/src/agent-runtime.ts +810 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +2183 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +482 -12
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +460 -26
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +958 -101
- package/src/db.ts +3216 -113
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +7 -137
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +61 -5
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/setup.ts +74 -5
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +611 -14
- package/src/local-auth.ts +36 -3
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations/024_agents_model.ts +10 -0
- package/src/migrations/025_room_archive.ts +44 -0
- package/src/migrations/026_project_archive.ts +44 -0
- package/src/migrations/027_agent_permission_profiles.ts +16 -0
- package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
- package/src/migrations/029_held_message_drafts.ts +32 -0
- package/src/migrations/030_agent_room_read_state.ts +25 -0
- package/src/migrations/031_room_tasks.ts +29 -0
- package/src/migrations/032_room_reminders.ts +29 -0
- package/src/migrations/033_room_saved_messages.ts +25 -0
- package/src/migrations/034_agent_activity_events.ts +27 -0
- package/src/migrations/035_agent_avatars.ts +17 -0
- package/src/migrations/036_project_agent_defaults.ts +21 -0
- package/src/migrations/037_message_attachments.ts +36 -0
- package/src/migrations/038_agent_activity_room_scope.ts +64 -0
- package/src/migrations/039_message_attachments_path.ts +34 -0
- package/src/migrations/040_message_attachments_file_schema.ts +80 -0
- package/src/migrations/041_room_system_events.ts +30 -0
- package/src/migrations/042_message_attachment_file_kind.ts +52 -0
- package/src/migrations/043_room_mode_skill_registry.ts +92 -0
- package/src/migrations/044_workflow_runtime.ts +69 -0
- package/src/migrations/045_skill_repository_ownership.ts +64 -0
- package/src/migrations.ts +70 -1
- package/src/neeko.ts +40 -4
- package/src/runtime-env.ts +179 -0
- package/src/runtime-registry.ts +83 -13
- package/src/server.ts +244 -4
- package/src/token-file.ts +13 -6
- package/src/types.ts +394 -0
- package/src/workflow-runtime.ts +275 -0
- package/src/web.ts +0 -904
package/src/runtime-registry.ts
CHANGED
|
@@ -3,23 +3,19 @@ import type { AgentRuntime, AgentRuntimeProtocol } from './agent-runtime.js';
|
|
|
3
3
|
const supportedProtocols = new Set<AgentRuntimeProtocol>(['json-stream', 'acp']);
|
|
4
4
|
|
|
5
5
|
const runtimeFactories = {
|
|
6
|
-
codex: async (agentUuid: string): Promise<AgentRuntime> => {
|
|
6
|
+
codex: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
|
|
7
7
|
const { makeCodexRuntime } = await import('./codex.js');
|
|
8
|
-
return makeCodexRuntime(agentUuid);
|
|
8
|
+
return makeCodexRuntime(agentUuid, model);
|
|
9
9
|
},
|
|
10
|
-
coco: async (agentUuid: string): Promise<AgentRuntime> => {
|
|
10
|
+
coco: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
|
|
11
11
|
const { makeCocoRuntime } = await import('./coco.js');
|
|
12
|
-
return makeCocoRuntime(agentUuid);
|
|
12
|
+
return makeCocoRuntime(agentUuid, model);
|
|
13
13
|
},
|
|
14
|
-
|
|
15
|
-
const { makeCocoStreamJsonRuntime } = await import('./coco.js');
|
|
16
|
-
return makeCocoStreamJsonRuntime(agentUuid);
|
|
17
|
-
},
|
|
18
|
-
neeko: async (agentUuid: string): Promise<AgentRuntime> => {
|
|
14
|
+
neeko: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
|
|
19
15
|
const { makeNeekoRuntime } = await import('./neeko.js');
|
|
20
|
-
return makeNeekoRuntime(agentUuid);
|
|
16
|
+
return makeNeekoRuntime(agentUuid, model);
|
|
21
17
|
},
|
|
22
|
-
} satisfies Record<string, (agentUuid: string) => Promise<AgentRuntime>>;
|
|
18
|
+
} satisfies Record<string, (agentUuid: string, model: string | null) => Promise<AgentRuntime>>;
|
|
23
19
|
|
|
24
20
|
export type RuntimeName = keyof typeof runtimeFactories;
|
|
25
21
|
|
|
@@ -27,13 +23,87 @@ export function knownRuntimeNames(): RuntimeName[] {
|
|
|
27
23
|
return Object.keys(runtimeFactories) as RuntimeName[];
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
export
|
|
26
|
+
export interface RuntimeModelLookup {
|
|
27
|
+
models: string[];
|
|
28
|
+
error: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function modelCommand(runtimeName: string): { command: string; args: string[] } {
|
|
32
|
+
if (runtimeName === 'codex') return { command: 'codex', args: ['debug', 'models'] };
|
|
33
|
+
if (runtimeName === 'coco') return { command: 'coco', args: ['models'] };
|
|
34
|
+
if (runtimeName === 'neeko') return { command: 'neeko', args: ['models'] };
|
|
35
|
+
throw new Error(`Unsupported runtime '${runtimeName}'. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stripAnsi(value: string): string {
|
|
39
|
+
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function uniqueNonEmpty(values: string[]): string[] {
|
|
43
|
+
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseModelOutput(runtimeName: string, stdout: string): string[] {
|
|
47
|
+
const clean = stripAnsi(stdout).trim();
|
|
48
|
+
if (!clean) return [];
|
|
49
|
+
if (runtimeName === 'codex') {
|
|
50
|
+
const parsed = JSON.parse(clean) as { models?: Array<{ slug?: unknown; id?: unknown; name?: unknown }> };
|
|
51
|
+
return uniqueNonEmpty((parsed.models ?? []).map((model) => String(model.slug ?? model.id ?? model.name ?? '')));
|
|
52
|
+
}
|
|
53
|
+
return uniqueNonEmpty(clean.split(/\r?\n/).filter((line) => !/^\s*(available\s+)?models\s*:?\s*$/i.test(line)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runtimeModelOptions(runtimeName: string): Promise<string[]> {
|
|
57
|
+
const runtime = runtimeName.trim();
|
|
58
|
+
modelCommand(runtime);
|
|
59
|
+
const { command, args } = modelCommand(runtime);
|
|
60
|
+
const proc = Bun.spawn([command, ...args], {
|
|
61
|
+
stdout: 'pipe',
|
|
62
|
+
stderr: 'pipe',
|
|
63
|
+
env: process.env,
|
|
64
|
+
});
|
|
65
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
66
|
+
new Response(proc.stdout).text(),
|
|
67
|
+
new Response(proc.stderr).text(),
|
|
68
|
+
proc.exited,
|
|
69
|
+
]);
|
|
70
|
+
if (exitCode !== 0) {
|
|
71
|
+
const detail = stderr.trim() || stdout.trim() || `exit ${exitCode}`;
|
|
72
|
+
throw new Error(`Could not load models for runtime '${runtime}': ${detail}`);
|
|
73
|
+
}
|
|
74
|
+
return parseModelOutput(runtime, stdout);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function lookupRuntimeModels(runtimeName: string): Promise<RuntimeModelLookup> {
|
|
78
|
+
try {
|
|
79
|
+
return { models: await runtimeModelOptions(runtimeName), error: null };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { models: [], error: error instanceof Error ? error.message : String(error) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function allRuntimeModelOptions(): Promise<Record<RuntimeName, RuntimeModelLookup>> {
|
|
86
|
+
const entries = await Promise.all(knownRuntimeNames().map(async (name) => [name, await lookupRuntimeModels(name)] as const));
|
|
87
|
+
return Object.fromEntries(entries) as Record<RuntimeName, RuntimeModelLookup>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function validateRuntimeModel(runtimeName: string, model: string | null | undefined): Promise<string | null> {
|
|
91
|
+
const trimmed = model?.trim() || null;
|
|
92
|
+
if (!trimmed) return null;
|
|
93
|
+
const options = await runtimeModelOptions(runtimeName);
|
|
94
|
+
if (!options.includes(trimmed)) {
|
|
95
|
+
throw new Error(`Unsupported model '${trimmed}' for runtime '${runtimeName}'. Supported models: ${options.join(', ')}.`);
|
|
96
|
+
}
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function resolveRuntimeDriver(runtimeName: string, agentUuid: string, model?: string | null): Promise<AgentRuntime> {
|
|
31
101
|
const factory = runtimeFactories[runtimeName as RuntimeName];
|
|
32
102
|
if (!factory) {
|
|
33
103
|
throw new Error(`Unsupported runtime '${runtimeName}'. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
|
|
34
104
|
}
|
|
35
105
|
|
|
36
|
-
const driver = await factory(agentUuid);
|
|
106
|
+
const driver = await factory(agentUuid, model?.trim() || null);
|
|
37
107
|
if (!supportedProtocols.has(driver.capabilities.protocol)) {
|
|
38
108
|
throw new Error(`Runtime '${runtimeName}' uses unsupported protocol '${driver.capabilities.protocol}'. Supported protocols: ${Array.from(supportedProtocols).join(', ')}.`);
|
|
39
109
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,34 +1,101 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { DEFAULT_HOST, DEFAULT_PORT, defaultDbPath } from './config.js';
|
|
3
4
|
import { MessageStore } from './db.js';
|
|
4
|
-
import { handleRequest } from './app.js';
|
|
5
|
+
import { fireDueRoomRemindersForDelivery, handleRequest } from './app.js';
|
|
6
|
+
import { DeliveryWebSocketHub, type DeliveryWebSocketData } from './delivery-ws.js';
|
|
5
7
|
import { startLarkOnServer } from './lark/server-integration.js';
|
|
8
|
+
import { buildMessagingHealthLogLines, buildMessagingStatus } from './messaging-status.js';
|
|
6
9
|
import { isLoopbackHost, tailscaleAddress } from './network.js';
|
|
10
|
+
import { assertServerAuth } from './server-auth.js';
|
|
7
11
|
|
|
8
12
|
const dbPath = defaultDbPath();
|
|
9
13
|
const store = new MessageStore(dbPath);
|
|
14
|
+
const bootId = randomUUID();
|
|
15
|
+
const bootedAt = new Date();
|
|
10
16
|
const port = Number(process.env.PAL_PORT ?? DEFAULT_PORT);
|
|
11
17
|
const host = process.env.PAL_HOST ?? DEFAULT_HOST;
|
|
12
18
|
const tailscaleHost = process.env.PAL_TAILSCALE_HOST ?? tailscaleAddress();
|
|
19
|
+
const larkAutoRestartStaleAfterMs = positiveEnvMs('PAL_LARK_AUTO_RESTART_STALE_AFTER_MS');
|
|
20
|
+
const larkAutoRestartIntervalMs = positiveEnvMs('PAL_LARK_AUTO_RESTART_INTERVAL_MS') ?? Math.min(larkAutoRestartStaleAfterMs ?? 60_000, 60_000);
|
|
21
|
+
const larkAutoRestartMinIntervalMs = positiveEnvMs('PAL_LARK_AUTO_RESTART_MIN_INTERVAL_MS') ?? Math.max(larkAutoRestartStaleAfterMs ?? 0, 5 * 60 * 1000);
|
|
22
|
+
const messagingHealthLogIntervalMs = nonNegativeEnvMs('PAL_MESSAGING_HEALTH_LOG_INTERVAL_MS') ?? 60_000;
|
|
23
|
+
const computerConnectionTimeoutMs = nonNegativeEnvMs('PAL_COMPUTER_CONNECTION_TIMEOUT_MS') ?? 30_000;
|
|
24
|
+
const computerConnectionCleanupIntervalMs = positiveEnvMs('PAL_COMPUTER_CONNECTION_CLEANUP_INTERVAL_MS') ?? Math.min(Math.max(computerConnectionTimeoutMs, 5_000), 60_000);
|
|
25
|
+
const reminderFireIntervalMs = nonNegativeEnvMs('PAL_REMINDER_FIRE_INTERVAL_MS') ?? 1_000;
|
|
26
|
+
const reminderFireLimit = positiveEnvMs('PAL_REMINDER_FIRE_LIMIT') ?? 25;
|
|
27
|
+
const deliveryWs = new DeliveryWebSocketHub(store, { staleConnectionTimeoutMs: computerConnectionTimeoutMs });
|
|
28
|
+
const lastMessagingHealthLogAt = new Map<string, number>();
|
|
13
29
|
|
|
14
|
-
function
|
|
30
|
+
function positiveEnvMs(name: string): number | null {
|
|
31
|
+
const raw = process.env[name];
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
const parsed = Number(raw);
|
|
34
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nonNegativeEnvMs(name: string): number | null {
|
|
38
|
+
const raw = process.env[name];
|
|
39
|
+
if (raw === undefined || raw === '') return null;
|
|
40
|
+
const parsed = Number(raw);
|
|
41
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fetch(request: Request, server: Bun.Server<DeliveryWebSocketData>): Promise<Response> | Response {
|
|
45
|
+
const upgraded = deliveryWs.handleUpgrade(request, server);
|
|
46
|
+
if (upgraded) return upgraded;
|
|
15
47
|
const url = new URL(request.url);
|
|
48
|
+
if (request.method === 'GET' && url.pathname === '/api/server/runtime') {
|
|
49
|
+
try {
|
|
50
|
+
assertServerAuth(request);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : 'unauthorized';
|
|
53
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
54
|
+
status: 401,
|
|
55
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return new Response(JSON.stringify({
|
|
59
|
+
ok: true,
|
|
60
|
+
data: {
|
|
61
|
+
boot_id: bootId,
|
|
62
|
+
booted_at: bootedAt.toISOString(),
|
|
63
|
+
uptime_ms: Date.now() - bootedAt.getTime(),
|
|
64
|
+
},
|
|
65
|
+
}), {
|
|
66
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (request.method === 'GET' && url.pathname === '/api/lark/status') {
|
|
70
|
+
return larkStatus();
|
|
71
|
+
}
|
|
16
72
|
if (request.method === 'POST' && url.pathname === '/api/lark/reload') {
|
|
17
73
|
return reloadLarkIntegration();
|
|
18
74
|
}
|
|
19
|
-
|
|
75
|
+
if (request.method === 'POST' && url.pathname === '/api/lark/restart') {
|
|
76
|
+
return restartLarkIntegration(request);
|
|
77
|
+
}
|
|
78
|
+
if (request.method === 'POST' && url.pathname === '/api/lark/probe-event') {
|
|
79
|
+
return probeLarkEvent(request, url);
|
|
80
|
+
}
|
|
81
|
+
return handleRequest(store, request, {
|
|
82
|
+
deliveryNotifier: deliveryWs,
|
|
83
|
+
larkStatusProvider: () => larkIntegration.status(),
|
|
84
|
+
});
|
|
20
85
|
}
|
|
21
86
|
|
|
22
87
|
const server = Bun.serve({
|
|
23
88
|
hostname: host,
|
|
24
89
|
port,
|
|
25
90
|
fetch,
|
|
91
|
+
websocket: deliveryWs.websocket,
|
|
26
92
|
});
|
|
27
93
|
const tailscaleServer = tailscaleHost && isLoopbackHost(host)
|
|
28
|
-
? Bun.serve({ hostname: tailscaleHost, port, fetch })
|
|
94
|
+
? Bun.serve({ hostname: tailscaleHost, port, fetch, websocket: deliveryWs.websocket })
|
|
29
95
|
: null;
|
|
30
96
|
|
|
31
97
|
console.log(`pal server listening on http://${server.hostname}:${server.port}`);
|
|
98
|
+
console.log(`[server] boot id=${bootId} started_at=${bootedAt.toISOString()}`);
|
|
32
99
|
if (tailscaleServer) {
|
|
33
100
|
console.log(`pal server also listening on Tailscale http://${tailscaleServer.hostname}:${tailscaleServer.port}`);
|
|
34
101
|
} else if (tailscaleHost) {
|
|
@@ -36,15 +103,98 @@ if (tailscaleServer) {
|
|
|
36
103
|
}
|
|
37
104
|
console.log(`database: ${dbPath}`);
|
|
38
105
|
|
|
106
|
+
const computerConnectionCleanup = setInterval(() => {
|
|
107
|
+
const closed = deliveryWs.pruneStaleConnections();
|
|
108
|
+
if (closed > 0) {
|
|
109
|
+
console.log(`[server] closed stale computer connection(s): ${closed}`);
|
|
110
|
+
}
|
|
111
|
+
}, computerConnectionCleanupIntervalMs);
|
|
112
|
+
console.log(`[server] computer connection cleanup enabled timeout_ms=${computerConnectionTimeoutMs} interval_ms=${computerConnectionCleanupIntervalMs}`);
|
|
113
|
+
|
|
114
|
+
function fireDueReminderWakeups(): void {
|
|
115
|
+
try {
|
|
116
|
+
const result = fireDueRoomRemindersForDelivery(store, { deliveryNotifier: deliveryWs }, { limit: reminderFireLimit });
|
|
117
|
+
if (result.reminders.length > 0) {
|
|
118
|
+
console.log(`[server] fired room reminders reminders=${result.reminders.length} messages=${result.messages.length} deliveries=${result.deliveries.length}`);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
console.warn(`[server] room reminder firing failed: ${message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const roomReminderFireLoop = reminderFireIntervalMs > 0
|
|
127
|
+
? setInterval(fireDueReminderWakeups, reminderFireIntervalMs)
|
|
128
|
+
: null;
|
|
129
|
+
if (roomReminderFireLoop) {
|
|
130
|
+
console.log(`[server] room reminder firing enabled interval_ms=${reminderFireIntervalMs} limit=${reminderFireLimit}`);
|
|
131
|
+
fireDueReminderWakeups();
|
|
132
|
+
} else {
|
|
133
|
+
console.log('[server] room reminder firing disabled');
|
|
134
|
+
}
|
|
135
|
+
|
|
39
136
|
function startLarkIntegration() {
|
|
40
137
|
return startLarkOnServer({
|
|
41
138
|
store,
|
|
139
|
+
dbPath,
|
|
140
|
+
deliveryNotifier: deliveryWs,
|
|
42
141
|
logger: { log: console.log, warn: console.warn, error: console.error },
|
|
43
142
|
});
|
|
44
143
|
}
|
|
45
144
|
|
|
46
145
|
// Start Lark bot integration on the server
|
|
47
146
|
let larkIntegration = startLarkIntegration();
|
|
147
|
+
const larkAutoRestart = larkAutoRestartStaleAfterMs
|
|
148
|
+
? setInterval(() => {
|
|
149
|
+
const result = larkIntegration.restartStale({
|
|
150
|
+
staleAfterMs: larkAutoRestartStaleAfterMs,
|
|
151
|
+
minRestartIntervalMs: larkAutoRestartMinIntervalMs,
|
|
152
|
+
});
|
|
153
|
+
if (result.restarted.length > 0) {
|
|
154
|
+
console.log(`[server] lark auto-restarted stale websocket(s): ${result.restarted.join(',')}`);
|
|
155
|
+
}
|
|
156
|
+
if (result.skipped_recent?.length) {
|
|
157
|
+
console.log(`[server] lark auto-restart skipped recent websocket(s): ${result.skipped_recent.join(',')}`);
|
|
158
|
+
}
|
|
159
|
+
if (result.skipped_ineffective?.length) {
|
|
160
|
+
console.warn(`[server] lark auto-restart skipped ineffective websocket repair(s): ${result.skipped_ineffective.join(',')}`);
|
|
161
|
+
}
|
|
162
|
+
if (!result.ok) {
|
|
163
|
+
console.warn(`[server] lark auto-restart failed: ${result.error}`);
|
|
164
|
+
}
|
|
165
|
+
logMessagingHealthDiagnostics();
|
|
166
|
+
}, larkAutoRestartIntervalMs)
|
|
167
|
+
: null;
|
|
168
|
+
if (larkAutoRestart) {
|
|
169
|
+
console.log(`[server] lark stale auto-restart enabled stale_after_ms=${larkAutoRestartStaleAfterMs} interval_ms=${larkAutoRestartIntervalMs} min_restart_interval_ms=${larkAutoRestartMinIntervalMs}`);
|
|
170
|
+
}
|
|
171
|
+
const messagingHealthLog = messagingHealthLogIntervalMs > 0
|
|
172
|
+
? setInterval(logMessagingHealthDiagnostics, messagingHealthLogIntervalMs)
|
|
173
|
+
: null;
|
|
174
|
+
if (messagingHealthLog) {
|
|
175
|
+
console.log(`[server] messaging health logging enabled interval_ms=${messagingHealthLogIntervalMs}`);
|
|
176
|
+
} else {
|
|
177
|
+
console.log('[server] messaging health logging disabled');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function logMessagingHealthDiagnostics(): void {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const larkBots = larkIntegration.status();
|
|
183
|
+
const deliveryWebSocket = deliveryWs.statsAllConnections();
|
|
184
|
+
const lines = buildMessagingHealthLogLines(buildMessagingStatus({ larkBots, deliveryWebSocket }));
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
const previous = lastMessagingHealthLogAt.get(line.throttle_key) ?? 0;
|
|
187
|
+
if (now - previous < 5 * 60 * 1000) continue;
|
|
188
|
+
lastMessagingHealthLogAt.set(line.throttle_key, now);
|
|
189
|
+
console.warn(`[server] ${line.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function larkStatus(): Response {
|
|
194
|
+
return new Response(JSON.stringify({ ok: true, data: { bots: larkIntegration.status() } }), {
|
|
195
|
+
headers: { 'content-type': 'application/json' },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
48
198
|
|
|
49
199
|
async function reloadLarkIntegration(): Promise<Response> {
|
|
50
200
|
const result = larkIntegration.reload();
|
|
@@ -56,17 +206,107 @@ async function reloadLarkIntegration(): Promise<Response> {
|
|
|
56
206
|
headers: { 'content-type': 'application/json' },
|
|
57
207
|
});
|
|
58
208
|
}
|
|
209
|
+
|
|
210
|
+
async function restartLarkIntegration(request: Request): Promise<Response> {
|
|
211
|
+
let appIds: string[] | undefined;
|
|
212
|
+
try {
|
|
213
|
+
const body = request.headers.get('content-type')?.includes('application/json')
|
|
214
|
+
? await request.json() as { app_id?: unknown; app_ids?: unknown }
|
|
215
|
+
: {};
|
|
216
|
+
if (typeof body.app_id === 'string' && body.app_id.trim()) {
|
|
217
|
+
appIds = [body.app_id.trim()];
|
|
218
|
+
} else if (Array.isArray(body.app_ids)) {
|
|
219
|
+
appIds = body.app_ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0).map((id) => id.trim());
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
return new Response(JSON.stringify({ ok: false, error: 'invalid JSON body' }), {
|
|
223
|
+
status: 400,
|
|
224
|
+
headers: { 'content-type': 'application/json' },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const result = larkIntegration.restart(appIds);
|
|
228
|
+
if (result.ok && result.restarted.length > 0) {
|
|
229
|
+
console.log(`[server] lark restarted: ${result.restarted.join(',')}`);
|
|
230
|
+
}
|
|
231
|
+
return new Response(JSON.stringify(result.ok ? { ok: true, data: result } : { ok: false, error: result.error, data: { bots: result.bots, restarted: result.restarted, missing: result.missing } }), {
|
|
232
|
+
status: result.ok ? 200 : 500,
|
|
233
|
+
headers: { 'content-type': 'application/json' },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function probeLarkEvent(request: Request, url: URL): Promise<Response> {
|
|
238
|
+
if (!isLoopbackHost(url.hostname)) {
|
|
239
|
+
return new Response(JSON.stringify({ ok: false, error: 'Lark probe is only available through a loopback server URL' }), {
|
|
240
|
+
status: 403,
|
|
241
|
+
headers: { 'content-type': 'application/json' },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
assertServerAuth(request);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const message = err instanceof Error ? err.message : 'unauthorized';
|
|
248
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
249
|
+
status: 401,
|
|
250
|
+
headers: { 'content-type': 'application/json' },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
let body: { app_id?: unknown; envelope?: unknown; data?: unknown };
|
|
254
|
+
try {
|
|
255
|
+
body = await request.json() as { app_id?: unknown; envelope?: unknown; data?: unknown };
|
|
256
|
+
} catch {
|
|
257
|
+
return new Response(JSON.stringify({ ok: false, error: 'invalid JSON body' }), {
|
|
258
|
+
status: 400,
|
|
259
|
+
headers: { 'content-type': 'application/json' },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (typeof body.app_id !== 'string' || !body.app_id.trim()) {
|
|
263
|
+
return new Response(JSON.stringify({ ok: false, error: 'app_id is required' }), {
|
|
264
|
+
status: 400,
|
|
265
|
+
headers: { 'content-type': 'application/json' },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (body.data === undefined || body.data === null || typeof body.data !== 'object') {
|
|
269
|
+
return new Response(JSON.stringify({ ok: false, error: 'data object is required' }), {
|
|
270
|
+
status: 400,
|
|
271
|
+
headers: { 'content-type': 'application/json' },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const result = await larkIntegration.injectProbeEvent({
|
|
276
|
+
appId: body.app_id,
|
|
277
|
+
envelope: typeof body.envelope === 'string' && body.envelope.trim() ? body.envelope : undefined,
|
|
278
|
+
data: body.data,
|
|
279
|
+
});
|
|
280
|
+
return new Response(JSON.stringify({ ok: true, data: result }), {
|
|
281
|
+
headers: { 'content-type': 'application/json' },
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return new Response(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }), {
|
|
285
|
+
status: 400,
|
|
286
|
+
headers: { 'content-type': 'application/json' },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
59
291
|
if (larkIntegration.handles.length > 0) {
|
|
60
292
|
console.log(`[server] lark integrated: ${larkIntegration.handles.map((h) => h.appId).join(',')}`);
|
|
61
293
|
}
|
|
62
294
|
|
|
63
295
|
process.on('SIGINT', () => {
|
|
296
|
+
if (larkAutoRestart) clearInterval(larkAutoRestart);
|
|
297
|
+
if (messagingHealthLog) clearInterval(messagingHealthLog);
|
|
298
|
+
clearInterval(computerConnectionCleanup);
|
|
299
|
+
deliveryWs.close();
|
|
64
300
|
larkIntegration.stop();
|
|
65
301
|
tailscaleServer?.stop();
|
|
66
302
|
server.stop();
|
|
67
303
|
process.exit(0);
|
|
68
304
|
});
|
|
69
305
|
process.on('SIGTERM', () => {
|
|
306
|
+
if (larkAutoRestart) clearInterval(larkAutoRestart);
|
|
307
|
+
if (messagingHealthLog) clearInterval(messagingHealthLog);
|
|
308
|
+
clearInterval(computerConnectionCleanup);
|
|
309
|
+
deliveryWs.close();
|
|
70
310
|
larkIntegration.stop();
|
|
71
311
|
tailscaleServer?.stop();
|
|
72
312
|
server.stop();
|
package/src/token-file.ts
CHANGED
|
@@ -5,7 +5,11 @@ import { homeDir } from './config.js';
|
|
|
5
5
|
|
|
6
6
|
const TOKEN_BYTES = 32;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
export interface DaemonTokenFileOptions {
|
|
9
|
+
platform?: NodeJS.Platform;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function assertPrivateDir(path: string, platform: NodeJS.Platform): void {
|
|
9
13
|
if (!existsSync(path)) {
|
|
10
14
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
11
15
|
}
|
|
@@ -14,6 +18,7 @@ function assertPrivateDir(path: string): void {
|
|
|
14
18
|
if (link.isSymbolicLink()) throw new Error(`unsafe token directory: ${path} is a symlink`);
|
|
15
19
|
const stat = statSync(path);
|
|
16
20
|
if (!stat.isDirectory()) throw new Error(`unsafe token directory: ${path} is not a directory`);
|
|
21
|
+
if (platform === 'win32') return;
|
|
17
22
|
if ((stat.mode & 0o777) !== 0o700) {
|
|
18
23
|
chmodSync(path, 0o700);
|
|
19
24
|
const fixed = statSync(path);
|
|
@@ -21,11 +26,12 @@ function assertPrivateDir(path: string): void {
|
|
|
21
26
|
}
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
function assertPrivateTokenFile(path: string): void {
|
|
29
|
+
function assertPrivateTokenFile(path: string, platform: NodeJS.Platform): void {
|
|
25
30
|
const link = lstatSync(path);
|
|
26
31
|
if (link.isSymbolicLink()) throw new Error(`unsafe token file: ${path} is a symlink`);
|
|
27
32
|
const stat = statSync(path);
|
|
28
33
|
if (!stat.isFile()) throw new Error(`unsafe token file: ${path} is not a regular file`);
|
|
34
|
+
if (platform === 'win32') return;
|
|
29
35
|
if ((stat.mode & 0o177) !== 0) throw new Error(`unsafe token file: ${path} permissions must be 0600`);
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -38,12 +44,13 @@ export function legacyTokenPath(): string {
|
|
|
38
44
|
return join(homeDir(), 'daemon-token');
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
export function loadOrCreateDaemonToken(path = defaultTokenPath()): string {
|
|
47
|
+
export function loadOrCreateDaemonToken(path = defaultTokenPath(), options: DaemonTokenFileOptions = {}): string {
|
|
48
|
+
const platform = options.platform ?? process.platform;
|
|
42
49
|
const resolved = resolve(path);
|
|
43
|
-
assertPrivateDir(dirname(resolved));
|
|
50
|
+
assertPrivateDir(dirname(resolved), platform);
|
|
44
51
|
|
|
45
52
|
if (existsSync(resolved)) {
|
|
46
|
-
assertPrivateTokenFile(resolved);
|
|
53
|
+
assertPrivateTokenFile(resolved, platform);
|
|
47
54
|
const token = readFileSync(resolved, 'utf8').trim();
|
|
48
55
|
if (!token) throw new Error(`empty token file: ${resolved}`);
|
|
49
56
|
return token;
|
|
@@ -52,6 +59,6 @@ export function loadOrCreateDaemonToken(path = defaultTokenPath()): string {
|
|
|
52
59
|
const token = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)).reduce((value, byte) => value + byte.toString(16).padStart(2, '0'), '');
|
|
53
60
|
const fd = openSync(resolved, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
54
61
|
writeFileSync(fd, `${token}\n`);
|
|
55
|
-
assertPrivateTokenFile(resolved);
|
|
62
|
+
assertPrivateTokenFile(resolved, platform);
|
|
56
63
|
return token;
|
|
57
64
|
}
|