@controlflow-ai/daemon 0.1.1 → 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.
@@ -0,0 +1,141 @@
1
+ export type LarkRegistrationTenantBrand = 'feishu' | 'lark';
2
+
3
+ export interface LarkRegistrationBegin {
4
+ deviceCode: string;
5
+ url: string;
6
+ expiresIn: number;
7
+ interval: number;
8
+ }
9
+
10
+ export interface LarkRegistrationComplete {
11
+ appId: string;
12
+ appSecret: string;
13
+ tenantBrand: LarkRegistrationTenantBrand;
14
+ userOpenId?: string;
15
+ }
16
+
17
+ export type LarkRegistrationPollResult =
18
+ | { status: 'pending' }
19
+ | { status: 'slow_down'; interval: number }
20
+ | { status: 'complete'; registration: LarkRegistrationComplete }
21
+ | { status: 'denied' | 'expired' | 'error'; message: string };
22
+
23
+ export type RegistrationFetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
24
+
25
+ const FEISHU_ACCOUNTS_ORIGIN = 'https://accounts.feishu.cn';
26
+ const LARK_ACCOUNTS_ORIGIN = 'https://accounts.larksuite.com';
27
+ const REGISTRATION_PATH = '/oauth/v1/app/registration';
28
+
29
+ interface RegistrationBeginBody {
30
+ device_code?: unknown;
31
+ verification_uri_complete?: unknown;
32
+ expires_in?: unknown;
33
+ interval?: unknown;
34
+ error?: unknown;
35
+ error_description?: unknown;
36
+ }
37
+
38
+ interface RegistrationPollBody {
39
+ client_id?: unknown;
40
+ client_secret?: unknown;
41
+ user_info?: { tenant_brand?: unknown; open_id?: unknown };
42
+ error?: unknown;
43
+ error_description?: unknown;
44
+ }
45
+
46
+ function sanitizeMessage(message: unknown): string {
47
+ return String(message ?? 'unknown').replace(/[A-Za-z0-9_-]{30,}/g, '***');
48
+ }
49
+
50
+ async function postRegistration(origin: string, params: Record<string, string>, fetchImpl: RegistrationFetchLike): Promise<unknown> {
51
+ const response = await fetchImpl(`${origin}${REGISTRATION_PATH}`, {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
54
+ body: new URLSearchParams(params).toString(),
55
+ });
56
+ const body = await response.json().catch(() => ({}));
57
+ if (!response.ok && !(body && typeof body === 'object' && 'error' in body)) {
58
+ throw new Error(`registration request failed: HTTP ${response.status}`);
59
+ }
60
+ return body;
61
+ }
62
+
63
+ export async function beginLarkAppRegistration(options: {
64
+ fetchImpl?: RegistrationFetchLike;
65
+ source?: string;
66
+ } = {}): Promise<LarkRegistrationBegin> {
67
+ const fetchImpl = options.fetchImpl ?? fetch;
68
+ const body = await postRegistration(FEISHU_ACCOUNTS_ORIGIN, {
69
+ action: 'begin',
70
+ archetype: 'PersonalAgent',
71
+ auth_method: 'client_secret',
72
+ request_user_info: 'open_id',
73
+ }, fetchImpl) as RegistrationBeginBody;
74
+
75
+ if (typeof body.error === 'string') {
76
+ throw new Error(sanitizeMessage(body.error_description ?? body.error));
77
+ }
78
+ if (typeof body.device_code !== 'string' || typeof body.verification_uri_complete !== 'string') {
79
+ throw new Error('registration begin response did not include device_code and verification_uri_complete');
80
+ }
81
+
82
+ const url = new URL(body.verification_uri_complete);
83
+ url.searchParams.set('from', 'sdk');
84
+ url.searchParams.set('source', `node-sdk/${options.source ?? 'pal'}`);
85
+ url.searchParams.set('tp', 'sdk');
86
+
87
+ return {
88
+ deviceCode: body.device_code,
89
+ url: url.toString(),
90
+ expiresIn: typeof body.expires_in === 'number' ? body.expires_in : 600,
91
+ interval: typeof body.interval === 'number' ? body.interval : 5,
92
+ };
93
+ }
94
+
95
+ export async function pollLarkAppRegistration(options: {
96
+ deviceCode: string;
97
+ fetchImpl?: RegistrationFetchLike;
98
+ origin?: 'feishu' | 'lark';
99
+ }): Promise<LarkRegistrationPollResult> {
100
+ const fetchImpl = options.fetchImpl ?? fetch;
101
+ const origin = options.origin === 'lark' ? LARK_ACCOUNTS_ORIGIN : FEISHU_ACCOUNTS_ORIGIN;
102
+ let body: RegistrationPollBody;
103
+ try {
104
+ body = await postRegistration(origin, {
105
+ action: 'poll',
106
+ device_code: options.deviceCode,
107
+ }, fetchImpl) as RegistrationPollBody;
108
+ } catch (error) {
109
+ return {
110
+ status: 'error',
111
+ message: error instanceof Error ? sanitizeMessage(error.message) : sanitizeMessage(error),
112
+ };
113
+ }
114
+
115
+ if (typeof body.client_id === 'string' && typeof body.client_secret === 'string') {
116
+ const tenantBrand: LarkRegistrationTenantBrand = body.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
117
+ const openId = typeof body.user_info?.open_id === 'string' && body.user_info.open_id.startsWith('ou_')
118
+ ? body.user_info.open_id
119
+ : undefined;
120
+ return {
121
+ status: 'complete',
122
+ registration: {
123
+ appId: body.client_id,
124
+ appSecret: body.client_secret,
125
+ tenantBrand,
126
+ userOpenId: openId,
127
+ },
128
+ };
129
+ }
130
+
131
+ if (body.user_info?.tenant_brand === 'lark' && options.origin !== 'lark') {
132
+ return pollLarkAppRegistration({ ...options, origin: 'lark' });
133
+ }
134
+
135
+ const error = typeof body.error === 'string' ? body.error : '';
136
+ if (error === 'authorization_pending' || !error) return { status: 'pending' };
137
+ if (error === 'slow_down') return { status: 'slow_down', interval: 10 };
138
+ if (error === 'access_denied') return { status: 'denied', message: 'user denied app registration' };
139
+ if (error === 'expired_token') return { status: 'expired', message: 'registration QR code expired' };
140
+ return { status: 'error', message: sanitizeMessage(body.error_description ?? error) };
141
+ }
package/src/lark/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { defaultDbPath, defaultServerUrl } from '../config.js';
2
+ import { defaultDbPath } from '../config.js';
3
3
  import { MessageStore } from '../db.js';
