Model orchestrator scratch workspaces (#4263)

* Model orchestrator scratch workspaces

* Address scratch workspace contract review
This commit is contained in:
Denis Redozubov
2026-06-14 11:14:30 +02:00
committed by GitHub
parent d20d78038c
commit 4b3bf91f27
9 changed files with 312 additions and 7 deletions

View File

@@ -9,6 +9,7 @@ import {
type InlineAssetReader,
} from './inline-assets.js';
import { sandboxImportedProjectRootUnavailableReason } from './sandbox-mode.js';
import { parseOrchestratorWorkspace } from './workspace-contract.js';
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
@@ -106,10 +107,21 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (!existing) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const { baseDir } = req.body || {};
const { baseDir, orchestratorWorkspace } = req.body || {};
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const parsedOrchestratorWorkspace =
parseOrchestratorWorkspace(orchestratorWorkspace);
if (!parsedOrchestratorWorkspace.ok) {
return sendApiError(
res,
400,
'BAD_REQUEST',
parsedOrchestratorWorkspace.message,
);
}
const normalizedOrchestratorWorkspace = parsedOrchestratorWorkspace.value;
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();
@@ -177,19 +189,26 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
) {
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot point at the data directory');
}
const sandboxReason = sandboxImportedProjectRootUnavailableReason(normalizedPath);
const sandboxReason = normalizedOrchestratorWorkspace
? null
: sandboxImportedProjectRootUnavailableReason(normalizedPath);
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
const entryFile = await detectEntryFile(normalizedPath);
const existingMeta = existing.metadata ?? {};
const { orchestratorWorkspace: _existingOrchestratorWorkspace, ...preservedMeta } =
existingMeta;
const nextMeta = {
...existingMeta,
...preservedMeta,
kind: existingMeta.kind ?? 'prototype',
baseDir: normalizedPath,
importedFrom: 'folder' as const,
entryFile,
...(normalizedOrchestratorWorkspace
? { orchestratorWorkspace: normalizedOrchestratorWorkspace }
: {}),
...(trustedPickerImport ? { fromTrustedPicker: true as const } : {}),
};
const updated = updateProject(db, projectId, { metadata: nextMeta });
@@ -210,10 +229,21 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
app.post('/api/import/folder', async (req, res) => {
try {
const { baseDir, name, skillId, designSystemId } = req.body || {};
const { baseDir, name, skillId, designSystemId, orchestratorWorkspace } = req.body || {};
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const parsedOrchestratorWorkspace =
parseOrchestratorWorkspace(orchestratorWorkspace);
if (!parsedOrchestratorWorkspace.ok) {
return sendApiError(
res,
400,
'BAD_REQUEST',
parsedOrchestratorWorkspace.message,
);
}
const normalizedOrchestratorWorkspace = parsedOrchestratorWorkspace.value;
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();
@@ -293,7 +323,9 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
) {
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import the data directory');
}
const sandboxReason = sandboxImportedProjectRootUnavailableReason(normalizedPath);
const sandboxReason = normalizedOrchestratorWorkspace
? null
: sandboxImportedProjectRootUnavailableReason(normalizedPath);
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
@@ -314,7 +346,6 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
designSystemValidation.message,
);
}
const project = insertProject(db, {
id,
name: projectName,
@@ -326,6 +357,9 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
baseDir: normalizedPath,
importedFrom: 'folder',
entryFile,
...(normalizedOrchestratorWorkspace
? { orchestratorWorkspace: normalizedOrchestratorWorkspace }
: {}),
...(trustedPickerImport ? { fromTrustedPicker: true as const } : {}),
},
createdAt: now,

View File

@@ -32,6 +32,7 @@ import {
isSandboxModeEnabled,
SANDBOX_IMPORTED_PROJECT_UNAVAILABLE_MESSAGE,
} from './sandbox-mode.js';
import { isOrchestratorScratchWorkspace } from './workspace-contract.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
@@ -77,6 +78,7 @@ export function assertSandboxProjectRootAvailable(metadata?) {
if (
isSandboxModeEnabled(process.env) &&
hasExternalProjectRoot(metadata) &&
!isOrchestratorScratchWorkspace(metadata) &&
!isSandboxImportedProjectRootAllowed(metadata.baseDir)
) {
throw new SandboxImportedProjectError();
@@ -85,6 +87,7 @@ export function assertSandboxProjectRootAvailable(metadata?) {
function usesExternalProjectRoot(metadata?) {
if (!hasExternalProjectRoot(metadata)) return false;
if (isOrchestratorScratchWorkspace(metadata)) return true;
if (!isSandboxModeEnabled(process.env)) return true;
return isSandboxImportedProjectRootAllowed(metadata.baseDir);
}

View File

@@ -0,0 +1,88 @@
// @ts-nocheck
import type { OrchestratorWorkspace } from '@open-design/contracts';
const ORCHESTRATOR_WORKSPACE_KIND = 'scratch';
const ORCHESTRATOR_WRITEBACK = 'external';
const ORCHESTRATOR_WORKSPACE_KEYS = new Set([
'kind',
'sourceLabel',
'sourceRef',
'baseRevision',
'writeback',
]);
function stringField(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function plainObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function normalizeOrchestratorWorkspace(value: unknown) {
const parsed = parseOrchestratorWorkspace(value);
return parsed.ok ? parsed.value : null;
}
export function parseOrchestratorWorkspace(value: unknown): {
ok: true;
value: OrchestratorWorkspace | null;
} | {
ok: false;
message: string;
} {
if (value == null) return { ok: true, value: null };
const record = plainObject(value);
if (!record) {
return {
ok: false,
message: 'orchestratorWorkspace must be an object when provided',
};
}
const unknownKey = Object.keys(record).find((key) => !ORCHESTRATOR_WORKSPACE_KEYS.has(key));
if (unknownKey) {
return {
ok: false,
message: `orchestratorWorkspace contains unsupported field: ${unknownKey}`,
};
}
if (stringField(record.kind) !== ORCHESTRATOR_WORKSPACE_KIND) {
return {
ok: false,
message: 'orchestratorWorkspace.kind must be "scratch"',
};
}
if (record.writeback != null && stringField(record.writeback) !== ORCHESTRATOR_WRITEBACK) {
return {
ok: false,
message: 'orchestratorWorkspace.writeback must be "external"',
};
}
for (const key of ['sourceLabel', 'sourceRef', 'baseRevision']) {
if (record[key] != null && !stringField(record[key])) {
return {
ok: false,
message: `orchestratorWorkspace.${key} must be a non-empty string when provided`,
};
}
}
const result: OrchestratorWorkspace = {
kind: ORCHESTRATOR_WORKSPACE_KIND,
writeback: ORCHESTRATOR_WRITEBACK,
};
const sourceLabel = stringField(record.sourceLabel);
const sourceRef = stringField(record.sourceRef);
const baseRevision = stringField(record.baseRevision);
if (sourceLabel) result.sourceLabel = sourceLabel;
if (sourceRef) result.sourceRef = sourceRef;
if (baseRevision) result.baseRevision = baseRevision;
return { ok: true, value: result };
}
export function isOrchestratorScratchWorkspace(metadata: unknown): boolean {
const record = plainObject(metadata);
return !!record && normalizeOrchestratorWorkspace(record.orchestratorWorkspace) !== null;
}

View File

@@ -165,6 +165,110 @@ describe('POST /api/import/folder', () => {
});
});
it('persists orchestrator scratch provenance for sandbox folder imports without an explicit import root', async () => {
await withSandboxMode(async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({
baseDir: folder,
orchestratorWorkspace: {
kind: 'scratch',
sourceLabel: 'checkout:main',
sourceRef: 'main@abc123',
baseRevision: 'abc123',
writeback: 'external',
},
});
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as {
project: {
id: string;
metadata?: {
baseDir?: string;
orchestratorWorkspace?: Record<string, unknown>;
};
};
};
expect(project.metadata?.baseDir).toBe(await realpath(folder));
expect(project.metadata?.orchestratorWorkspace).toEqual({
kind: 'scratch',
sourceLabel: 'checkout:main',
sourceRef: 'main@abc123',
baseRevision: 'abc123',
writeback: 'external',
});
const filesResp = await fetch(`${baseUrl}/api/projects/${project.id}/files`);
expect(filesResp.status).toBe(200);
const filesBody = (await filesResp.json()) as { files: Array<{ name: string }> };
expect(filesBody.files.map((file) => file.name)).toContain('index.html');
});
});
it('rejects malformed orchestrator workspace metadata on folder import', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const resp = await importFolder({
baseDir: folder,
orchestratorWorkspace: { kind: 'bogus' },
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/orchestratorWorkspace\.kind/i);
});
it('rejects malformed orchestrator workspace metadata on working-dir replacement', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const nextFolder = makeFolder();
await writeFile(path.join(nextFolder, 'index.html'), '<!doctype html>');
const replaceResp = await fetch(`${baseUrl}/api/projects/${project.id}/working-dir`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
baseDir: nextFolder,
orchestratorWorkspace: { kind: 'scratch', source_reference: 'typo' },
}),
});
expect(replaceResp.status).toBe(400);
const body = (await replaceResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/unsupported field: source_reference/i);
});
it('clears scratch provenance when replacing a working directory without new provenance', async () => {
const scratchFolder = makeFolder();
await writeFile(path.join(scratchFolder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({
baseDir: scratchFolder,
orchestratorWorkspace: {
kind: 'scratch',
sourceRef: 'main@abc123',
writeback: 'external',
},
});
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const localFolder = makeFolder();
await writeFile(path.join(localFolder, 'index.html'), '<!doctype html>');
const replaceResp = await fetch(`${baseUrl}/api/projects/${project.id}/working-dir`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseDir: localFolder }),
});
expect(replaceResp.status).toBe(200);
const replaceBody = (await replaceResp.json()) as {
project: { metadata?: { orchestratorWorkspace?: unknown } };
};
expect(replaceBody.project.metadata?.orchestratorWorkspace).toBeUndefined();
});
it('rejects sandbox runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');

