diff --git a/packages/orbis-agent-backend/src/backend.ts b/packages/orbis-agent-backend/src/backend.ts index a9ddb8c..efecdaf 100644 --- a/packages/orbis-agent-backend/src/backend.ts +++ b/packages/orbis-agent-backend/src/backend.ts @@ -126,6 +126,12 @@ export interface AgentWorkspaceRegisterInput { readonly folderRef: string; } +export interface AgentWorkspaceCreateFolderInput { + readonly driverId: AgentDriverId; + readonly name: string; + readonly parentFolderRef: string; +} + export interface AgentWorkspaceRegisterResult { readonly created: boolean; readonly workspace: AgentWorkspaceDescriptor; @@ -219,6 +225,9 @@ export interface AgentBackend { close(): Promise; browseWorkspaceFolders(input: AgentWorkspaceBrowseInput): Promise; + createWorkspaceFolder( + input: AgentWorkspaceCreateFolderInput, + ): Promise; connectRuntime(ref: AgentSessionRef): Promise; createSession(input: AgentSessionCreateInput): Promise; listDrivers(): Promise; diff --git a/packages/orbis-agent-backend/src/index.ts b/packages/orbis-agent-backend/src/index.ts index c5914ab..d322449 100644 --- a/packages/orbis-agent-backend/src/index.ts +++ b/packages/orbis-agent-backend/src/index.ts @@ -12,6 +12,7 @@ export { type AgentModelThinkingLevelMetadata, type AgentWorkspaceDescriptor, type AgentWorkspaceBrowseInput, + type AgentWorkspaceCreateFolderInput, type AgentWorkspaceFolderDescriptor, type AgentWorkspaceFolderListing, type AgentWorkspaceListInput, diff --git a/packages/orbis-agent-backend/src/testkit.ts b/packages/orbis-agent-backend/src/testkit.ts index 50f7003..e65d5f4 100644 --- a/packages/orbis-agent-backend/src/testkit.ts +++ b/packages/orbis-agent-backend/src/testkit.ts @@ -8,6 +8,8 @@ import type { AgentModelMetadata, AgentWorkspaceDescriptor, AgentWorkspaceBrowseInput, + AgentWorkspaceCreateFolderInput, + AgentWorkspaceFolderDescriptor, AgentWorkspaceFolderListing, AgentWorkspaceListInput, AgentWorkspaceRegisterInput, @@ -138,6 +140,12 @@ export class FakeAgentBackend implements AgentBackend, FakeRuntimeHost { throw new AgentBackendError("unsupported", "The fake backend cannot browse folders"); } + async createWorkspaceFolder( + _input: AgentWorkspaceCreateFolderInput, + ): Promise { + throw new AgentBackendError("unsupported", "The fake backend cannot create folders"); + } + async registerWorkspace( _input: AgentWorkspaceRegisterInput, ): Promise { diff --git a/packages/orbis-remote-agent-protocol/src/index.ts b/packages/orbis-remote-agent-protocol/src/index.ts index 84a0b44..fde79e8 100644 --- a/packages/orbis-remote-agent-protocol/src/index.ts +++ b/packages/orbis-remote-agent-protocol/src/index.ts @@ -77,5 +77,6 @@ export type { RemoteAgentV2Usage, RemoteAgentV2UpdateInput, RemoteAgentV2WorkspaceBrowseInput, + RemoteAgentV2WorkspaceCreateFolderInput, RemoteAgentV2WorkspaceRegisterInput, } from "./v2-types"; diff --git a/packages/orbis-remote-agent-protocol/src/v2-connection.test.ts b/packages/orbis-remote-agent-protocol/src/v2-connection.test.ts index 83ad995..2d60099 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-connection.test.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-connection.test.ts @@ -1,15 +1,10 @@ -import { expect, test } from "vitest"; - import { agentTimestamp, createAgentSessionRef, type AgentJsonValue, } from "@orbisapp/orbis-agent-backend"; -import { - OrbisTransportError, - type JsonValue, - type TransportEvent, -} from "@orbisapp/transport"; +import { OrbisTransportError, type JsonValue, type TransportEvent } from "@orbisapp/transport"; +import { expect, test } from "vitest"; import { OrbisRemoteAgentV2Connection } from "./v2-connection"; import { @@ -219,6 +214,25 @@ test("v2 connection enforces hello-first and delivers replayable events without current: null, entries: [{ ref: "folder-a" }], }); + transport.respond(ORBIS_REMOTE_AGENT_V2_METHODS.workspacesCreateFolder, { + displayName: "New Folder", + hidden: false, + ref: "folder-new", + selectable: true, + }); + expect( + await connection.createWorkspaceFolder({ + driverId: ref.driverId, + idempotencyKey: "workspace-create-folder-a", + name: "New Folder", + parentFolderRef: "folder-a", + }), + ).toEqual({ + displayName: "New Folder", + hidden: false, + ref: "folder-new", + selectable: true, + }); transport.respond(ORBIS_REMOTE_AGENT_V2_METHODS.workspacesRegister, { created: true, workspace: { displayName: "Projects", ref: "workspace-b" }, diff --git a/packages/orbis-remote-agent-protocol/src/v2-connection.ts b/packages/orbis-remote-agent-protocol/src/v2-connection.ts index ce53cab..fa43e5d 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-connection.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-connection.ts @@ -17,6 +17,7 @@ import { type AgentModelMetadata, type AgentSessionRef, type AgentWorkspaceDescriptor, + type AgentWorkspaceFolderDescriptor, type AgentWorkspaceFolderListing, type AgentWorkspaceRegisterResult, } from "@orbisapp/orbis-agent-backend"; @@ -71,6 +72,7 @@ import type { RemoteAgentV2SessionSummary, RemoteAgentV2UpdateInput, RemoteAgentV2WorkspaceBrowseInput, + RemoteAgentV2WorkspaceCreateFolderInput, RemoteAgentV2WorkspaceRegisterInput, RemoteAgentV2Usage, } from "./v2-types"; @@ -98,6 +100,9 @@ export type RemoteAgentV2SyncResult = export interface RemoteAgentV2Connection { browseWorkspaces(input: RemoteAgentV2WorkspaceBrowseInput): Promise; + createWorkspaceFolder( + input: RemoteAgentV2WorkspaceCreateFolderInput, + ): Promise; cancel(input: RemoteAgentV2CancelInput): Promise<{ readonly cancelled: boolean }>; close(): void; createSession(input: RemoteAgentV2CreateInput): Promise; @@ -876,6 +881,12 @@ export class OrbisRemoteAgentV2Connection implements RemoteAgentV2Connection { ); } + async createWorkspaceFolder(input: RemoteAgentV2WorkspaceCreateFolderInput) { + return this.request(ORBIS_REMOTE_AGENT_V2_METHODS.workspacesCreateFolder, input, (value) => + parseSchema(v2WorkspaceFolderSchema, value, "Created workspace folder"), + ); + } + async registerWorkspace(input: RemoteAgentV2WorkspaceRegisterInput) { return this.request(ORBIS_REMOTE_AGENT_V2_METHODS.workspacesRegister, input, (value) => { const output = record(value, "Workspace registration result"); diff --git a/packages/orbis-remote-agent-protocol/src/v2-constants.ts b/packages/orbis-remote-agent-protocol/src/v2-constants.ts index 1e65407..ba74612 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-constants.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-constants.ts @@ -8,6 +8,7 @@ export const ORBIS_REMOTE_AGENT_V2_METHODS = { modelsList: "orbis.models.list", workspacesList: "orbis.workspaces.list", workspacesBrowse: "orbis.workspaces.browse", + workspacesCreateFolder: "orbis.workspaces.createFolder", workspacesRegister: "orbis.workspaces.register", sessionsList: "orbis.sessions.list", sessionsCreate: "orbis.sessions.create", @@ -26,6 +27,10 @@ export const ORBIS_REMOTE_AGENT_V2_METHOD_SCOPES = { [ORBIS_REMOTE_AGENT_V2_METHODS.modelsList]: [ORBIS_REMOTE_SCOPES.agentRead], [ORBIS_REMOTE_AGENT_V2_METHODS.workspacesList]: [ORBIS_REMOTE_SCOPES.agentRead], [ORBIS_REMOTE_AGENT_V2_METHODS.workspacesBrowse]: [ORBIS_REMOTE_SCOPES.workspaceBrowse], + [ORBIS_REMOTE_AGENT_V2_METHODS.workspacesCreateFolder]: [ + ORBIS_REMOTE_SCOPES.workspaceBrowse, + ORBIS_REMOTE_SCOPES.agentWrite, + ], [ORBIS_REMOTE_AGENT_V2_METHODS.workspacesRegister]: [ ORBIS_REMOTE_SCOPES.workspaceBrowse, ORBIS_REMOTE_SCOPES.agentWrite, diff --git a/packages/orbis-remote-agent-protocol/src/v2-host.test.ts b/packages/orbis-remote-agent-protocol/src/v2-host.test.ts index 81c9a67..bf4334b 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-host.test.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-host.test.ts @@ -1,5 +1,3 @@ -import { expect, test } from "vitest"; - import { agentBackendId, agentDeliveryCursor, @@ -13,11 +11,8 @@ import { type AgentJsonValue, type AgentSessionRef, } from "@orbisapp/orbis-agent-backend"; -import { - OrbisTransportError, - type JsonValue, - type TransportEvent, -} from "@orbisapp/transport"; +import { OrbisTransportError, type JsonValue, type TransportEvent } from "@orbisapp/transport"; +import { expect, test } from "vitest"; import { OrbisRemoteAgentV2Host, @@ -195,6 +190,12 @@ test("v2 host replays native entries from its cursor index without ACK state", a title: null, updatedAt: now, }), + createWorkspaceFolder: async () => ({ + displayName: "New Folder", + hidden: false, + ref: "folder-new", + selectable: true, + }), hostId: agentBackendId("native"), listDrivers: async () => [ createAgentDriverDescriptor({ @@ -298,6 +299,23 @@ test("v2 host replays native entries from its cursor index without ACK state", a context(), ), ).toEqual({ breadcrumbs: [], current: null, entries: [], truncated: false }); + expect( + await host.handleRequest( + ORBIS_REMOTE_AGENT_V2_METHODS.workspacesCreateFolder, + params({ + driverId: "dsh", + idempotencyKey: "create-folder-1", + name: "New Folder", + parentFolderRef: "folder-a", + }), + context(), + ), + ).toEqual({ + displayName: "New Folder", + hidden: false, + ref: "folder-new", + selectable: true, + }); expect( await host.handleRequest( ORBIS_REMOTE_AGENT_V2_METHODS.workspacesRegister, @@ -596,6 +614,9 @@ test("v2 host announces catalog rows that move outside its own session runtimes" createSession: async () => { throw new Error("unused"); }, + createWorkspaceFolder: async () => { + throw new Error("unused"); + }, hostId: agentBackendId("native"), listDrivers: async () => [], listModels: async () => [], diff --git a/packages/orbis-remote-agent-protocol/src/v2-host.ts b/packages/orbis-remote-agent-protocol/src/v2-host.ts index e3f8d78..9deb686 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-host.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-host.ts @@ -44,6 +44,7 @@ import { v2ModelsListInputSchema, v2WorkspacesListInputSchema, v2WorkspacesBrowseInputSchema, + v2WorkspacesCreateFolderInputSchema, v2WorkspacesRegisterInputSchema, v2PromptInputSchema, v2RefSchema, @@ -789,6 +790,8 @@ export class OrbisRemoteAgentV2Host { } case ORBIS_REMOTE_AGENT_V2_METHODS.workspacesBrowse: return toJsonResult(await this.browseWorkspaces(params, context)); + case ORBIS_REMOTE_AGENT_V2_METHODS.workspacesCreateFolder: + return await this.createWorkspaceFolder(params, context); case ORBIS_REMOTE_AGENT_V2_METHODS.workspacesRegister: return await this.registerWorkspace(params, context); case ORBIS_REMOTE_AGENT_V2_METHODS.sessionsList: @@ -897,6 +900,40 @@ export class OrbisRemoteAgentV2Host { return await this.backend.browseWorkspaceFolders(driverId, input.folderRef, context.signal); } + private async createWorkspaceFolder( + params: JsonValue, + context: RemoteAgentHostRequestContext, + ): Promise { + const input = parseSchema( + v2WorkspacesCreateFolderInputSchema, + params, + "Workspace folder creation input", + ); + const driverId = agentDriverId(input.driverId); + await this.assertDriverCapability(driverId, "workspace.open"); + const key = this.idempotencyKey( + context.peer, + ORBIS_REMOTE_AGENT_V2_METHODS.workspacesCreateFolder, + input.idempotencyKey, + input.driverId, + ); + const claim = await this.store.claimIdempotency(key); + if (claim.kind === "accepted") return toJsonResult(claim.result); + if (claim.kind === "pending") { + throw new AgentBackendError( + "unavailable", + "The prior workspace folder creation is still reconciling", + { retryable: true }, + ); + } + this.assertRequestActive(context); + const result = toJsonResult( + await this.backend.createWorkspaceFolder(driverId, input.parentFolderRef, input.name), + ); + await this.store.completeIdempotency(key, result as AgentJsonValue); + return result; + } + private async registerWorkspace( params: JsonValue, context: RemoteAgentHostRequestContext, diff --git a/packages/orbis-remote-agent-protocol/src/v2-schemas.ts b/packages/orbis-remote-agent-protocol/src/v2-schemas.ts index e3edb32..dbcb778 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-schemas.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-schemas.ts @@ -342,6 +342,15 @@ export const v2WorkspacesBrowseInputSchema = z .object({ driverId: nonEmptyString, folderRef: nonEmptyString.optional() }) .passthrough(); +export const v2WorkspacesCreateFolderInputSchema = z + .object({ + driverId: nonEmptyString, + idempotencyKey: nonEmptyString, + name: nonEmptyString, + parentFolderRef: nonEmptyString, + }) + .passthrough(); + export const v2WorkspacesRegisterInputSchema = z .object({ driverId: nonEmptyString, diff --git a/packages/orbis-remote-agent-protocol/src/v2-types.ts b/packages/orbis-remote-agent-protocol/src/v2-types.ts index e8003f5..d26dcf6 100644 --- a/packages/orbis-remote-agent-protocol/src/v2-types.ts +++ b/packages/orbis-remote-agent-protocol/src/v2-types.ts @@ -6,6 +6,7 @@ import type { AgentModelSelection, AgentSessionRef, AgentWorkspaceDescriptor, + AgentWorkspaceFolderDescriptor, AgentWorkspaceFolderListing, AgentWorkspaceRegisterResult, } from "@orbisapp/orbis-agent-backend"; @@ -227,6 +228,13 @@ export interface RemoteAgentV2WorkspaceRegisterInput { readonly idempotencyKey: string; } +export interface RemoteAgentV2WorkspaceCreateFolderInput { + readonly driverId: AgentSessionRef["driverId"]; + readonly idempotencyKey: string; + readonly name: string; + readonly parentFolderRef: string; +} + export interface RemoteAgentV2PromptInput { readonly ref: AgentSessionRef; readonly content: readonly RemoteAgentV2ContentBlock[]; @@ -284,6 +292,11 @@ export interface RemoteAgentV2Backend { folderRef?: string, signal?: AbortSignal, ): Promise; + createWorkspaceFolder( + driverId: AgentSessionRef["driverId"], + parentFolderRef: string, + name: string, + ): Promise; connectRuntime(ref: AgentSessionRef): Promise; createSession( input: Omit, diff --git a/packages/orbis-remote-dsh/src/adapter/dsh-local-backend.ts b/packages/orbis-remote-dsh/src/adapter/dsh-local-backend.ts index 2d44ab8..5009e44 100644 --- a/packages/orbis-remote-dsh/src/adapter/dsh-local-backend.ts +++ b/packages/orbis-remote-dsh/src/adapter/dsh-local-backend.ts @@ -25,6 +25,8 @@ import { type AgentModelSelection, type AgentWorkspaceDescriptor, type AgentWorkspaceBrowseInput, + type AgentWorkspaceCreateFolderInput, + type AgentWorkspaceFolderDescriptor, type AgentWorkspaceFolderListing, type AgentWorkspaceRegisterInput, type AgentWorkspaceRegisterResult, @@ -379,6 +381,12 @@ export class DshLocalBackend implements AgentBackend, DshLocalControllerHost { throw new AgentBackendError("unsupported", "Local DSH folder browsing is host-owned"); } + async createWorkspaceFolder( + _input: AgentWorkspaceCreateFolderInput, + ): Promise { + throw new AgentBackendError("unsupported", "Local DSH folder creation is host-owned"); + } + async registerWorkspace( _input: AgentWorkspaceRegisterInput, ): Promise { diff --git a/packages/orbis-remote-dsh/src/host/dsh-v2-backend.ts b/packages/orbis-remote-dsh/src/host/dsh-v2-backend.ts index 59e85f1..ea12403 100644 --- a/packages/orbis-remote-dsh/src/host/dsh-v2-backend.ts +++ b/packages/orbis-remote-dsh/src/host/dsh-v2-backend.ts @@ -14,10 +14,10 @@ import { type AgentSessionProjection, type AgentSessionRef, type AgentSessionStatePatch, + type AgentWorkspaceFolderDescriptor, type AgentWorkspaceFolderListing, type AgentWorkspaceRegisterResult, } from "@orbisapp/orbis-agent-backend"; -import { DshLocalBackend, type DshLocalSessionRuntime } from "../adapter"; import type { RemoteAgentV2Backend, RemoteAgentV2ContentBlock, @@ -34,6 +34,8 @@ import type { RemoteAgentV2SessionSummary, } from "@orbisapp/remote-agent-protocol"; +import { DshLocalBackend, type DshLocalSessionRuntime } from "../adapter"; + const DSH_HOST_BACKEND_ID = agentBackendId("dsh-host"); export interface DshRemoteWorkspaceProvider { @@ -41,6 +43,10 @@ export interface DshRemoteWorkspaceProvider { readonly folderRef?: string; readonly signal?: AbortSignal; }): Promise; + create(input: { + readonly folderRef: string; + readonly name: string; + }): Promise; register(input: { readonly folderRef: string }): Promise; } @@ -563,6 +569,18 @@ export class DshRemoteV2Backend implements RemoteAgentV2Backend { return this.workspaces.browse({ ...(folderRef === undefined ? {} : { folderRef }), signal }); } + async createWorkspaceFolder( + driverId: AgentSessionRef["driverId"], + parentFolderRef: string, + name: string, + ): Promise { + this.assertDshDriver(driverId); + if (this.workspaces === undefined) { + throw new AgentBackendError("unsupported", "Server folder creation is unavailable"); + } + return this.workspaces.create({ folderRef: parentFolderRef, name }); + } + async registerWorkspace( driverId: AgentSessionRef["driverId"], folderRef: string, diff --git a/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.test.ts b/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.test.ts index d57d96a..39fd228 100644 --- a/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.test.ts +++ b/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.test.ts @@ -25,8 +25,10 @@ async function fixture() { await mkdir(child, { recursive: true }); const browser: DirectoryPickerBrowseCapability = { kind: "browse", - createDirectory: async () => { - throw new Error("unused"); + createDirectory: async (path, name) => { + const created = join(path, name); + await mkdir(created); + return created; }, list: async (path): Promise => { const current = await realpath(path ?? root); @@ -73,6 +75,27 @@ test("browse exposes opaque refs and registers the selected canonical directory" }); }); +test("creates a child folder under the selected canonical directory", async () => { + const { provider, root } = await fixture(); + const roots = await provider.browse({}); + const created = await provider.create({ folderRef: roots.entries[0]!.ref, name: "fresh" }); + + expect(created).toMatchObject({ displayName: "fresh", hidden: false, selectable: true }); + expect(created.ref).not.toContain(root); + await expect(readdir(join(root, "fresh"))).resolves.toEqual([]); +}); + +test("rejects invalid folder names before touching the directory picker", async () => { + const { provider } = await fixture(); + const roots = await provider.browse({}); + + await expect( + provider.create({ folderRef: roots.entries[0]!.ref, name: "../escape" }), + ).rejects.toMatchObject({ + code: "invalid_argument", + }); +}); + test("uses the directory picker's Home instead of the DSH process cwd by default", async () => { const parent = await mkdtemp(join(tmpdir(), "orbis-folder-provider-home-")); fixtures.push(parent); diff --git a/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.ts b/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.ts index f52837e..b03e79a 100644 --- a/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.ts +++ b/packages/orbis-remote-dsh/src/plugin/workspace-folder-provider.ts @@ -11,6 +11,7 @@ import { type AgentWorkspaceFolderListing, type AgentWorkspaceRegisterResult, } from "@orbisapp/orbis-agent-backend"; + import type { DshRemoteWorkspaceProvider } from "../host"; const TOKEN_PREFIX = "folder.v1"; @@ -130,6 +131,44 @@ class DshWorkspaceFolderProvider implements DshRemoteWorkspaceProvider { return operation; } + async create(input: { + readonly folderRef: string; + readonly name: string; + }): Promise { + if ( + input.name.trim() === "" || + input.name === "." || + input.name === ".." || + input.name.includes("/") || + input.name.includes("\\") + ) { + throw new AgentBackendError("invalid_argument", "Folder name is invalid"); + } + + try { + const resolved = await this.resolveFolder(input.folderRef); + const createdPath = await this.browser.createDirectory(resolved.path, input.name); + const canonical = await realpath(createdPath); + if (!contained(resolved.root.path, canonical)) { + throw new AgentBackendError("unavailable", "The created folder is outside the workspace"); + } + const metadata = await stat(canonical); + if (!metadata.isDirectory()) { + throw new AgentBackendError("unavailable", "The created folder is unavailable"); + } + const segments = this.relativeSegments(resolved.root.path, canonical); + return this.descriptor(resolved.root, segments, basename(canonical), false); + } catch (error) { + if (isAgentBackendError(error)) throw error; + if (directoryPickerErrorCode(error) === "directory-exists") { + throw new AgentBackendError("conflict", "A folder with this name already exists"); + } + throw new AgentBackendError("unavailable", "The server folder could not be created", { + retryable: true, + }); + } + } + private breadcrumbs( root: RootRecord, segments: readonly string[], @@ -232,6 +271,12 @@ class DshWorkspaceFolderProvider implements DshRemoteWorkspaceProvider { } } +function directoryPickerErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { readonly code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + export async function createDshWorkspaceFolderProvider( options: CreateDshWorkspaceFolderProviderOptions, ): Promise {