blob: 8a36517f08c8f507465aeecebfa172843f51b4d0 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { isAbsolute } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
export class FeishuCliError extends Error {
constructor(readonly code: string | number) {
super(`Feishu CLI read failed (${code}); check login, permissions or source availability`);
}
}
const exec = promisify(execFile);
export type CliConfig = {
cliPath: string;
accountId: string;
appId: string;
cliProfile?: string;
};
export type CliRun = (args: string[], signal?: AbortSignal) => Promise<any>;
/** Credentials stay in the official CLI/keychain. This transport never accepts model-supplied commands. */
export function createCli(
config: CliConfig,
run?: CliRun,
pause = (ms: number, signal?: AbortSignal) => delay(ms, undefined, { signal }),
) {
if (!isAbsolute(config.cliPath) || !config.accountId || !config.appId)
throw Error('CLI requires absolute cliPath, pinned accountId and appId');
const execute: CliRun =
run ??
(async (args, signal) => {
try {
const { stdout } = await exec(config.cliPath, args, {
signal,
maxBuffer: 16 * 1024 * 1024,
timeout: 120000,
env: {
...process.env,
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1',
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1',
},
});
return JSON.parse(stdout);
} catch (error: any) {
if (signal?.aborted) throw signal.reason;
// Never propagate CLI stderr: OAuth diagnostics can contain credential-bearing links.
let detail: any;
for (const stream of [error.stderr, error.stdout]) {
if (typeof stream !== 'string') continue;
const at = stream.indexOf('{');
if (at < 0) continue;
try {
detail = JSON.parse(stream.slice(at)).error;
if (detail) break;
} catch {}
}
const code = error.killed
? 'command_timeout'
: (detail?.code ?? error.code ?? 'invalid_response');
throw new FeishuCliError(/^[A-Za-z0-9_]+$/.test(String(code)) ? code : 'invalid_response');
}
});
const invoke: CliRun = async (args, signal) => {
signal?.throwIfAborted();
try {
return await execute(
[...args, ...(config.cliProfile ? ['--profile', config.cliProfile] : [])],
signal,
);
} catch (error: any) {
if (signal?.aborted) throw signal.reason;
if (error instanceof FeishuCliError) throw error;
const code = error.killed ? 'command_timeout' : (error.code ?? 'invalid_response');
throw new FeishuCliError(/^[A-Za-z0-9_]+$/.test(String(code)) ? code : 'invalid_response');
}
};
const transient = (error: unknown) =>
error instanceof FeishuCliError &&
['invalid_response', 'command_timeout', 'EAGAIN', 'EMFILE', 'ENFILE'].includes(
String(error.code),
);
const verified = new WeakMap<object, Promise<void>>();
async function verify(caller: any) {
const invocation = caller.invocation;
if (!invocation) throw Error('Source requires invocation');
invocation.abortSignal?.throwIfAborted();
let pending = verified.get(invocation);
if (!pending) {
pending = (async () => {
let status: any;
for (let retry = 0; ; retry++) {
try {
status = await invoke(['auth', 'status', '--json'], invocation.abortSignal);
if (status?.error) throw new FeishuCliError(status.error.code ?? 'invalid_response');
if (!status || typeof status !== 'object') throw new FeishuCliError('invalid_response');
break;
} catch (error) {
invocation.abortSignal?.throwIfAborted();
if (!transient(error) || retry >= 2) throw error;
await pause(250 * 2 ** retry, invocation.abortSignal);
}
}
// The CLI refreshes an expired access token on the next API read. Keep the
// pinned identity checks; only a refreshable login may proceed.
if (
status.appId !== config.appId ||
status.identities?.user?.openId !== config.accountId ||
!['ready', 'needs_refresh'].includes(status.identities?.user?.status)
)
throw Error('Feishu CLI account changed or logged out; source access denied');
})();
verified.set(invocation, pending);
}
try {
await pending;
} catch (error) {
if (verified.get(invocation) === pending) verified.delete(invocation);
throw error;
}
}
const safe = new Set([
'im +chat-list',
'im +messages-search',
'drive +search',
'drive +inspect',
'docs +fetch',
'task tasks get',
'task tasks list',
'calendar +agenda',
'calendar +get',
'calendar calendars list',
'wiki spaces list',
'minutes +search',
'minutes +detail',
'mail +triage',
'mail +message',
]);
let nextRequestAt = 0;
async function call(args: string[], caller: any) {
const raw =
args[0] === 'api' &&
args[1] === 'GET' &&
/^\/open-apis\/(?:docx\/v1\/documents\/[A-Za-z0-9_-]+|im\/v1\/(?:chats|messages(?:\/[A-Za-z0-9_-]+)?))$/.test(
args[2],
);
if (
!(
raw ||
(args[0] === 'api' && args[1] === 'POST' && args[2] === '/open-apis/im/v1/messages/search')
) &&
![args.slice(0, 2).join(' '), args.slice(0, 3).join(' ')].some((x) => safe.has(x))
)
throw Error('CLI command is not in the read-only allowlist');
await verify(caller);
const signal = caller.invocation.abortSignal;
let transientRetries = 0,
rateRetries = 0;
for (;;) {
// Shared across sources and invocations: parallel metadata reads cannot create an unbounded burst.
const slot = Math.max(Date.now(), nextRequestAt);
nextRequestAt = slot + 250;
if (slot > Date.now()) await pause(slot - Date.now(), signal);
signal?.throwIfAborted();
try {
const result = await invoke([...args, '--as', 'user', '--format', 'json'], signal);
if (!result?.ok) throw new FeishuCliError(result?.error?.code ?? 'invalid_response');
if (result.identity !== 'user')
throw Error('Feishu CLI did not return a successful user read');
return result.data;
} catch (error) {
signal?.throwIfAborted();
if (transient(error)) {
if (transientRetries >= 2) throw error;
await pause(250 * 2 ** transientRetries++, signal);
continue;
}
if (
!(error instanceof FeishuCliError) ||
!['99991400', '429'].includes(String(error.code)) ||
rateRetries >= 3
)
throw error;
const backoff = 1000 * 2 ** rateRetries++;
nextRequestAt = Math.max(nextRequestAt, Date.now() + backoff);
await pause(backoff, signal);
}
}
}
return { call, verify };
}