🔨 chore: add headless approval and apiKey WS auth to lh agent run (#13819)

 feat: add headless approval and apiKey ws auth to `lh agent run`

Two fixes so `lh agent run` works end-to-end against the WebSocket agent
gateway when the user is authenticated via LOBEHUB_CLI_API_KEY.

- Default to `userInterventionConfig: { approvalMode: 'headless' }` when
  running the agent from the CLI. Without this flag the runtime waits
  for human tool-call approval and local-device commands hang forever.
  Users who want interactive approval can pass `--no-headless`.
- Pass `tokenType` (`jwt` | `apiKey`) in the WebSocket auth handshake so
  the gateway knows how to verify the token. Previously the CLI sent
  only the raw token value and the gateway assumed JWT, rejecting valid
  API keys.

Fixes LOBE-6939

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-14 23:28:01 +08:00 committed by GitHub
parent d6f11f80b6
commit f6081c9914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 78 additions and 8 deletions

View file

@ -37,7 +37,25 @@ export async function getAuthInfo(): Promise<AuthInfo> {
};
}
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
export type AgentStreamTokenType = 'jwt' | 'apiKey';
export interface AgentStreamAuthInfo {
headers: Record<string, string>;
serverUrl: string;
/**
* Raw token value (without header prefix). Used for WebSocket auth messages
* where header-based auth is not available.
*/
token: string;
/**
* How the token should be verified by downstream services (agent gateway WS).
* jwt validate with JWKS
* apiKey validate by calling /api/v1/users/me
*/
tokenType: AgentStreamTokenType;
}
export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
const serverUrl = resolveServerUrl();
const envJwt = process.env.LOBEHUB_JWT;
@ -45,6 +63,8 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
return {
headers: { 'Oidc-Auth': envJwt },
serverUrl,
token: envJwt,
tokenType: 'jwt',
};
}
@ -53,6 +73,8 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
return {
headers: { 'X-API-Key': envApiKey },
serverUrl,
token: envApiKey,
tokenType: 'apiKey',
};
}
@ -64,11 +86,15 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
return {
headers: {},
serverUrl,
token: '',
tokenType: 'jwt',
};
}
return {
headers: { 'Oidc-Auth': result.credentials.accessToken },
serverUrl,
token: result.credentials.accessToken,
tokenType: 'jwt',
};
}

View file

@ -258,6 +258,10 @@ export function registerAgentCommand(program: Command) {
'--device <target>',
'Target device ID, or use "local" for the current connected device',
)
.option(
'--no-headless',
"Disable headless mode and wait for human approval on tool calls (default: headless — tools auto-run, matching the CLI's non-interactive nature)",
)
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
@ -267,6 +271,7 @@ export function registerAgentCommand(program: Command) {
agentId?: string;
autoStart?: boolean;
device?: string;
headless?: boolean;
json?: boolean;
prompt?: string;
replay?: string;
@ -340,6 +345,11 @@ export function registerAgentCommand(program: Command) {
if (options.slug) input.slug = options.slug;
if (options.topicId) input.appContext = { topicId: options.topicId };
if (options.autoStart === false) input.autoStart = false;
// commander's --no-headless sets `headless` to false. Anything else
// (undefined, true) → headless mode is on and tool calls auto-execute.
if (options.headless !== false) {
input.userInterventionConfig = { approvalMode: 'headless' };
}
const result = await client.aiAgent.execAgent.mutate(input as any);
const r = result as any;
@ -355,16 +365,16 @@ export function registerAgentCommand(program: Command) {
}
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
const { serverUrl, headers } = await getAgentStreamAuthInfo();
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
if (agentGatewayUrl) {
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
token,
tokenType,
verbose: options.verbose,
});
} else {

View file

@ -280,7 +280,7 @@ describe('streamAgentEventsViaWebSocket', () => {
const ws = capturedWs!;
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
{ token: 'test-token', type: 'auth' },
{ token: 'test-token', tokenType: 'jwt', type: 'auth' },
{ lastEventId: '', type: 'resume' },
]);
@ -288,6 +288,27 @@ describe('streamAgentEventsViaWebSocket', () => {
await promise;
});
it('should send tokenType=apiKey when the caller uses an API key', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'lh_sk_abc',
tokenType: 'apiKey',
});
await flush();
const ws = capturedWs!;
expect(ws.sent.map((s) => JSON.parse(s))[0]).toEqual({
token: 'lh_sk_abc',
tokenType: 'apiKey',
type: 'auth',
});
ws.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should render agent_event messages using existing renderEvent', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',

View file

@ -21,6 +21,11 @@ interface WebSocketStreamOptions extends StreamOptions {
gatewayUrl: string;
operationId: string;
token: string;
/**
* How the gateway should verify `token`. `jwt` is the default for
* backwards compatibility with existing callers.
*/
tokenType?: 'jwt' | 'apiKey';
}
/**
@ -168,13 +173,13 @@ const HEARTBEAT_INTERVAL = 30_000;
export async function streamAgentEventsViaWebSocket(
options: WebSocketStreamOptions,
): Promise<void> {
const { gatewayUrl, operationId, token, ...streamOpts } = options;
const { gatewayUrl, operationId, token, tokenType = 'jwt', ...streamOpts } = options;
const wsUrl = urlJoin(
gatewayUrl.replace(/^http/, 'ws'),
`/ws?operationId=${encodeURIComponent(operationId)}`,
);
log.debug(`Connecting to gateway: ${wsUrl}`);
log.debug(`Connecting to gateway: ${wsUrl} (auth: ${tokenType})`);
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsUrl);
@ -192,7 +197,7 @@ export async function streamAgentEventsViaWebSocket(
};
ws.onopen = () => {
ws.send(JSON.stringify({ token, type: 'auth' }));
ws.send(JSON.stringify({ token, tokenType, type: 'auth' }));
};
ws.onmessage = (event) => {

View file

@ -1,7 +1,7 @@
import { type AgentRuntimeContext } from '@lobechat/agent-runtime';
import { parse } from '@lobechat/conversation-flow';
import { type TaskCurrentActivity, type TaskStatusResult } from '@lobechat/types';
import { ThreadStatus, ThreadType } from '@lobechat/types';
import { ThreadStatus, ThreadType, UserInterventionConfigSchema } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import pMap from 'p-map';
@ -109,6 +109,12 @@ const ExecAgentSchema = z
prompt: z.string(),
/** The agent slug to run (either agentId or slug is required) */
slug: z.string().optional(),
/**
* User intervention configuration for tool approvals.
* Pass `{ approvalMode: 'headless' }` from headless clients (CLI, cron, bots)
* so tool calls auto-execute without waiting for human approval.
*/
userInterventionConfig: UserInterventionConfigSchema.optional(),
})
.refine((data) => data.agentId || data.slug, {
message: 'Either agentId or slug must be provided',
@ -541,6 +547,7 @@ export const aiAgentRouter = router({
existingMessageIds = [],
fileIds,
parentMessageId,
userInterventionConfig,
} = input;
log('execAgent: identifier=%s, prompt=%s', agentId || slug, prompt.slice(0, 50));
@ -559,6 +566,7 @@ export const aiAgentRouter = router({
// When parentMessageId is provided, this is a regeneration/continue — skip user message creation
resume: !!parentMessageId,
slug,
userInterventionConfig,
});
} catch (error: any) {
console.error('execAgent failed: %O', error);