-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: add OpenClaw loopback HTTP fast path #793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Nikhil (shadowfax92)
wants to merge
6
commits into
dev
Choose a base branch
from
feat/add-openclaw-ws
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
02962d2
feat(openclaw): add loopback http client for admin reads
shadowfax92 5ec9c71
feat(openclaw): fetch session history over http with sse streaming
shadowfax92 48711db
feat(openclaw): loopback mode=none + http admin reads + session histo…
shadowfax92 db113dc
chore: self-review fixes
shadowfax92 0684219
refactor(openclaw): keep session history json-only
shadowfax92 5e350ae
fix(openclaw): restore token-backed gateway auth
shadowfax92 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
224 changes: 224 additions & 0 deletions
224
packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-http-client.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 BrowserOS | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| import type { OpenClawAgentRecord } from './openclaw-cli-client' | ||
|
|
||
| export interface OpenClawSessionSummary { | ||
| key: string | ||
| agentId?: string | ||
| model?: string | ||
| kind?: string | ||
| updatedAt?: number | ||
| messageCount?: number | ||
| [extra: string]: unknown | ||
| } | ||
|
|
||
| export interface OpenClawListSessionsInput { | ||
| limit?: number | ||
| activeMinutes?: number | ||
| kinds?: string[] | ||
| signal?: AbortSignal | ||
| } | ||
|
|
||
| export interface OpenClawSessionHistoryMessage { | ||
| role: 'user' | 'assistant' | 'system' | 'tool' | ||
| content: string | ||
| messageId?: string | ||
| messageSeq?: number | ||
| timestamp?: number | ||
| } | ||
|
|
||
| export interface OpenClawSessionHistory { | ||
| sessionKey: string | ||
| messages: OpenClawSessionHistoryMessage[] | ||
| cursor?: string | null | ||
| hasMore?: boolean | ||
| truncated?: boolean | ||
| } | ||
|
|
||
| export class OpenClawSessionNotFoundError extends Error { | ||
| constructor(readonly sessionKey: string) { | ||
| super(`OpenClaw session not found: ${sessionKey}`) | ||
| this.name = 'OpenClawSessionNotFoundError' | ||
| } | ||
| } | ||
|
|
||
| type RawAgentRow = { | ||
| id: string | ||
| name?: string | ||
| workspace?: string | ||
| model?: string | ||
| } | ||
|
|
||
| type AgentsListResult = RawAgentRow[] | { agents?: RawAgentRow[] } | ||
|
|
||
| type ToolResponse<T> = | ||
| | { ok: true; result: T } | ||
| | { ok: false; error?: { message?: string } } | ||
|
|
||
| export class OpenClawHttpClient { | ||
| constructor( | ||
| private readonly hostPort: number, | ||
| private readonly getToken: () => Promise<string>, | ||
| ) {} | ||
|
|
||
| async probe(signal?: AbortSignal): Promise<void> { | ||
| const response = await this.request('/v1/models', { | ||
| method: 'GET', | ||
| signal, | ||
| }) | ||
| if (!response.ok) { | ||
| throw await toError(response, 'OpenClaw probe failed') | ||
| } | ||
| } | ||
|
|
||
| async listAgents(signal?: AbortSignal): Promise<OpenClawAgentRecord[]> { | ||
| const result = await this.invokeTool<AgentsListResult>( | ||
| 'agents_list', | ||
| {}, | ||
| signal, | ||
| ) | ||
| const rows = Array.isArray(result) ? result : (result.agents ?? []) | ||
| return rows.map((row) => ({ | ||
| agentId: row.id, | ||
| name: row.name ?? row.id, | ||
| workspace: row.workspace ?? '', | ||
| model: row.model, | ||
| })) | ||
| } | ||
|
|
||
| async listSessions( | ||
| input: OpenClawListSessionsInput = {}, | ||
| ): Promise<OpenClawSessionSummary[]> { | ||
| const args: Record<string, unknown> = {} | ||
| if (input.limit !== undefined) { | ||
| args.limit = input.limit | ||
| } | ||
| if (input.activeMinutes !== undefined) { | ||
| args.activeMinutes = input.activeMinutes | ||
| } | ||
| if (input.kinds !== undefined) { | ||
| args.kinds = input.kinds | ||
| } | ||
|
|
||
| return this.invokeTool<OpenClawSessionSummary[]>( | ||
| 'sessions_list', | ||
| args, | ||
| input.signal, | ||
| ) | ||
| } | ||
|
|
||
| async getSessionHistory( | ||
| sessionKey: string, | ||
| input: { | ||
| limit?: number | ||
| cursor?: string | ||
| signal?: AbortSignal | ||
| } = {}, | ||
| ): Promise<OpenClawSessionHistory> { | ||
| const response = await this.request( | ||
| this.buildHistoryPath(sessionKey, input), | ||
| { | ||
| method: 'GET', | ||
| signal: input.signal, | ||
| }, | ||
| ) | ||
|
|
||
| if (response.status === 404) { | ||
| throw new OpenClawSessionNotFoundError(sessionKey) | ||
| } | ||
| if (!response.ok) { | ||
| throw await toError(response, 'OpenClaw session history failed') | ||
| } | ||
|
|
||
| return (await response.json()) as OpenClawSessionHistory | ||
| } | ||
|
|
||
| protected async request(path: string, init: RequestInit): Promise<Response> { | ||
| const token = await this.getToken() | ||
| const headers = new Headers(init.headers) | ||
| headers.set('Authorization', `Bearer ${token}`) | ||
|
|
||
| return fetch(`http://127.0.0.1:${this.hostPort}${path}`, { | ||
| ...init, | ||
| headers, | ||
| }) | ||
| } | ||
|
|
||
| private async invokeTool<T>( | ||
| tool: string, | ||
| args: Record<string, unknown>, | ||
| signal?: AbortSignal, | ||
| ): Promise<T> { | ||
| const response = await this.request('/tools/invoke', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ tool, args }), | ||
| signal, | ||
| }) | ||
|
|
||
| if (!response.ok) { | ||
| throw await toError(response, `OpenClaw tool '${tool}' failed`) | ||
| } | ||
|
|
||
| const body = (await response.json()) as ToolResponse<T> | ||
| if (!body.ok) { | ||
| throw new Error(body.error?.message ?? `OpenClaw tool '${tool}' failed`) | ||
| } | ||
|
|
||
| return body.result | ||
| } | ||
|
|
||
| private buildHistoryPath( | ||
| sessionKey: string, | ||
| input: { limit?: number; cursor?: string }, | ||
| ): string { | ||
| const params = new URLSearchParams() | ||
| if (input.limit !== undefined) { | ||
| params.set('limit', String(Number(input.limit))) | ||
| } | ||
| if (input.cursor !== undefined) { | ||
| params.set('cursor', input.cursor) | ||
| } | ||
| const query = params.toString() | ||
| const suffix = query ? `?${query}` : '' | ||
| return `/sessions/${encodeURIComponent(sessionKey)}/history${suffix}` | ||
| } | ||
| } | ||
|
|
||
| async function toError(response: Response, fallback: string): Promise<Error> { | ||
| const detail = await readErrorDetail(response) | ||
| return new Error(detail || `${fallback} (HTTP ${response.status})`) | ||
| } | ||
|
|
||
| async function readErrorDetail(response: Response): Promise<string> { | ||
| const detail = await response.text().catch(() => '') | ||
| if (!detail) { | ||
| return '' | ||
| } | ||
|
|
||
| try { | ||
| return extractErrorMessage(JSON.parse(detail)) ?? detail | ||
| } catch { | ||
| return detail | ||
| } | ||
| } | ||
|
|
||
| function extractErrorMessage(value: unknown): string | null { | ||
| if (!value || typeof value !== 'object') { | ||
| return null | ||
| } | ||
|
|
||
| const message = (value as { message?: unknown }).message | ||
| if (typeof message === 'string' && message) { | ||
| return message | ||
| } | ||
|
|
||
| const error = (value as { error?: { message?: unknown } }).error | ||
| return typeof error?.message === 'string' && error.message | ||
| ? error.message | ||
| : null | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NaNforwarded to gateway whenlimitis non-numericNumber.parseInt('abc', 10)returnsNaN, and sinceNaN !== undefined, the check inbuildHistoryPathpasses the value through, producing?limit=NaNin the upstream URL. Depending on gateway behaviour this could result in a 400, unintended unbounded reads, or the param being silently ignored — none of which match the caller's intent.Add a guard after parsing:
Prompt To Fix With AI