@controlflow-ai/daemon 0.1.0 → 0.1.2
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 +21 -19
- package/package.json +14 -3
- package/src/agent-runtime.ts +15 -0
- package/src/app.ts +314 -3
- package/src/client.ts +11 -3
- package/src/console.ts +397 -18
- package/src/daemon.ts +25 -6
- package/src/db.ts +181 -6
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +4 -134
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +22 -2
- package/src/lark/server-integration.ts +9 -16
- package/src/lark/setup.ts +74 -5
- package/src/local-api.ts +69 -2
- package/src/local-auth.ts +4 -3
- package/src/migrations/022_lark_authorized_users.ts +16 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations.ts +3 -1
- package/src/network.ts +24 -0
- package/src/server.ts +21 -7
- package/src/types.ts +40 -0
- package/src/web.ts +368 -29
package/src/lark/event-router.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MessageStore, CreateMessageInput } from '../db.js';
|
|
2
|
+
import { ALL_AGENTS_MENTION } from '../db.js';
|
|
2
3
|
import type { Message } from '../types.js';
|
|
3
4
|
import { palIdentityHandle } from '../provider-identity.js';
|
|
4
5
|
|
|
@@ -82,6 +83,22 @@ export function extractMentionOpenIds(envelope: LarkMessageEnvelope): string[] {
|
|
|
82
83
|
return ids;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
function isAllMention(mention: NonNullable<NonNullable<LarkMessageEnvelope['message']>['mentions']>[number]): boolean {
|
|
87
|
+
const key = mention.key?.trim().toLowerCase();
|
|
88
|
+
const name = mention.name?.trim().toLowerCase();
|
|
89
|
+
const openId = mention.id?.open_id?.trim().toLowerCase();
|
|
90
|
+
return key === '@all' || key === 'all' || name === 'all' || name === '所有人' || openId === 'all';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function mentionsAllAgents(envelope: LarkMessageEnvelope): boolean {
|
|
94
|
+
const mentions = envelope.message?.mentions ?? [];
|
|
95
|
+
for (const mention of mentions) {
|
|
96
|
+
if (isAllMention(mention)) return true;
|
|
97
|
+
}
|
|
98
|
+
const raw = parseLarkTextContent(envelope.message?.content, envelope.message?.message_type).toLowerCase();
|
|
99
|
+
return /(^|\s)@(_all|all|所有人)(\s|$)/u.test(raw);
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
function normalizeMappedMentions(values: Array<string | null>): string[] {
|
|
86
103
|
const out: string[] = [];
|
|
87
104
|
const seen = new Set<string>();
|
|
@@ -124,8 +141,11 @@ export function mapLarkMessageToCreateInput(input: MapLarkMessageInput): MapLark
|
|
|
124
141
|
const text = parseLarkTextContent(msg.content, msg.message_type);
|
|
125
142
|
if (!text.trim()) return { status: 'skipped', reason: 'empty_text' };
|
|
126
143
|
|
|
127
|
-
const firstMention = msg.mentions?.find((m) => m.id?.open_id)?.id?.open_id;
|
|
128
|
-
const mappedMentions = normalizeMappedMentions(
|
|
144
|
+
const firstMention = msg.mentions?.find((m) => m.id?.open_id && !isAllMention(m))?.id?.open_id;
|
|
145
|
+
const mappedMentions = normalizeMappedMentions([
|
|
146
|
+
...(mentionsAllAgents(envelope) ? [ALL_AGENTS_MENTION] : []),
|
|
147
|
+
...(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []),
|
|
148
|
+
]);
|
|
129
149
|
const recipient = input.recipientOverride !== undefined
|
|
130
150
|
? input.recipientOverride
|
|
131
151
|
: firstMention ? input.recipientByMentionOpenId?.get(firstMention) ?? firstMention : null;
|
|
@@ -52,10 +52,6 @@ function mentionsBotLabel(envelope: LarkMessageEnvelope, bot: LarkCredential): b
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
function configuredOwnerUnionId(): string | null {
|
|
56
|
-
return process.env.PAL_OWNER_LARK_UNION_ID?.trim() || process.env.PAL_LARK_OWNER_UNION_ID?.trim() || null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
55
|
function senderUnionId(envelope: LarkMessageEnvelope): string | null {
|
|
60
56
|
return envelope.sender?.sender_id?.union_id?.trim() || null;
|
|
61
57
|
}
|
|
@@ -261,18 +257,15 @@ export function startLarkOnServer(options: LarkServerIntegrationOptions): LarkSe
|
|
|
261
257
|
if (envelope === 'im.message.receive_v1') {
|
|
262
258
|
try {
|
|
263
259
|
const larkEnvelope = data as LarkMessageEnvelope;
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
log.log(`[lark/${bot.appId}] business ingest skipped sender_union=${identity.status === 'resolved' ? identity.unionId : '-'} owner_configured=true`);
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
260
|
+
const identity = await resolveSenderUnionId({ bot, envelope: larkEnvelope, store: msgStore });
|
|
261
|
+
if (identity.status === 'pending') {
|
|
262
|
+
msgStore.recordPendingInboundEvent({ rawEventId: storeResult.event_id, provider: 'lark', reason: 'missing_lark_sender_user_id', error: identity.reason });
|
|
263
|
+
log.warn(`[lark/${bot.appId}] sender union_id lookup pending: ${identity.reason}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (identity.status !== 'resolved' || !msgStore.isLarkAuthorizedUser(identity.unionId)) {
|
|
267
|
+
log.log(`[lark/${bot.appId}] business ingest skipped sender_union=${identity.status === 'resolved' ? identity.unionId : '-'} authorized=false`);
|
|
268
|
+
return;
|
|
276
269
|
}
|
|
277
270
|
const agents = boundAgents(bot);
|
|
278
271
|
const mentionOpenIds = extractMentionOpenIds(larkEnvelope);
|
package/src/lark/setup.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
+
import QRCode from 'qrcode';
|
|
2
3
|
import type { LarkCredential } from './credentials.js';
|
|
3
4
|
import { loadLarkCredentials, saveLarkCredentials, upsertCredential } from './credentials.js';
|
|
5
|
+
import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './app-registration.js';
|
|
4
6
|
|
|
5
7
|
export interface CredentialValidationOk {
|
|
6
8
|
ok: true;
|
|
@@ -62,6 +64,7 @@ export interface InteractiveSetupOptions {
|
|
|
62
64
|
listAgents?: () => Promise<LarkSetupAgentOption[]>;
|
|
63
65
|
validateCredentials?: typeof validateLarkCredentials;
|
|
64
66
|
resolveBotInfo?: typeof resolveLarkBotInfo;
|
|
67
|
+
registerApp?: typeof registerLarkAppFromDeviceFlow;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
function clean(value: string | undefined): string | undefined {
|
|
@@ -69,6 +72,41 @@ function clean(value: string | undefined): string | undefined {
|
|
|
69
72
|
return trimmed ? trimmed : undefined;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
export async function registerLarkAppFromDeviceFlow(options: {
|
|
76
|
+
log: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
77
|
+
qrCode?: (url: string) => Promise<string>;
|
|
78
|
+
fetchImpl?: FetchLike;
|
|
79
|
+
}): Promise<LarkRegistrationComplete | null> {
|
|
80
|
+
const begin = await beginLarkAppRegistration({ fetchImpl: options.fetchImpl, source: 'pal' });
|
|
81
|
+
options.log.log('Create a Feishu app by scanning this QR code or opening the link:');
|
|
82
|
+
options.log.log('');
|
|
83
|
+
const terminalQr = options.qrCode
|
|
84
|
+
? await options.qrCode(begin.url)
|
|
85
|
+
: await QRCode.toString(begin.url, { type: 'terminal', small: true });
|
|
86
|
+
options.log.log(terminalQr);
|
|
87
|
+
options.log.log(begin.url);
|
|
88
|
+
options.log.log('');
|
|
89
|
+
options.log.log(`Waiting for confirmation. The code expires in about ${Math.max(1, Math.round(begin.expiresIn / 60))} minutes.`);
|
|
90
|
+
|
|
91
|
+
let intervalMs = Math.max(1, begin.interval) * 1000;
|
|
92
|
+
const deadline = Date.now() + begin.expiresIn * 1000;
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
95
|
+
const result = await pollLarkAppRegistration({ deviceCode: begin.deviceCode, fetchImpl: options.fetchImpl });
|
|
96
|
+
if (result.status === 'pending') continue;
|
|
97
|
+
if (result.status === 'slow_down') {
|
|
98
|
+
intervalMs = Math.max(intervalMs + 5000, result.interval * 1000);
|
|
99
|
+
options.log.warn(`Feishu asked us to slow polling; retrying every ${Math.round(intervalMs / 1000)}s.`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (result.status === 'complete') return result.registration;
|
|
103
|
+
options.log.warn(`App registration did not complete (${result.status}): ${result.message}`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
options.log.warn('App registration expired before it completed.');
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
72
110
|
async function chooseAgent(options: InteractiveSetupOptions, ask: (question: string) => Promise<string>): Promise<string | null> {
|
|
73
111
|
if (!options.listAgents) {
|
|
74
112
|
return clean(await ask('Bind to agent key [codex]: ')) ?? 'codex';
|
|
@@ -79,7 +117,7 @@ async function chooseAgent(options: InteractiveSetupOptions, ask: (question: str
|
|
|
79
117
|
agents = await options.listAgents();
|
|
80
118
|
} catch (error) {
|
|
81
119
|
options.log.error(`Could not load agent list: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
-
options.log.error('Set up an agent first, then
|
|
120
|
+
options.log.error('Set up an agent first, then bind the bot from agent settings.');
|
|
83
121
|
return null;
|
|
84
122
|
}
|
|
85
123
|
|
|
@@ -275,9 +313,37 @@ export async function runInteractiveLarkSetup(options: InteractiveSetupOptions):
|
|
|
275
313
|
const agent = await chooseAgent(options, ask);
|
|
276
314
|
if (!agent) return null;
|
|
277
315
|
options.log.log('');
|
|
278
|
-
options.log.log('
|
|
279
|
-
|
|
280
|
-
|
|
316
|
+
options.log.log('Create a Feishu app by scan/link, or paste an existing credential.');
|
|
317
|
+
options.log.log(' 1. Scan or open link to create app');
|
|
318
|
+
options.log.log(' 2. Paste App ID / App Secret');
|
|
319
|
+
const method = clean(await ask('Setup method [1]: ')) ?? '1';
|
|
320
|
+
let appId: string | undefined;
|
|
321
|
+
let appSecret: string | undefined;
|
|
322
|
+
let scannedUserOpenId: string | undefined;
|
|
323
|
+
if (method !== '2') {
|
|
324
|
+
const registerApp = options.registerApp ?? registerLarkAppFromDeviceFlow;
|
|
325
|
+
const registration = await registerApp({ log: options.log }).catch((error) => {
|
|
326
|
+
options.log.warn(`App registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
327
|
+
return null;
|
|
328
|
+
});
|
|
329
|
+
if (!registration) {
|
|
330
|
+
options.log.warn('Falling back to manual App ID / App Secret entry.');
|
|
331
|
+
} else if (registration.tenantBrand === 'lark') {
|
|
332
|
+
options.log.error('Lark international tenants are not supported yet; Pal currently expects Feishu (feishu.cn) credentials.');
|
|
333
|
+
options.log.error('No config was written.');
|
|
334
|
+
return null;
|
|
335
|
+
} else {
|
|
336
|
+
appId = registration.appId;
|
|
337
|
+
appSecret = registration.appSecret;
|
|
338
|
+
scannedUserOpenId = registration.userOpenId;
|
|
339
|
+
options.log.log(`App created: ${appId}`);
|
|
340
|
+
if (scannedUserOpenId) options.log.log(`Scanner open_id: ${scannedUserOpenId}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!appId || !appSecret) {
|
|
344
|
+
appId = clean(await ask('App ID (cli_xxx): '));
|
|
345
|
+
appSecret = clean(await ask('App Secret: '));
|
|
346
|
+
}
|
|
281
347
|
if (!appId || !appSecret) {
|
|
282
348
|
options.log.error('App ID and App Secret are required; no config was written.');
|
|
283
349
|
return null;
|
|
@@ -315,8 +381,11 @@ export async function runInteractiveLarkSetup(options: InteractiveSetupOptions):
|
|
|
315
381
|
agent,
|
|
316
382
|
configPath: options.configPath,
|
|
317
383
|
});
|
|
384
|
+
if (scannedUserOpenId) {
|
|
385
|
+
options.log.log(`Scanner open_id ${scannedUserOpenId} was detected; add it under Lark authorized users if this user should operate Pal.`);
|
|
386
|
+
}
|
|
318
387
|
if (result.replaced) {
|
|
319
|
-
options.log.warn(`[lark
|
|
388
|
+
options.log.warn(`[lark] overwrote existing credential for appId=${appId} in ${options.configPath}`);
|
|
320
389
|
}
|
|
321
390
|
options.log.log(formatLarkSetupNextSteps(result));
|
|
322
391
|
return result;
|
package/src/local-api.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { LockClient } from './client.js';
|
|
2
2
|
import { assertLocalRequest, assertLoopbackBindHost } from './local-auth.js';
|
|
3
3
|
import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
|
|
4
|
-
import
|
|
4
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, isAbsolute, join, normalize } from 'node:path';
|
|
7
|
+
import type { RemoteFileEntry, RemoteFileList, RunAction } from './types.js';
|
|
5
8
|
|
|
6
9
|
interface LocalApiOptions {
|
|
7
10
|
serverUrl: string;
|
|
8
11
|
token?: string;
|
|
12
|
+
controlToken?: string;
|
|
9
13
|
daemonAuth?: ConstructorParameters<typeof LockClient>[1];
|
|
10
14
|
}
|
|
11
15
|
|
|
@@ -38,9 +42,64 @@ function routeNotFound(): Response {
|
|
|
38
42
|
return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
async function canReadDirectory(path: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
await readdir(path);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listLocalFiles(input: { path?: string; showHidden?: boolean }): Promise<RemoteFileList> {
|
|
55
|
+
const home = homedir();
|
|
56
|
+
const rawPath = input.path?.trim() || home;
|
|
57
|
+
if (!isAbsolute(rawPath)) throw new HttpError(400, 'PATH_NOT_ABSOLUTE', 'path must be absolute');
|
|
58
|
+
const targetPath = normalize(rawPath);
|
|
59
|
+
const targetStat = await stat(targetPath).catch(() => null);
|
|
60
|
+
if (!targetStat) throw new HttpError(404, 'PATH_NOT_FOUND', 'path was not found');
|
|
61
|
+
if (!targetStat.isDirectory()) throw new HttpError(400, 'PATH_NOT_DIRECTORY', 'path is not a directory');
|
|
62
|
+
|
|
63
|
+
let dirents: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
64
|
+
try {
|
|
65
|
+
dirents = await readdir(targetPath, { withFileTypes: true }) as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
66
|
+
} catch {
|
|
67
|
+
throw new HttpError(403, 'PATH_NOT_READABLE', 'directory is not readable');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entries = await Promise.all(dirents
|
|
71
|
+
.filter((entry) => input.showHidden || !entry.name.startsWith('.'))
|
|
72
|
+
.map(async (entry): Promise<RemoteFileEntry> => {
|
|
73
|
+
const entryPath = join(targetPath, entry.name);
|
|
74
|
+
const type = entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other';
|
|
75
|
+
const readable = type === 'directory' ? await canReadDirectory(entryPath) : false;
|
|
76
|
+
return {
|
|
77
|
+
name: entry.name,
|
|
78
|
+
path: entryPath,
|
|
79
|
+
type,
|
|
80
|
+
hidden: entry.name.startsWith('.'),
|
|
81
|
+
readable,
|
|
82
|
+
selectable: type === 'directory' && readable,
|
|
83
|
+
};
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
entries.sort((left, right) => {
|
|
87
|
+
if (left.type === 'directory' && right.type !== 'directory') return -1;
|
|
88
|
+
if (left.type !== 'directory' && right.type === 'directory') return 1;
|
|
89
|
+
return left.name.localeCompare(right.name);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
path: targetPath,
|
|
94
|
+
parent: targetPath === dirname(targetPath) ? null : dirname(targetPath),
|
|
95
|
+
home,
|
|
96
|
+
entries,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
41
100
|
export async function localRoute(request: Request, options: LocalApiOptions): Promise<Response> {
|
|
42
101
|
try {
|
|
43
|
-
assertLocalRequest(request, options.token);
|
|
102
|
+
assertLocalRequest(request, [options.token, options.controlToken].filter((item): item is string => Boolean(item)));
|
|
44
103
|
const client = new LockClient(options.serverUrl, options.daemonAuth ?? null);
|
|
45
104
|
const url = new URL(request.url);
|
|
46
105
|
const { pathname } = url;
|
|
@@ -49,6 +108,14 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
|
|
|
49
108
|
return json({ status: 'ok', mode: 'daemon' });
|
|
50
109
|
}
|
|
51
110
|
|
|
111
|
+
if (request.method === 'GET' && pathname === '/local/files') {
|
|
112
|
+
const files = await listLocalFiles({
|
|
113
|
+
path: stringParam(url, 'path'),
|
|
114
|
+
showHidden: url.searchParams.get('show_hidden') === 'true',
|
|
115
|
+
});
|
|
116
|
+
return json(files);
|
|
117
|
+
}
|
|
118
|
+
|
|
52
119
|
if (request.method === 'GET' && pathname === '/local/chats') {
|
|
53
120
|
return json({ chats: await client.listChats() });
|
|
54
121
|
}
|
package/src/local-auth.ts
CHANGED
|
@@ -9,7 +9,7 @@ function hostName(value: string | null): string {
|
|
|
9
9
|
return withoutPort.toLowerCase();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function assertLocalRequest(request: Request, token: string | undefined): void {
|
|
12
|
+
export function assertLocalRequest(request: Request, token: string | string[] | undefined): void {
|
|
13
13
|
const host = hostName(request.headers.get('host'));
|
|
14
14
|
if (!ALLOWED_HOSTS.has(host)) {
|
|
15
15
|
throw new HttpError(403, 'BAD_HOST', 'local daemon host is not allowed');
|
|
@@ -24,12 +24,13 @@ export function assertLocalRequest(request: Request, token: string | undefined):
|
|
|
24
24
|
throw new HttpError(403, 'CORS_DENIED', 'CORS preflight is not allowed');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const tokens = Array.isArray(token) ? token.filter(Boolean) : token ? [token] : [];
|
|
28
|
+
if (tokens.length === 0) {
|
|
28
29
|
throw new HttpError(401, 'UNAUTHORIZED', 'local daemon token is required');
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const header = request.headers.get('authorization');
|
|
32
|
-
if (header
|
|
33
|
+
if (!tokens.some((item) => header === `Bearer ${item}`)) {
|
|
33
34
|
throw new HttpError(401, 'UNAUTHORIZED', 'invalid local daemon token');
|
|
34
35
|
}
|
|
35
36
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export const version = 22;
|
|
4
|
+
export const name = 'lark_authorized_users';
|
|
5
|
+
|
|
6
|
+
export function up(db: Database): void {
|
|
7
|
+
db.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS lark_authorized_users (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
user_id TEXT NOT NULL UNIQUE,
|
|
11
|
+
display_name TEXT,
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
13
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
14
|
+
);
|
|
15
|
+
`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export const version = 23;
|
|
4
|
+
export const name = 'projects';
|
|
5
|
+
|
|
6
|
+
function hasColumn(db: Database, table: string, column: string): boolean {
|
|
7
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
8
|
+
return rows.some((row) => row.name === column);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function addColumnIfMissing(db: Database, table: string, column: string, ddl: string): void {
|
|
12
|
+
if (!hasColumn(db, table, column)) {
|
|
13
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function up(db: Database): void {
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT NOT NULL,
|
|
22
|
+
computer_id TEXT NOT NULL REFERENCES computers(id) ON DELETE RESTRICT,
|
|
23
|
+
root_path TEXT NOT NULL,
|
|
24
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
25
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_projects_computer
|
|
29
|
+
ON projects(computer_id, name);
|
|
30
|
+
`);
|
|
31
|
+
|
|
32
|
+
addColumnIfMissing(db, 'chats', 'project_id', 'project_id TEXT REFERENCES projects(id) ON DELETE SET NULL');
|
|
33
|
+
addColumnIfMissing(db, 'computer_connections', 'local_control_token', 'local_control_token TEXT');
|
|
34
|
+
|
|
35
|
+
db.exec(`
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_chats_project
|
|
37
|
+
ON chats(project_id, provider, kind);
|
|
38
|
+
|
|
39
|
+
DROP VIEW IF EXISTS chat_stats;
|
|
40
|
+
CREATE VIEW chat_stats AS
|
|
41
|
+
SELECT
|
|
42
|
+
c.id,
|
|
43
|
+
c.name,
|
|
44
|
+
c.display_name,
|
|
45
|
+
c.kind,
|
|
46
|
+
c.server_id,
|
|
47
|
+
c.provider,
|
|
48
|
+
c.dm_type,
|
|
49
|
+
c.capabilities_json,
|
|
50
|
+
c.audit_visibility,
|
|
51
|
+
c.project_id,
|
|
52
|
+
p.name AS project_name,
|
|
53
|
+
p.root_path AS project_root_path,
|
|
54
|
+
p.computer_id AS project_computer_id,
|
|
55
|
+
pc.name AS project_computer_name,
|
|
56
|
+
c.created_at,
|
|
57
|
+
COUNT(m.id) AS message_count,
|
|
58
|
+
MAX(m.created_at) AS last_message_at
|
|
59
|
+
FROM chats c
|
|
60
|
+
LEFT JOIN projects p ON p.id = c.project_id
|
|
61
|
+
LEFT JOIN computers pc ON pc.id = p.computer_id
|
|
62
|
+
LEFT JOIN messages m ON m.chat_id = c.id
|
|
63
|
+
GROUP BY c.id;
|
|
64
|
+
`);
|
|
65
|
+
}
|
package/src/migrations.ts
CHANGED
|
@@ -20,6 +20,8 @@ import * as roomDisplayNames from './migrations/018_room_display_names.js';
|
|
|
20
20
|
import * as computerConnections from './migrations/019_computer_connections.js';
|
|
21
21
|
import * as computerAgentAssignments from './migrations/020_computer_agent_assignments.js';
|
|
22
22
|
import * as providerIdentityBindings from './migrations/021_provider_identity_bindings.js';
|
|
23
|
+
import * as larkAuthorizedUsers from './migrations/022_lark_authorized_users.js';
|
|
24
|
+
import * as projects from './migrations/023_projects.js';
|
|
23
25
|
|
|
24
26
|
interface Migration {
|
|
25
27
|
version: number;
|
|
@@ -27,7 +29,7 @@ interface Migration {
|
|
|
27
29
|
up(db: Database): void;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
const migrations: Migration[] = [initial, daemonDeliveries, sessionsRuns, messageIdempotency, artifacts, larkChannelFoundation, agentsA0, b0ChatHistory, b0TranscriptIngestSeq, b0TranscriptShadowExternalIds, b0ChannelConversationAuditOnly, b0CrossConversationInvariant, b10EngInboundRawEvents, agentsRuntime, agentRuntimeSessions, roomParticipants, unifiedRoomDelivery, roomDisplayNames, computerConnections, computerAgentAssignments, providerIdentityBindings].sort((a, b) => a.version - b.version);
|
|
32
|
+
const migrations: Migration[] = [initial, daemonDeliveries, sessionsRuns, messageIdempotency, artifacts, larkChannelFoundation, agentsA0, b0ChatHistory, b0TranscriptIngestSeq, b0TranscriptShadowExternalIds, b0ChannelConversationAuditOnly, b0CrossConversationInvariant, b10EngInboundRawEvents, agentsRuntime, agentRuntimeSessions, roomParticipants, unifiedRoomDelivery, roomDisplayNames, computerConnections, computerAgentAssignments, providerIdentityBindings, larkAuthorizedUsers, projects].sort((a, b) => a.version - b.version);
|
|
31
33
|
|
|
32
34
|
function assertContiguousMigrations(): void {
|
|
33
35
|
for (let index = 0; index < migrations.length; index += 1) {
|
package/src/network.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
|
|
3
|
+
export function tailscaleAddress(): string | null {
|
|
4
|
+
const interfaces = networkInterfaces();
|
|
5
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
6
|
+
for (const entry of entries ?? []) {
|
|
7
|
+
if (entry.family !== 'IPv4' || entry.internal) continue;
|
|
8
|
+
if (name.toLowerCase().startsWith('tailscale') || isTailscaleIPv4(entry.address)) {
|
|
9
|
+
return entry.address;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isTailscaleIPv4(address: string): boolean {
|
|
17
|
+
const parts = address.split('.').map((part) => Number(part));
|
|
18
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
19
|
+
return parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isLoopbackHost(host: string): boolean {
|
|
23
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
24
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -3,25 +3,37 @@ import { DEFAULT_HOST, DEFAULT_PORT, defaultDbPath } from './config.js';
|
|
|
3
3
|
import { MessageStore } from './db.js';
|
|
4
4
|
import { handleRequest } from './app.js';
|
|
5
5
|
import { startLarkOnServer } from './lark/server-integration.js';
|
|
6
|
+
import { isLoopbackHost, tailscaleAddress } from './network.js';
|
|
6
7
|
|
|
7
8
|
const dbPath = defaultDbPath();
|
|
8
9
|
const store = new MessageStore(dbPath);
|
|
9
10
|
const port = Number(process.env.PAL_PORT ?? DEFAULT_PORT);
|
|
10
11
|
const host = process.env.PAL_HOST ?? DEFAULT_HOST;
|
|
12
|
+
const tailscaleHost = process.env.PAL_TAILSCALE_HOST ?? tailscaleAddress();
|
|
13
|
+
|
|
14
|
+
function fetch(request: Request): Promise<Response> | Response {
|
|
15
|
+
const url = new URL(request.url);
|
|
16
|
+
if (request.method === 'POST' && url.pathname === '/api/lark/reload') {
|
|
17
|
+
return reloadLarkIntegration();
|
|
18
|
+
}
|
|
19
|
+
return handleRequest(store, request);
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
const server = Bun.serve({
|
|
13
23
|
hostname: host,
|
|
14
24
|
port,
|
|
15
|
-
|
|
16
|
-
const url = new URL(request.url);
|
|
17
|
-
if (request.method === 'POST' && url.pathname === '/api/lark/reload') {
|
|
18
|
-
return reloadLarkIntegration();
|
|
19
|
-
}
|
|
20
|
-
return handleRequest(store, request);
|
|
21
|
-
},
|
|
25
|
+
fetch,
|
|
22
26
|
});
|
|
27
|
+
const tailscaleServer = tailscaleHost && isLoopbackHost(host)
|
|
28
|
+
? Bun.serve({ hostname: tailscaleHost, port, fetch })
|
|
29
|
+
: null;
|
|
23
30
|
|
|
24
31
|
console.log(`pal server listening on http://${server.hostname}:${server.port}`);
|
|
32
|
+
if (tailscaleServer) {
|
|
33
|
+
console.log(`pal server also listening on Tailscale http://${tailscaleServer.hostname}:${tailscaleServer.port}`);
|
|
34
|
+
} else if (tailscaleHost) {
|
|
35
|
+
console.log(`pal server Tailscale address: http://${tailscaleHost}:${server.port}`);
|
|
36
|
+
}
|
|
25
37
|
console.log(`database: ${dbPath}`);
|
|
26
38
|
|
|
27
39
|
function startLarkIntegration() {
|
|
@@ -50,11 +62,13 @@ if (larkIntegration.handles.length > 0) {
|
|
|
50
62
|
|
|
51
63
|
process.on('SIGINT', () => {
|
|
52
64
|
larkIntegration.stop();
|
|
65
|
+
tailscaleServer?.stop();
|
|
53
66
|
server.stop();
|
|
54
67
|
process.exit(0);
|
|
55
68
|
});
|
|
56
69
|
process.on('SIGTERM', () => {
|
|
57
70
|
larkIntegration.stop();
|
|
71
|
+
tailscaleServer?.stop();
|
|
58
72
|
server.stop();
|
|
59
73
|
process.exit(0);
|
|
60
74
|
});
|
package/src/types.ts
CHANGED
|
@@ -16,11 +16,43 @@ export interface Chat {
|
|
|
16
16
|
dm_type: 'agent_agent' | 'user_agent' | 'user_user' | null;
|
|
17
17
|
capabilities_json: string;
|
|
18
18
|
audit_visibility: 'members' | 'admins';
|
|
19
|
+
project_id: string | null;
|
|
20
|
+
project_name: string | null;
|
|
21
|
+
project_root_path: string | null;
|
|
22
|
+
project_computer_id: string | null;
|
|
23
|
+
project_computer_name: string | null;
|
|
19
24
|
created_at: string;
|
|
20
25
|
message_count: number;
|
|
21
26
|
last_message_at: string | null;
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
export interface Project {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
computer_id: string;
|
|
33
|
+
computer_name: string | null;
|
|
34
|
+
root_path: string;
|
|
35
|
+
room_count: number;
|
|
36
|
+
created_at: string;
|
|
37
|
+
updated_at: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RemoteFileEntry {
|
|
41
|
+
name: string;
|
|
42
|
+
path: string;
|
|
43
|
+
type: 'directory' | 'file' | 'other';
|
|
44
|
+
hidden: boolean;
|
|
45
|
+
readable: boolean;
|
|
46
|
+
selectable: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RemoteFileList {
|
|
50
|
+
path: string;
|
|
51
|
+
parent: string | null;
|
|
52
|
+
home: string;
|
|
53
|
+
entries: RemoteFileEntry[];
|
|
54
|
+
}
|
|
55
|
+
|
|
24
56
|
export type RoomParticipantKind = 'user' | 'bot' | 'agent';
|
|
25
57
|
export type RoomParticipantSource = 'lark_member_api' | 'known_bot' | 'event' | 'local_agent';
|
|
26
58
|
|
|
@@ -228,6 +260,14 @@ export interface ChannelAccount {
|
|
|
228
260
|
updated_at: string;
|
|
229
261
|
}
|
|
230
262
|
|
|
263
|
+
export interface LarkAuthorizedUser {
|
|
264
|
+
id: string;
|
|
265
|
+
user_id: string;
|
|
266
|
+
display_name: string | null;
|
|
267
|
+
created_at: string;
|
|
268
|
+
updated_at: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
231
271
|
export interface PalIdentity {
|
|
232
272
|
id: string;
|
|
233
273
|
kind: PalIdentityKind;
|