@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.
@@ -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(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []);
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 ownerUnionId = configuredOwnerUnionId();
265
- if (ownerUnionId) {
266
- const identity = await resolveSenderUnionId({ bot, envelope: larkEnvelope, store: msgStore });
267
- if (identity.status === 'pending') {
268
- msgStore.recordPendingInboundEvent({ rawEventId: storeResult.event_id, provider: 'lark', reason: 'missing_union_id', error: identity.reason });
269
- log.warn(`[lark/${bot.appId}] sender union_id lookup pending: ${identity.reason}`);
270
- return;
271
- }
272
- if (identity.status !== 'resolved' || identity.unionId !== ownerUnionId) {
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 rerun lark setup.');
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('Paste an existing Feishu app credential. Pal will validate it before writing lark.json.');
279
- const appId = clean(await ask('App ID (cli_xxx): '));
280
- const appSecret = clean(await ask('App Secret: '));
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 setup] overwrote existing credential for appId=${appId} in ${options.configPath}`);
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 type { RunAction } from './types.js';
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
- if (!token) {
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 !== `Bearer ${token}`) {
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
- async fetch(request) {
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;