Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
20b83ba
feat: add conversation-scoped runtime APIs and clients
openhands-agent Sep 12, 2026
f638e0e
fix(ci): recognize upstream approved LLM.modify_params removal
openhands-agent Sep 12, 2026
954892d
refactor(client): bind runtime services to explicit conversation owne…
openhands-agent Sep 12, 2026
29184d8
fix(client): keep runtime URL parameters within public type budget
openhands-agent Sep 12, 2026
c12cf0a
Merge main into conversation runtime API foundation
openhands-agent Sep 12, 2026
584aa4a
feat: expose runtime lifecycle contracts independently of Docker
openhands-agent Sep 13, 2026
f64e34f
chore: keep runtime lifecycle tests focused
openhands-agent Sep 13, 2026
ae05f6e
Merge branch 'factory/restack-lifecycle' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
ad08608
style: format extracted runtime lifecycle model
openhands-agent Sep 13, 2026
b8e0d3c
ci: use the native stack base instead of temporary branch filters
openhands-agent Sep 13, 2026
c3d8db9
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
d5719d4
docs: capture live Canvas evidence for #4966
openhands-agent Sep 13, 2026
86695aa
chore: Remove PR-only artifacts [automated]
Sep 13, 2026
2805ce4
refactor(agent-server): keep runtime contracts independent of Docker
openhands-agent Sep 13, 2026
3808768
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
4244f91
refactor(client): scope existing clients without runtime facades
openhands-agent Sep 14, 2026
3600b6d
fix(api): type download schemas on their owning routes
openhands-agent Sep 14, 2026
cd064c0
fix(runtime): limit process cleanup to scoped services
openhands-agent Sep 14, 2026
65911c8
docs: add live narrowed-client evidence
openhands-agent Sep 14, 2026
97e664a
refactor(runtime): keep route reuse compatibility explicit
openhands-agent Sep 14, 2026
9fd0983
refactor(runtime): remove unused scoped contracts
openhands-agent Sep 14, 2026
8152c39
refactor(client): rely on the compiled client contract
openhands-agent Sep 14, 2026
4b75798
docs: align runtime evidence with narrowed scope
openhands-agent Sep 14, 2026
72a74b3
docs(client): explain optional conversation ownership
openhands-agent Sep 14, 2026
c39586e
fix(runtime): keep host bash teardown unchanged
openhands-agent Sep 14, 2026
8182852
refactor(runtime): name conversation ownership explicitly
openhands-agent Sep 14, 2026
01398e7
refactor(runtime): make bash lifecycle intrinsic
openhands-agent Sep 14, 2026
1e1a610
chore: Remove PR-only artifacts [automated]
Sep 14, 2026
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
23 changes: 23 additions & 0 deletions .pr/sdk4966-narrow-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Live Canvas evidence for the narrowed runtime client

An isolated Agent Canvas used the #4966 TypeScript client to run a real
DeepSeek V4 Flash conversation against a local agent server. The agent ran
`pwd`, wrote `runtime-proof.txt`, read it back, and reported the exact contents.
Captured network responses show all three shell operations used
`/api/conversations/{conversation_id}/bash/execute_bash_command` with status
200. The workspace path also contained that conversation ID.

[Animated recording](live-canvas.gif)

The client was #4966 at `4244f91f0`. Later commits retain the demonstrated
Bash route while tightening cleanup and removing unused scoped contracts.
Canvas additionally needed its existing browser event-stream client commits
`dd056163b` and `2e59a53d1`; these are companion client functionality, not part
of #4966. Canvas was `e895bb22`. Automation was present only to serve the
isolated UI and had no schedules or runs. The stack permitted one worker and
was guarded against less than 3 GiB available memory.

