Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import { OpenClawSessionNotFoundError } from '../services/openclaw/openclaw-http-client'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'

Expand Down Expand Up @@ -344,6 +345,29 @@ export function createOpenClawRoutes() {
}
})

.get('/session/:key/history', async (c) => {
const key = c.req.param('key')
const limitRaw = c.req.query('limit')
const cursor = c.req.query('cursor')
const limit =
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined

try {
Comment on lines +351 to +355
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 NaN forwarded to gateway when limit is non-numeric

Number.parseInt('abc', 10) returns NaN, and since NaN !== undefined, the check in buildHistoryPath passes the value through, producing ?limit=NaN in 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:

Suggested change
const cursor = c.req.query('cursor')
const limit =
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined
try {
const limit =
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined
if (limit !== undefined && !Number.isFinite(limit)) {
return c.json({ error: 'invalid_limit' }, 400)
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Line: 351-355

Comment:
**`NaN` forwarded to gateway when `limit` is non-numeric**

`Number.parseInt('abc', 10)` returns `NaN`, and since `NaN !== undefined`, the check in `buildHistoryPath` passes the value through, producing `?limit=NaN` in 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:

```suggestion
      const limit =
        limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined
      if (limit !== undefined && !Number.isFinite(limit)) {
        return c.json({ error: 'invalid_limit' }, 400)
      }
```

How can I resolve this? If you propose a fix, please make it concise.

const history = await getOpenClawService().getSessionHistory(key, {
limit,
cursor,
signal: c.req.raw.signal,
})
return c.json(history)
} catch (err) {
if (err instanceof OpenClawSessionNotFoundError) {
return c.json({ error: 'session_not_found' }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})

.get('/logs', async (c) => {
try {
const logs = await getOpenClawService().getLogs()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,12 @@ export class OpenClawCliClient {
args.push('--non-interactive', '--json')
await this.runCommand(args)

const agents = await this.listAgents()
const agent = agents.find((entry) => entry.agentId === input.name)
if (!agent) {
throw new Error(`Created agent ${input.name} was not found in agent list`)
return {
agentId: input.name,
name: input.name,
workspace,
model: input.model,
}

return agent
}

async deleteAgent(agentId: string): Promise<void> {
Expand Down
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
}
Loading
Loading