View File

@@ -2,7 +2,7 @@
**Parent:** [`spec.md`](spec.md) · **Siblings:** [`skills-protocol.md`](skills-protocol.md) · [`agent-adapters.md`](agent-adapters.md) · [`modes.md`](modes.md)
This doc describes the system topology, runtime modes, data flow, and file layout. Design rationale lives in [`spec.md`](spec.md); protocol details for skills and agent adapters live in their own docs.
This doc describes the system topology, runtime modes, data flow, and file layout. Design rationale lives in [`spec.md`](spec.md); protocol details for skills and agent adapters live in their own docs. For embedding OD behind another control plane, see [`orchestrator-workspaces.md`](orchestrator-workspaces.md).
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[acd]: https://github.com/VoltAgent/awesome-claude-design

View File

@@ -0,0 +1,59 @@
# Externally Prepared Workspaces
**Parent:** [`architecture.md`](architecture.md) · **Related:** [`plugins-spec.md`](plugins-spec.md) · [`agent-adapters.md`](agent-adapters.md)
Open Design supports OD-owned project storage and folder-backed projects. Folder-backed projects need explicit provenance so OD can tell apart user-selected local folders from disposable workspaces prepared by an external orchestrator.
The boundary is intentionally narrow: OD may read and write the workspace it is given, produce inspectable design outputs, and report enough provenance for callers to act on the result. Source checkout state, pull-request creation, deployment, publishing, and writeback policy remain outside OD unless a future feature deliberately owns that workflow.
## Contract
Workspace metadata separates storage from provenance:
- **Storage** answers where OD reads and writes files.
- `od-owned`: OD-owned project files under the normal data/project directory.
- `folder-backed`: files live at an external filesystem root.
- **Folder provenance** answers who prepared a folder-backed workspace and who owns follow-up actions.
- `user-local`: a user-selected folder that OD edits in place.
- `orchestrator-scratch`: a disposable folder prepared by an external orchestrator. OD may read and write the scratch workspace, but source authority and writeback stay outside OD.
This keeps OD local-first and integration-friendly without expanding its responsibility into source-control, deployment, or writeback policy.
## Metadata Shape
Callers should mark scratch workspaces declaratively on project metadata:
```json
{
"baseDir": "/tmp/od-run-123/workspace",
"importedFrom": "folder",
"orchestratorWorkspace": {
"kind": "scratch",
"sourceLabel": "checkout:main",
"sourceRef": "main@abc123",
"baseRevision": "abc123",
"writeback": "external"
}
}
```
The `orchestratorWorkspace` fields are provenance, not authority. OD may surface them in run status, diagnostics, result manifests, and telemetry. It must not infer permission to mutate an external source from them.
## Git And Safety
OD should not maintain a broad git command firewall for externally prepared scratch workspaces. The orchestrator owns source checkout policy, credentials, remote configuration, branch selection, and writeback. OD should treat git state inside a scratch workspace as implementation detail unless a specific skill or artifact explicitly asks for a diff.
Local user folder imports have different provenance. When a user points OD directly at their own folder, OD is editing that folder in place. It is reasonable for OD to provide narrow UX warnings or preflight checks for that local mode, but those checks must not be framed as the sandbox boundary for externally prepared scratch workspaces.
## Result Boundary
The stable output boundary is a result package:
- run identity and terminal status;
- workspace storage and provenance supplied by the caller;
- project files and artifact manifests OD can enumerate;
- event-log location when configured;
- errors, exit code, signal, and cancellation state;
- optional diff or patch metadata when a future caller asks OD to compute it explicitly.
The package describes what OD produced. It does not apply the result to any source workspace.