This demonstrates the local runtime route through the actual UI and model. The
existing Docker recording linked from the PR description covers Docker routing;
the current descendant passed 82 focused runtime, mediation, proxy, and
server-info tests after this simplification.
Binary file added .pr/sdk4966-narrow-client/live-canvas.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions clients/typescript/src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2285,6 +2285,8 @@ describe('Auxiliary API clients', () => {
await client.respondToConfirmation('c1', { accept: true });
await client.deleteConversation('c1');
await client.updateConversation('c1', { title: 'New title' });
await client.getRuntime('c1');
await client.reprovisionRuntime('c1');

expect(global.fetch).toHaveBeenNthCalledWith(
1,
Expand Down Expand Up @@ -2314,6 +2316,16 @@ describe('Auxiliary API clients', () => {
'http://example.com/api/conversations/c1',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'New title' }) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
9,
'http://example.com/api/conversations/c1/runtime',
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
10,
'http://example.com/api/conversations/c1/runtime/reprovision',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
});

it('ConversationClient wraps SDK v1.23.0 conversation endpoints', async () => {
Expand Down
170 changes: 170 additions & 0 deletions clients/typescript/src/__tests__/conversation-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { ConversationManager } from '../conversation/conversation-manager';
import { FileClient } from '../client/file-client';
import { HttpClient } from '../client/http-client';
import { BashClient } from '../client/bash-client';
import type { AgentBase } from '../types/base';
import { RemoteWorkspace } from '../workspace/remote-workspace';

describe('conversation-scoped requests', () => {
let server: Server;
let host: string;
const urls: string[] = [];
let scoped = false;
let discoveryStatus = 200;
beforeAll(async () => {
server = createServer((req, res) => {
urls.push(req.url!);

Check warning on line 18 in clients/typescript/src/__tests__/conversation-scope.test.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Forbidden non-null assertion

Check warning on line 18 in clients/typescript/src/__tests__/conversation-scope.test.ts

View workflow job for this annotation

GitHub Actions / test (22.12)

Forbidden non-null assertion
if (req.method === 'DELETE' && req.url === '/api/auth/workspace-session') {
res.writeHead(204).end();
return;
}
if (req.url === '/server_info') {
res.statusCode = discoveryStatus;
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
version: '1.47.0',
capabilities: scoped ? ['conversation_runtime_routes_v1'] : [],
})
);
return;
}
if (
req.url === '/api/conversations' ||
req.url === '/api/conversations/created' ||
req.url === '/api/conversations/created/fork'
) {
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
id: req.url.endsWith('/fork') ? 'forked' : 'created',
agent: { kind: 'Agent' },
workspace: { working_dir: '/workspace' },
})
);
return;
}
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ exit_code: 0, stdout: 'ok', stderr: '' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
urls.length = 0;
scoped = true;
discoveryStatus = 200;
});
it.each([false, true])('routes workspace commands with scoped support=%s', async (supported) => {
scoped = supported;
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'selected',
});
const result = await workspace.executeCommand('pwd');
expect(result.stdout).toBe('ok');
expect(urls.pop()).toBe(
supported
? '/api/conversations/selected/bash/execute_bash_command'
: '/api/bash/execute_bash_command?cid=selected'
);
});
it('scopes workspace operations while keeping setup operations global', async () => {
const options = { host, conversationId: 'selected' };
const files = new FileClient(options);
await files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/conversations/selected/file/download?path=%2Fworkspace%2Fa');
await files.getHome();
expect(urls.pop()).toBe('/api/file/home');
await new BashClient(options).executeCommand({ command: 'pwd' });
expect(urls.pop()).toBe('/api/conversations/selected/bash/execute_bash_command');
await new RemoteWorkspace({ ...options, workingDir: '/workspace' }).gitChanges('/workspace');
expect(urls.pop()).toBe('/api/conversations/selected/git/changes?path=%2Fworkspace');
});
it('rejects requests that override the selected conversation', async () => {
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'selected',
});
await expect(
workspace.client.get('/api/file/download', { params: { cid: 'other' } })
).rejects.toThrow('cannot be overridden');
await expect(workspace.client.get('/api/file/../../settings')).rejects.toThrow('API path');
expect(
() => new RemoteWorkspace({ host, workingDir: '/workspace', conversationId: '' })
).toThrow('conversation ID');
expect(urls).toEqual([]);
});
it('preserves unscoped clients without capability discovery', async () => {
await new HttpClient({ baseUrl: host }).get('/api/file/download', {
params: { cid: 'explicit' },
});
await new FileClient({ host }).downloadFile('/workspace/a');
await new RemoteWorkspace({ host, workingDir: '/workspace' }).executeCommand('pwd');
expect(urls).toEqual([
'/api/file/download?cid=explicit',
'/api/file/download?path=%2Fworkspace%2Fa',
'/api/bash/execute_bash_command',
]);
});
it('reuses existing discovery for concurrent requests on a client', async () => {
const files = new FileClient({ host, conversationId: 'selected' });
await Promise.all([files.downloadFile('/workspace/a'), files.downloadFile('/workspace/b')]);
expect(urls.filter((url) => url === '/server_info')).toHaveLength(1);
expect(urls).toContain('/api/conversations/selected/file/download?path=%2Fworkspace%2Fa');
expect(urls).toContain('/api/conversations/selected/file/download?path=%2Fworkspace%2Fb');
});
it('retries failed discovery without silently downgrading', async () => {
discoveryStatus = 503;
const files = new FileClient({ host, conversationId: 'retry' });
await expect(files.downloadFile('/workspace/a')).rejects.toMatchObject({ status: 503 });
expect(urls).toEqual(['/server_info']);
discoveryStatus = 200;
await files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/conversations/retry/file/download?path=%2Fworkspace%2Fa');
});
it('supports older servers without server info', async () => {
discoveryStatus = 404;
const files = new FileClient({ host, conversationId: 'selected' });
await files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/file/download?path=%2Fworkspace%2Fa&cid=selected');
});
it('keeps workspace session authentication global and identity fixed', async () => {
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'preview',
});
expect(await workspace.startWorkspaceSession('preview')).toBe(
`${host}/api/conversations/preview/workspace/`
);
expect(urls.pop()).toBe('/api/auth/workspace-session');
await expect(workspace.startWorkspaceSession('other')).rejects.toThrow('selected runtime');
await workspace.deleteWorkspaceSession();
expect(urls.pop()).toBe('/api/auth/workspace-session');
});
it('binds created, loaded and forked workspaces to their conversations', async () => {
const manager = new ConversationManager({ host });
const created = await manager.createConversation({ kind: 'Agent' } as AgentBase, {
workingDir: '/workspace',
});
await created.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
const loaded = await manager.loadConversation('created', '/workspace');
await loaded.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
const forked = await created.fork();
await forked.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/forked/bash/execute_bash_command');
await created.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
});
});
18 changes: 7 additions & 11 deletions clients/typescript/src/client/bash-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { HttpClient, HttpError } from './http-client';
import { HttpError } from './http-client';
import { createRuntimeHttpClients } from './runtime-transport';
import type { RuntimeServiceClientOptions } from './runtime-transport';
import type { HttpClient } from './http-client';
import {
BashCommand,
BashEvent,
Expand All @@ -9,25 +12,18 @@ import {
ExecuteBashRequest,
} from '../models/workspace';

export interface BashClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}
export type BashClientOptions = RuntimeServiceClientOptions;