4
4
  import {
5
5
  boundAgents,
@@ -11,12 +11,6 @@ import { countInboundEvents, listRecentInboundEvents } from './inbound-events.js
11
11
  import { extractMentionOpenIds, ingestLarkMessage, type LarkMessageEnvelope } from './event-router.js';
12
12
  import { ChatDispatcher, chatKeyOf, parseReceivePolicy, PeriodicQueue, shouldAcceptForAgent, type DispatchInput, type ReceivePolicy } from './dispatcher.js';
13
13
  import { parseRuntimeSpec } from './agent-runtime.js';
14
- import {
15
- formatLarkSetupNextSteps,
16
- persistLarkCredential,
17
- resolveLarkBotInfo,
18
- runInteractiveLarkSetup,
19
- } from './setup.js';
20
14
  import { createLarkApiClient, sendTextMessage, startLarkDaemon } from './ws-daemon.js';
21
15
 
22
16
  export interface LarkCliArgs {
@@ -44,69 +38,6 @@ function flagBool(flags: Record<string, unknown>, key: string): boolean {
44
38
  return flags[key] === true;
45
39
  }
46
40
 
47
- async function postJson(url: string, body: unknown): Promise<{ ok: boolean; status: number; text: string }> {
48
- try {
49
- const response = await fetch(url, {
50
- method: 'POST',
51
- headers: { 'content-type': 'application/json' },
52
- body: JSON.stringify(body),
53
- });
54
- return { ok: response.ok, status: response.status, text: await response.text() };
55
- } catch (error) {
56
- return { ok: false, status: 0, text: error instanceof Error ? error.message : String(error) };
57
- }
58
- }
59
-
60
- async function listAgentsForSetup(serverUrl: string): Promise<Array<{ agent_key: string; display_name: string; runtime?: string | null }>> {
61
- const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/agents`);
62
- const payload = await response.json() as { ok?: boolean; data?: { agents?: Array<{ agent_key?: unknown; display_name?: unknown; runtime?: unknown }> }; message?: string };
63
- if (!response.ok || payload.ok === false) {
64
- throw new Error(payload.message ?? `agent list failed: ${response.status}`);
65
- }
66
- return (payload.data?.agents ?? [])
67
- .filter((agent) => typeof agent.agent_key === 'string' && typeof agent.display_name === 'string')
68
- .map((agent) => ({
69
- agent_key: String(agent.agent_key),
70
- display_name: String(agent.display_name),
71
- runtime: agent.runtime === null || typeof agent.runtime === 'string' ? agent.runtime : undefined,
72
- }));
73
- }
74
-
75
- async function onboardAgentForLark(flags: Record<string, unknown>, log: NonNullable<RunOptions['log']>, agent: string | undefined): Promise<boolean> {
76
- if (!flagBool(flags, 'create-agent')) return true;
77
- if (!agent) {
78
- (log.error ?? console.error)('lark setup --create-agent requires --agent');
79
- return false;
80
- }
81
- const serverUrl = flagString(flags, 'server') ?? defaultServerUrl();
82
- const displayName = flagString(flags, 'agent-name') ?? agent;
83
- const runtime = flagString(flags, 'runtime') ?? 'codex';
84
- const computerId = flagString(flags, 'computer-id');
85
- const result = await postJson(`${serverUrl.replace(/\/$/, '')}/api/agents/onboard`, {
86
- agent_key: agent,
87
- display_name: displayName,
88
- runtime,
89
- computer_id: computerId,
90
- });
91
- if (!result.ok) {
92
- (log.error ?? console.error)(`agent onboard failed (${result.status || 'network'}): ${result.text}`);
93
- return false;
94
- }
95
- (log.log ?? console.log)(`[lark setup] agent onboarded: ${agent}`);
96
- return true;
97
- }
98
-
99
- async function reloadLarkIntegration(flags: Record<string, unknown>, log: NonNullable<RunOptions['log']>): Promise<void> {
100
- if (flagBool(flags, 'no-reload')) return;
101
- const serverUrl = flagString(flags, 'server') ?? defaultServerUrl();
102
- const result = await postJson(`${serverUrl.replace(/\/$/, '')}/api/lark/reload`, {});
103
- if (result.ok) {
104
- (log.log ?? console.log)('[lark setup] server lark integration reloaded');
105
- } else {
106
- (log.warn ?? console.warn)(`[lark setup] saved config, but server reload failed (${result.status || 'network'}): ${result.text}`);
107
- }
108
- }
109
-
110
41
  export async function runLarkCli(options: RunOptions): Promise<number> {
111
42
  const { argv } = options;
112
43
  const log = options.log ?? {};
@@ -118,58 +49,8 @@ export async function runLarkCli(options: RunOptions): Promise<number> {
118
49
  }
119
50
 
120
51
  if (sub === 'setup') {
121
- const appId = flagString(argv.flags, 'app-id');
122
- const appSecret = flagString(argv.flags, 'app-secret');
123
- const label = flagString(argv.flags, 'label');
124
- const agent = flagString(argv.flags, 'agent');
125
- const path = flagString(argv.flags, 'config') ?? defaultLarkConfigPath();
126
- if ('agents' in argv.flags) {
127
- (log.error ?? console.error)('lark setup no longer supports --agents; bind one bot to one agent with --agent');
128
- return 2;
129
- }
130
- if (!appId && !appSecret) {
131
- const result = await runInteractiveLarkSetup({
132
- configPath: path,
133
- log: {
134
- log: log.log ?? console.log,
135
- warn: log.warn ?? console.warn,
136
- error: log.error ?? console.error,
137
- },
138
- ask: options.setupAsk,
139
- listAgents: () => listAgentsForSetup(flagString(argv.flags, 'server') ?? defaultServerUrl()),
140
- });
141
- if (result) await reloadLarkIntegration(argv.flags, log);
142
- return result ? 0 : 2;
143
- }
144
- if (!appId || !appSecret) {
145
- (log.error ?? console.error)('lark setup requires --app-id and --app-secret');
146
- return 2;
147
- }
148
- if (!(await onboardAgentForLark(argv.flags, log, agent))) return 2;
149
- const botInfo = await resolveLarkBotInfo(appId, appSecret);
150
- if (!botInfo.ok) {
151
- (log.error ?? console.error)(`lark setup could not resolve bot open_id (${botInfo.error}): ${botInfo.message}`);
152
- return 2;
153
- }
154
- const result = persistLarkCredential({
155
- appId,
156
- appSecret,
157
- label,
158
- agent,
159
- botOpenId: botInfo.openId,
160
- configPath: path,
161
- });
162
- if (result.replaced) {
163
- (log.warn ?? console.warn)(`[lark setup] overwrote existing credential for appId=${appId} in ${path}`);
164
- }
165
- if (flagBool(argv.flags, 'next-steps')) {
166
- (log.log ?? console.log)(formatLarkSetupNextSteps(result));
167
- await reloadLarkIntegration(argv.flags, log);
168
- return 0;
169
- }
170
- printJson(result, { log: log.log });
171
- await reloadLarkIntegration(argv.flags, log);
172
- return 0;
52
+ (log.error ?? console.error)('lark setup has been removed. Bind or rebind bots with "bun run console -- agents update --key <agent> --lark-app-id <id> --lark-app-secret <secret> [--rebind-lark]".');
53
+ return 2;
173
54
  }
174
55
 
175
56
  if (sub === 'list') {
@@ -223,17 +104,6 @@ function printLarkUsage(log: NonNullable<RunOptions['log']>): void {
223
104
  (log.log ?? console.log)(`pal lark <subcommand> [flags]
224
105
 
225
106
  Subcommands:
226
- setup [--app-id <id> --app-secret <secret>] [--label <name>] [--agent <agent-key>] [--config <path>] [--next-steps]
227
- [--create-agent --agent-name <name> --runtime codex --computer-id <machine>] [--server <url>] [--no-reload]
228
- Persist a (appId, appSecret) credential pair to ~/.pal/lark.json (0600).
229
- With no app-id/app-secret flags, starts an interactive setup wizard that
230
- validates credentials before writing the config.
231
- Overwrites if appId already present and logs a warning.
232
- Setup resolves the bot open_id through Feishu's bot info API before
233
- writing config. --agent binds this bot to a logical agent key. --create-agent creates or
234
- updates that agent through the Pal server before writing lark.json.
235
- By default setup asks the running server to reload Lark integration.
236
-
237
107
  list [--config <path>]
238
108
  List configured bots (secrets redacted).
239
109
 
@@ -281,7 +151,7 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
281
151
  }
282
152
  const store = loadLarkCredentials(configPath);
283
153
  if (store.bots.length === 0) {
284
- (log.error ?? console.error)(`No bots configured in ${configPath}. Run pal lark setup first.`);
154
+ (log.error ?? console.error)(`No bots configured in ${configPath}. Bind a bot with "bun run console -- agents update --key <agent> --lark-app-id <id> --lark-app-secret <secret>" first.`);
285
155
  return 2;
286
156
  }
287
157
  let targets = store.bots;
@@ -40,18 +40,51 @@ export function saveLarkCredentials(store: LarkCredentialStore, path: string = d
40
40
  export interface AddCredentialResult {
41
41
  store: LarkCredentialStore;
42
42
  replaced: boolean;
43
+ unbound: LarkCredential[];
43
44
  }
44
45
 
45
- export function upsertCredential(store: LarkCredentialStore, credential: LarkCredential): AddCredentialResult {
46
+ export function findCredentialByAgent(store: LarkCredentialStore, agent: string): LarkCredential | undefined {
47
+ const key = agent.trim();
48
+ if (!key) return undefined;
49
+ return store.bots.find((bot) => bot.agent?.trim() === key);
50
+ }
51
+
52
+ export function unbindCredentialAgent(store: LarkCredentialStore, agent: string): { store: LarkCredentialStore; changed: boolean; unbound?: LarkCredential } {
53
+ const key = agent.trim();
54
+ if (!key) throw new Error('agent is required');
55
+ const index = store.bots.findIndex((bot) => bot.agent?.trim() === key);
56
+ if (index === -1) return { store: { bots: [...store.bots] }, changed: false };
57
+ const next: LarkCredentialStore = { bots: [...store.bots] };
58
+ const existing = next.bots[index]!;
59
+ const { agent: _agent, ...withoutAgent } = existing;
60
+ next.bots[index] = withoutAgent;
61
+ return { store: next, changed: true, unbound: existing };
62
+ }
63
+
64
+ export function upsertCredential(store: LarkCredentialStore, credential: LarkCredential, options: { rebind?: boolean } = {}): AddCredentialResult {
46
65
  validateCredential(credential);
47
66
  const existingIndex = store.bots.findIndex((bot) => bot.appId === credential.appId);
48
67
  const next: LarkCredentialStore = { bots: [...store.bots] };
68
+ const unbound: LarkCredential[] = [];
69
+ const agent = credential.agent?.trim();
70
+ if (agent) {
71
+ const existingAgentIndex = next.bots.findIndex((bot, index) => index !== existingIndex && bot.agent?.trim() === agent);
72
+ if (existingAgentIndex !== -1) {
73
+ const existing = next.bots[existingAgentIndex]!;
74
+ if (!options.rebind) {
75
+ throw new Error(`agent ${agent} is already bound to Lark app ${existing.appId}; pass --rebind-lark to move the binding`);
76
+ }
77
+ const { agent: _agent, ...withoutAgent } = existing;
78
+ next.bots[existingAgentIndex] = withoutAgent;
79
+ unbound.push(existing);
80
+ }
81
+ }
49
82
  if (existingIndex === -1) {
50
83
  next.bots.push(credential);
51
- return { store: next, replaced: false };
84
+ return { store: next, replaced: false, unbound };
52
85
  }
53
86
  next.bots[existingIndex] = credential;
54
- return { store: next, replaced: true };
87
+ return { store: next, replaced: true, unbound };
55
88
  }
56
89
 
57
90
  export function findCredential(store: LarkCredentialStore, appId: string): LarkCredential | undefined {
@@ -96,7 +96,7 @@ export function mentionsAllAgents(envelope: LarkMessageEnvelope): boolean {
96
96
  if (isAllMention(mention)) return true;
97
97
  }
98
98
  const raw = parseLarkTextContent(envelope.message?.content, envelope.message?.message_type).toLowerCase();
99
- return /(^|\s)@(all|所有人)(\s|$)/u.test(raw);
99
+ return /(^|\s)@(_all|all|所有人)(\s|$)/u.test(raw);
100
100
  }
101
101
 
102
102
  function normalizeMappedMentions(values: Array<string | null>): string[] {
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
  }