View File

@@ -1,4 +1,5 @@
import type { ChatMessage, ChatRunStatus, ChatSessionMode } from './chat.js';
import type { OrchestratorWorkspace } from './workspaces.js';
import type {
ProjectContextConnectorRef,
ProjectContextMcpServerRef,
@@ -130,6 +131,9 @@ export interface ProjectMetadata {
// it set `baseDir` outside the trusted flow. Privileged: rejected
// by `POST /api/projects` and `PATCH /api/projects/:id`.
fromTrustedPicker?: true;
// Externally prepared scratch workspace provenance. OD may read/write
// metadata.baseDir, but source authority and writeback stay outside OD.
orchestratorWorkspace?: OrchestratorWorkspace;
// Hint stamped by the Home composer working-directory chip. It records
// where the user wanted the project to live without granting write access
// to that path; actual filesystem roots still use baseDir/import flows.
@@ -327,6 +331,7 @@ export interface ImportFolderRequest {
name?: string;
skillId?: string | null;
designSystemId?: string | null;
orchestratorWorkspace?: OrchestratorWorkspace;
}
export interface ImportFolderResponse {
@@ -337,6 +342,7 @@ export interface ImportFolderResponse {
export interface ReplaceProjectWorkingDirRequest {
baseDir: string;
orchestratorWorkspace?: OrchestratorWorkspace;
}
export interface ReplaceProjectWorkingDirResponse {

View File

@@ -0,0 +1,10 @@
export type OrchestratorWorkspaceKind = 'scratch';
export type OrchestratorWorkspaceWriteback = 'external';
export interface OrchestratorWorkspace {
kind: OrchestratorWorkspaceKind;
sourceLabel?: string;
sourceRef?: string;
baseRevision?: string;
writeback?: OrchestratorWorkspaceWriteback;
}

View File

@@ -30,6 +30,7 @@ export * from './api/research.js';
export * from './api/social-share.js';
export * from './api/terminals.js';
export * from './api/version.js';
export * from './api/workspaces.js';
export * from './examples.js';
export * from './design-systems/components-manifest.js';
export * from './design-systems/derived-token-outputs.js';