export class BashClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

constructor(options: BashClientOptions) {
const { runtimeClient } = createRuntimeHttpClients(options);
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
this.client = runtimeClient;
}

async searchEvents(options: BashEventSearchOptions = {}): Promise<BashEventPage> {
Expand Down
16 changes: 16 additions & 0 deletions clients/typescript/src/client/conversation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ConversationEventPage,
ConversationEventSearchOptions,
ConversationInfo,
ConversationRuntimeInfo,
ConversationSearchRequest,
ConversationSearchResponse,
ForkConversationRequest,
Expand Down Expand Up @@ -108,6 +109,21 @@ export class ConversationClient {
return response.data;
}

async getRuntime(conversationId: string): Promise<ConversationRuntimeInfo> {
const response = await this.client.get<ConversationRuntimeInfo>(
`/api/conversations/${conversationId}/runtime`
);
return response.data;
}

async reprovisionRuntime(conversationId: string): Promise<ConversationRuntimeInfo> {
const response = await this.client.post<ConversationRuntimeInfo>(
`/api/conversations/${conversationId}/runtime/reprovision`,
{}
);
return response.data;
}

async searchEvents(
conversationId: string,
options: ConversationEventSearchOptions = {}
Expand Down
17 changes: 6 additions & 11 deletions clients/typescript/src/client/desktop-client.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { HttpClient } from './http-client';
import { createRuntimeHttpClients } from './runtime-transport';
import type { RuntimeServiceClientOptions } from './runtime-transport';
import type { HttpClient } from './http-client';
import { DesktopUrlResponse } from '../models/api';

export interface DesktopClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}
export type DesktopClientOptions = RuntimeServiceClientOptions;

export class DesktopClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

constructor(options: DesktopClientOptions) {
const { runtimeClient } = createRuntimeHttpClients(options);
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
this.client = runtimeClient;
}

async getUrl(baseUrl?: string): Promise<string | null> {
Expand Down
Loading
Loading