feat(install): add sandbox provider step to web install wizard

Operators choose Docker (default, zero-config) or Daytona (validated
via Daytona SDK) during browser install. Selection is captured in
settings.toml under [run.sandbox] -- explicitly even for Docker, so the
choice is locked in. Daytona keys land in the vault as DAYTONA_API_KEY
(Environment secret). Step always runs after object_store and before
the LLM step.

Server adds POST /install/sandbox/test (validates Daytona key via
client.list) and PUT /install/sandbox; both reuse the install-token
auth and InstallSecret redaction patterns established by object-store.
A resolve_install_sandbox_state helper preserves a saved Daytona key
when the operator revisits the step without re-entering it. The
in-memory api_key is dropped from PendingInstall after finish, matching
the manual_credentials cleanup for S3 access keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-27 14:59:17 -07:00
parent af56b38e55
commit 2460ffc37a
No known key found for this signature in database
33 changed files with 1605 additions and 79 deletions

1
Cargo.lock generated
View file

@ -2111,6 +2111,7 @@ dependencies = [
"fabro-install",
"fabro-interview",
"fabro-llm",
"fabro-macros",
"fabro-model",
"fabro-proc",
"fabro-redact",

View file

@ -2,8 +2,10 @@ import { describe, expect, test } from "bun:test";
import {
putInstallObjectStore,
putInstallSandbox,
readInstallError,
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
describe("readInstallError", () => {
@ -92,3 +94,47 @@ describe("install object-store requests", () => {
).rejects.toThrow("Bucket is required.");
});
});
describe("install sandbox requests", () => {
test("testInstallSandbox posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
await testInstallSandbox("test-install-token", {
provider: "daytona",
api_key: "dtn_test",
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/sandbox/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.body).toBe(
JSON.stringify({ provider: "daytona", api_key: "dtn_test" }),
);
});
test("putInstallSandbox surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "api_key is required for daytona",
},
],
}),
{ status: 422, headers: { "Content-Type": "application/json" } },
),
)) as typeof fetch;
await expect(
putInstallSandbox("test-install-token", { provider: "daytona" }),
).rejects.toThrow("api_key is required for daytona");
});
});

View file

@ -6,6 +6,8 @@ import type {
InstallLlmProviderInput,
InstallObjectStoreInput,
InstallObjectStoreSummary,
InstallSandboxInput,
InstallSandboxSummary,
InstallSessionResponse,
} from "@qltysh/fabro-api-client";
@ -17,6 +19,8 @@ export type {
InstallLlmProviderInput,
InstallObjectStoreInput,
InstallObjectStoreSummary,
InstallSandboxInput,
InstallSandboxSummary,
InstallSessionResponse,
};
@ -157,6 +161,30 @@ export async function putInstallObjectStore(
});
}
export async function testInstallSandbox(
token: string,
input: InstallSandboxInput,
): Promise<void> {
await installRequest(token, {
path: "/install/sandbox/test",
method: "POST",
body: input,
errorFallback: "install sandbox validation failed",
});
}
export async function putInstallSandbox(
token: string,
input: InstallSandboxInput,
): Promise<void> {
await installRequest(token, {
path: "/install/sandbox",
method: "PUT",
body: input,
errorFallback: "install sandbox request failed",
});
}
export async function testInstallGithubToken(
token: string,
githubToken: string,

View file

@ -102,6 +102,25 @@ function renderTreeText(
return (node.children ?? []).map(renderTreeText).join("");
}
function findOptionButton(
renderer: TestRenderer.ReactTestRenderer,
title: string,
): TestRenderer.ReactTestInstance {
const button = renderer.root.findAll((node) => {
if (node.type !== "button" || node.props["aria-pressed"] === undefined) {
return false;
}
const titleSpan = node.findAll(
(child) => child.type === "span" && child.children[0] === title,
);
return titleSpan.length > 0;
})[0];
if (!button) {
throw new Error(`option button "${title}" not found`);
}
return button;
}
async function waitFor(assertion: () => void, timeoutMs = 1000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
@ -249,7 +268,7 @@ describe("InstallApp", () => {
}
});
test("saves local disk object-store settings and advances to the LLM step", async () => {
test("saves local disk object-store settings and advances to the sandbox step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
@ -335,7 +354,7 @@ describe("InstallApp", () => {
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
expect(renderTreeText(renderer!.toJSON())).toContain("Choose the sandbox runtime");
});
const backLink = renderer!.root.findAll(
(node) =>
@ -511,6 +530,277 @@ describe("InstallApp", () => {
}
});
test("shows the sandbox provider on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox", "llm", "github"],
llm: { providers: [{ provider: "anthropic" }] },
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "daytona", api_key_saved: true },
github: { strategy: "token", username: "octocat" },
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
),
);
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/review"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("Daytona");
expect(text).toContain("Saved");
});
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("validates Daytona key, saves sandbox, and advances to the LLM step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
if (String(input) === "/install/session" && fetchCalls.length === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: null,
github: null,
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (
String(input) === "/install/sandbox/test"
|| String(input) === "/install/sandbox"
) {
return Promise.resolve(
new Response(JSON.stringify({ ok: true }), {
status: String(input).endsWith("/test") ? 200 : 204,
}),
);
}
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "daytona", api_key_saved: true },
github: null,
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/sandbox"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Choose the sandbox runtime");
});
const daytonaButton = findOptionButton(renderer!, "Daytona");
await act(async () => {
daytonaButton.props.onClick();
});
const apiKeyInput = renderer!.root.findByProps({ name: "sandbox_api_key" });
await act(async () => {
apiKeyInput.props.onChange({ target: { value: "dtn_secret" } });
});
const form = renderer!.root.findByType("form");
await act(async () => {
form.props.onSubmit({ preventDefault() {} });
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
});
const calls = fetchCalls.map((call) => String(call.input));
const testIdx = calls.indexOf("/install/sandbox/test");
const putIdx = calls.indexOf("/install/sandbox");
expect(testIdx).toBeGreaterThanOrEqual(0);
expect(putIdx).toBeGreaterThan(testIdx);
const sandboxTestCall = fetchCalls[testIdx];
expect(sandboxTestCall?.init?.body).toBe(
JSON.stringify({ provider: "daytona", api_key: "dtn_secret" }),
);
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("blocks Daytona save when the API key is missing", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL }> = [];
const fetchMock = mock((input: RequestInfo | URL) => {
fetchCalls.push({ input });
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: null,
github: null,
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/sandbox"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Choose the sandbox runtime");
});
const daytonaButton = findOptionButton(renderer!, "Daytona");
await act(async () => {
daytonaButton.props.onClick();
});
const form = renderer!.root.findByType("form");
await act(async () => {
form.props.onSubmit({ preventDefault() {} });
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain(
"Enter the Daytona API key before continuing.",
);
});
expect(fetchCalls.map((call) => String(call.input))).toEqual([
"/install/session",
]);
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("shows the GitHub App callback URL on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;

View file

@ -19,6 +19,7 @@ import {
type InstallGithubAppOwner,
type InstallLlmProviderInput,
type InstallObjectStoreInput,
type InstallSandboxInput,
type InstallSessionResponse,
createInstallGithubAppManifest,
finishInstall,
@ -27,11 +28,13 @@ import {
putInstallGithubToken,
putInstallLlm,
putInstallObjectStore,
putInstallSandbox,
putInstallServer,
readStoredInstallToken,
testInstallGithubToken,
testInstallLlm,
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
import { INSTALL_PROVIDERS } from "./install-config";
import { shouldRedirectAfterHealthPoll } from "./install-flow";
@ -53,6 +56,7 @@ const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
{ id: "server", label: "Server", href: "/install/server" },
{ id: "object_store", label: "Object store", href: "/install/object-store" },
{ id: "sandbox", label: "Sandbox", href: "/install/sandbox" },
{ id: "llm", label: "LLMs", href: "/install/llm" },
{ id: "github", label: "GitHub", href: "/install/github" },
{ id: "review", label: "Review", href: "/install/review" },
@ -92,6 +96,12 @@ type ObjectStoreForm = {
secretAccessKey: string;
manualCredentialsSaved: boolean;
};
type SandboxProvider = NonNullable<InstallSandboxInput["provider"]>;
type SandboxForm = {
provider: SandboxProvider;
apiKey: string;
apiKeySaved: boolean;
};
export default function InstallApp() {
const navigate = useNavigate();
@ -108,6 +118,9 @@ export default function InstallApp() {
const [objectStoreForm, setObjectStoreForm] = useState<ObjectStoreForm>(() =>
defaultObjectStoreForm(),
);
const [sandboxForm, setSandboxForm] = useState<SandboxForm>(() =>
defaultSandboxForm(),
);
const [canonicalUrl, setCanonicalUrl] = useState("");
const [githubStrategy, setGithubStrategy] = useState<GithubStrategy>("token");
const [tokenForm, setTokenForm] = useState<TokenForm>({ token: "", username: "" });
@ -126,6 +139,7 @@ export default function InstallApp() {
const regionInputRef = useRef<HTMLInputElement>(null);
const accessKeyIdInputRef = useRef<HTMLInputElement>(null);
const secretAccessKeyInputRef = useRef<HTMLInputElement>(null);
const sandboxApiKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
@ -164,6 +178,7 @@ export default function InstallApp() {
current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url,
);
setObjectStoreForm(hydrateObjectStoreForm(nextSession));
setSandboxForm((current) => hydrateSandboxForm(current, nextSession));
setLlmSelection((current) =>
hydrateProviderSelection(current, nextSession),
);
@ -296,6 +311,7 @@ export default function InstallApp() {
if (args.next) {
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
setSandboxForm((current) => hydrateSandboxForm(current, nextSession));
navigate(args.next);
}
} catch (error) {
@ -346,7 +362,7 @@ export default function InstallApp() {
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
error={saveError}
submitting={submitting}
backHref="/install/object-store"
backHref="/install/sandbox"
onSubmit={async () => {
const providers = INSTALL_PROVIDERS.map(({ id }) => {
const current = llmSelection[id] ?? { apiKey: "" };
@ -469,7 +485,7 @@ export default function InstallApp() {
await putInstallObjectStore(installToken, payload);
},
fallback: "Failed to save object-store settings.",
next: "/install/llm",
next: "/install/sandbox",
});
}}
>
@ -611,6 +627,88 @@ export default function InstallApp() {
</div>
)}
</StepPanel>
) : location.pathname === "/install/sandbox" ? (
<StepPanel
title="Choose the sandbox runtime"
description="Workflows run inside this sandbox. Docker uses the host daemon; Daytona runs each sandbox in its cloud."
error={saveError}
submitting={submitting}
submittingLabel={
sandboxForm.provider === "daytona" ? "Checking access..." : "Saving..."
}
backHref="/install/object-store"
onSubmit={async () => {
if (sandboxForm.provider === "daytona") {
const apiKey = sandboxForm.apiKey.trim();
const keepStoredKey = sandboxForm.apiKeySaved && !apiKey;
if (!keepStoredKey && !apiKey) {
setSaveError("Enter the Daytona API key before continuing.");
focusInput(sandboxApiKeyInputRef);
return;
}
}
const payload = buildSandboxPayload(sandboxForm);
await runStepSubmit({
action: async () => {
if (sandboxForm.provider === "daytona") {
await testInstallSandbox(installToken, payload);
}
await putInstallSandbox(installToken, payload);
},
fallback: "Failed to save sandbox settings.",
next: "/install/llm",
});
}}
>
<CardPicker
legend="Sandbox runtime"
options={SANDBOX_PROVIDER_OPTIONS}
value={sandboxForm.provider}
onChange={(provider) => {
setSandboxForm((current) => ({ ...current, provider }));
if (provider === "daytona") {
focusInput(sandboxApiKeyInputRef);
}
}}
/>
{sandboxForm.provider === "daytona" ? (
<div className="space-y-5">
<Field
label="Daytona API key"
hint={
sandboxForm.apiKeySaved
? "A key is already saved. Leave blank to keep using it."
: "Stored in the vault and exported to workflows as DAYTONA_API_KEY."
}
>
<input
ref={sandboxApiKeyInputRef}
type="password"
name="sandbox_api_key"
value={sandboxForm.apiKey}
onChange={(event) =>
setSandboxForm((current) => ({
...current,
apiKey: event.target.value,
}))
}
className={`${INPUT_CLASS} font-mono`}
placeholder={
sandboxForm.apiKeySaved ? "•••• (saved)" : "dtn_..."
}
autoComplete="off"
spellCheck={false}
/>
</Field>
</div>
) : (
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will use the host Docker daemon. Make sure the server has
access to <code className="font-mono text-fg-2">/var/run/docker.sock</code>.
</p>
)}
</StepPanel>
) : location.pathname === "/install/github/done" ? (
<GithubAppDoneScreen github={session?.github} />
) : location.pathname === "/install/github" ? (
@ -1018,8 +1116,8 @@ function WelcomeScreen() {
</h1>
<p className="mt-4 max-w-[56ch] text-base/7 text-fg-3 text-pretty sm:text-[0.9375rem]/7">
A short walkthrough to confirm the public server URL, choose the shared
object store, validate your LLM credentials, and connect GitHub. When
you finish, Fabro restarts into normal mode.
object store and sandbox runtime, validate your LLM credentials, and
connect GitHub. When you finish, Fabro restarts into normal mode.
</p>
<ol role="list" className="mt-10 divide-y divide-line border-y border-line">
{[
@ -1028,6 +1126,7 @@ function WelcomeScreen() {
"Object store",
"Choose local disk or AWS S3 for SlateDB and artifacts.",
],
["Sandbox", "Choose Docker or Daytona for workflow execution."],
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
["GitHub", "Choose a personal access token or a GitHub App."],
["Review", "Double-check the plan, then write the files."],
@ -1165,6 +1264,7 @@ function ReviewScreen({
action={<CopyButton value={serverUrl} label="Copy server URL" />}
/>
{renderObjectStoreSummaryRows(session?.object_store)}
{renderSandboxSummaryRows(session?.sandbox)}
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
{renderGithubSummaryRows(session?.github, serverUrl)}
</dl>
@ -1361,6 +1461,19 @@ const OBJECT_STORE_CREDENTIAL_MODE_OPTIONS: ReadonlyArray<
},
];
const SANDBOX_PROVIDER_OPTIONS: ReadonlyArray<CardOption<SandboxProvider>> = [
{
id: "docker",
title: "Docker",
body: "Default. Uses the host Docker daemon to run sandbox containers.",
},
{
id: "daytona",
title: "Daytona",
body: "Each run gets a managed Daytona cloud sandbox. Requires an API key.",
},
];
const GITHUB_OWNER_OPTIONS: ReadonlyArray<CardOption<GithubOwnerKind>> = [
{
id: "personal",
@ -1716,6 +1829,40 @@ function buildObjectStorePayload(form: ObjectStoreForm): InstallObjectStoreInput
return payload;
}
function defaultSandboxForm(): SandboxForm {
return { provider: "docker", apiKey: "", apiKeySaved: false };
}
function hydrateSandboxForm(
current: SandboxForm,
session: InstallSessionResponse,
): SandboxForm {
const summary = session.sandbox;
if (!summary) {
return current.apiKey ? { ...current, apiKeySaved: false } : defaultSandboxForm();
}
if (current.apiKey) {
return { ...current, apiKeySaved: Boolean(summary.api_key_saved) };
}
return {
provider: summary.provider === "daytona" ? "daytona" : "docker",
apiKey: "",
apiKeySaved: Boolean(summary.api_key_saved),
};
}
function buildSandboxPayload(form: SandboxForm): InstallSandboxInput {
if (form.provider === "docker") {
return { provider: "docker" };
}
const apiKey = form.apiKey.trim();
const payload: InstallSandboxInput = { provider: "daytona" };
if (apiKey) {
payload.api_key = apiKey;
}
return payload;
}
function focusInput(ref: { current: HTMLInputElement | null }): void {
window.setTimeout(() => ref.current?.focus(), 0);
}
@ -1798,6 +1945,26 @@ function renderObjectStoreSummaryRows(
);
}
function renderSandboxSummaryRows(
sandbox: InstallSessionResponse["sandbox"],
): ReactNode {
if (!sandbox) {
return <SummaryRow label="Sandbox" value="Not configured" />;
}
if (sandbox.provider === "daytona") {
return (
<>
<SummaryRow label="Sandbox" value="Daytona" />
<SummaryRow
label="Daytona API key"
value={sandbox.api_key_saved ? "Saved" : "Not set"}
/>
</>
);
}
return <SummaryRow label="Sandbox" value="Docker" />;
}
function describeGithubAppOwner(
owner: InstallGithubAppOwner | undefined,
): string {

View file

@ -247,6 +247,68 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/install/sandbox/test:
post:
operationId: testInstallSandbox
tags: [Install]
summary: Validate install sandbox configuration
description: Validates the browser-install sandbox-provider selection without persisting it. For Daytona, performs a cheap authenticated call against the Daytona SDK to verify the API key. For Docker, returns ok without further checks. Requires the one-time install token.
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/InstallSandboxInput"
responses:
"200":
description: Sandbox configuration validated successfully
content:
application/json:
schema:
$ref: "#/components/schemas/InstallSandboxValidationResponse"
"401":
description: Invalid or missing install token
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Sandbox validation failed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/install/sandbox:
put:
operationId: putInstallSandbox
tags: [Install]
summary: Save install sandbox configuration
description: Records the sandbox provider selected during browser install. Requires the one-time install token.
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/InstallSandboxInput"
responses:
"204":
description: Sandbox configuration recorded
"401":
description: Invalid or missing install token
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Invalid install input
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/install/github/token/test:
post:
operationId: testInstallGithubToken
@ -2566,6 +2628,10 @@ components:
oneOf:
- $ref: "#/components/schemas/InstallObjectStoreSummary"
- type: "null"
sandbox:
oneOf:
- $ref: "#/components/schemas/InstallSandboxSummary"
- type: "null"
github:
oneOf:
- $ref: "#/components/schemas/InstallGithubSummary"
@ -2715,6 +2781,40 @@ components:
manual_credentials_saved:
type: boolean
InstallSandboxValidationResponse:
description: Successful response from install-time sandbox validation.
type: object
required:
- ok
properties:
ok:
type: boolean
example: true
InstallSandboxInput:
description: Sandbox provider selected during browser install. `api_key` is required for Daytona and ignored for Docker.
type: object
required:
- provider
properties:
provider:
type: string
enum: [docker, daytona]
api_key:
type: string
InstallSandboxSummary:
description: Redacted summary of the sandbox provider selected during browser install.
type: object
required:
- provider
properties:
provider:
type: string
enum: [docker, daytona]
api_key_saved:
type: boolean
InstallGithubTokenTestInput:
description: Input for install-time GitHub token validation.
type: object

View file

@ -54,6 +54,12 @@ pub enum InstallObjectStoreSelection {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallSandboxSelection {
Docker,
Daytona,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallObjectStoreEnvPlan {
pub writes: Vec<envfile::EnvFileUpdate>,
@ -379,6 +385,24 @@ pub fn write_object_store_settings(
}
}
pub fn write_sandbox_settings(
doc: &mut toml::Value,
selection: InstallSandboxSelection,
) -> Result<()> {
let provider = match selection {
InstallSandboxSelection::Docker => "docker",
InstallSandboxSelection::Daytona => "daytona",
};
let root = root_table_mut(doc)?;
let run = ensure_table(root, "run")?;
let sandbox = ensure_table(run, "sandbox")?;
sandbox.insert(
"provider".to_string(),
toml::Value::String(provider.to_string()),
);
Ok(())
}
fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result<()> {
match previous_contents {
Some(contents) => {
@ -505,10 +529,10 @@ mod tests {
use super::{
InstallListenConfig, InstallObjectStoreCredentialMode, InstallObjectStoreSelection,
OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_MANAGED_COMMENT,
InstallSandboxSelection, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_MANAGED_COMMENT,
OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite, VaultSecretWrite,
default_web_url, merge_server_settings, persist_install_outputs_direct,
write_github_app_settings, write_object_store_settings,
write_github_app_settings, write_object_store_settings, write_sandbox_settings,
};
fn format_config_toml() -> String {
@ -855,6 +879,40 @@ name = "custom"
);
}
#[test]
fn write_sandbox_settings_records_docker_provider() {
let mut doc = toml::Value::Table(toml::Table::default());
write_sandbox_settings(&mut doc, InstallSandboxSelection::Docker)
.expect("docker sandbox selection should succeed");
assert_eq!(
doc.get("run")
.and_then(toml::Value::as_table)
.and_then(|run| run.get("sandbox"))
.and_then(toml::Value::as_table)
.and_then(|sandbox| sandbox.get("provider"))
.and_then(toml::Value::as_str),
Some("docker")
);
}
#[test]
fn write_sandbox_settings_records_daytona_provider() {
let mut doc = toml::Value::Table(toml::Table::default());
write_sandbox_settings(&mut doc, InstallSandboxSelection::Daytona)
.expect("daytona sandbox selection should succeed");
assert_eq!(
doc.get("run")
.and_then(toml::Value::as_table)
.and_then(|run| run.get("sandbox"))
.and_then(toml::Value::as_table)
.and_then(|sandbox| sandbox.get("provider"))
.and_then(toml::Value::as_str),
Some("daytona")
);
}
#[test]
fn persist_install_outputs_direct_only_removes_marked_object_store_keys() {
let dir = tempfile::tempdir().unwrap();

View file

@ -40,6 +40,16 @@ async fn build_daytona_client(
daytona_sdk::Client::new_with_config(sdk_config).await
}
/// Validate a Daytona API key by performing a single authenticated call. Used
/// at install time to confirm the operator-provided credential is accepted by
/// the Daytona control plane before persisting it. The call requests a single
/// sandbox listing entry to keep latency and quota impact minimal.
pub async fn validate_daytona_api_key(api_key: String) -> Result<(), daytona_sdk::DaytonaError> {
let client = build_daytona_client(Some(api_key)).await?;
client.list(None, Some(1), Some(1)).await?;
Ok(())
}
fn elapsed_ms(start: &Instant) -> u64 {
u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
}

View file

@ -97,6 +97,7 @@ serde_yaml = "0.9"
tracing-subscriber.workspace = true
async-trait.workspace = true
tokio-util.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
fabro-test = { workspace = true }
fabro-types = { path = "../fabro-types", features = ["test-support"] }

View file

@ -17,11 +17,13 @@ use fabro_config::Storage;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::envfile::EnvFileUpdate;
use fabro_install::{
InstallListenConfig, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
PendingSettingsWrite, VaultSecretWrite, merge_server_settings, persist_install_outputs_direct,
write_github_app_settings, write_object_store_settings, write_token_settings,
InstallListenConfig, InstallSandboxSelection, OBJECT_STORE_ACCESS_KEY_ID_ENV,
OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite, VaultSecretWrite,
merge_server_settings, persist_install_outputs_direct, write_github_app_settings,
write_object_store_settings, write_sandbox_settings, write_token_settings,
};
use fabro_model::Provider;
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
@ -219,6 +221,7 @@ struct PendingInstall {
llm: Option<LlmProvidersInput>,
server: Option<ServerConfigInput>,
object_store: Option<InstallObjectStoreState>,
sandbox: Option<InstallSandboxState>,
github: Option<GithubInstallState>,
pending_github_app: Option<PendingGithubApp>,
}
@ -394,6 +397,45 @@ impl InstallObjectStoreState {
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
enum InstallSandboxProvider {
Docker,
Daytona,
}
#[derive(Clone, Debug, Deserialize)]
struct InstallSandboxInput {
provider: InstallSandboxProvider,
api_key: Option<String>,
}
#[derive(Clone, Debug)]
enum InstallSandboxState {
Docker,
Daytona { api_key: InstallSecret },
}
impl InstallSandboxState {
fn as_session_value(&self) -> serde_json::Value {
match self {
Self::Docker => serde_json::json!({ "provider": "docker" }),
Self::Daytona { .. } => serde_json::json!({
"provider": "daytona",
"api_key_saved": true,
}),
}
}
fn to_persistence_selection(&self) -> InstallSandboxSelection {
match self {
Self::Docker => InstallSandboxSelection::Docker,
Self::Daytona { .. } => InstallSandboxSelection::Daytona,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct GithubTokenInput {
token: String,
@ -545,6 +587,11 @@ pub fn build_install_router(state: InstallAppState) -> Router {
"/install/object-store",
get(render_install_shell).put(put_install_object_store),
)
.route("/install/sandbox/test", post(post_install_sandbox_test))
.route(
"/install/sandbox",
get(render_install_shell).put(put_install_sandbox),
)
.route(
"/install/github/token/test",
post(post_install_github_token_test),
@ -699,6 +746,7 @@ async fn get_install_session(
"llm": redacted_llm(&pending_install),
"server": pending_install.server,
"object_store": redacted_object_store(&pending_install),
"sandbox": redacted_sandbox(&pending_install),
"github": redacted_github(&pending_install),
"prefill": {
"canonical_url": detect_canonical_url(&headers),
@ -867,6 +915,62 @@ async fn put_install_object_store(
StatusCode::NO_CONTENT.into_response()
}
async fn post_install_sandbox_test(
State(state): State<InstallAppState>,
headers: HeaderMap,
Query(query): Query<InstallTokenQuery>,
Json(input): Json<InstallSandboxInput>,
) -> Response {
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
return response;
}
observe_operator(&state, &headers);
let api_key = {
let pending_install = lock_unpoisoned(&state.pending_install, "install session");
match resolve_install_sandbox_state(pending_install.sandbox.as_ref(), input) {
Ok(InstallSandboxState::Docker) => {
return Json(serde_json::json!({ "ok": true })).into_response();
}
Ok(InstallSandboxState::Daytona { api_key }) => api_key.expose_secret().to_string(),
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
}
};
match daytona::validate_daytona_api_key(api_key).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => {
warn!(error = %err, "install sandbox validation failed");
install_error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("daytona credential validation failed: {err}"),
)
}
}
}
async fn put_install_sandbox(
State(state): State<InstallAppState>,
headers: HeaderMap,
Query(query): Query<InstallTokenQuery>,
Json(input): Json<InstallSandboxInput>,
) -> Response {
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
return response;
}
observe_operator(&state, &headers);
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
let selection = match resolve_install_sandbox_state(pending_install.sandbox.as_ref(), input) {
Ok(selection) => selection,
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
};
pending_install.sandbox = Some(selection);
info!(step = "sandbox", "install step completed");
StatusCode::NO_CONTENT.into_response()
}
fn trim_install_field(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
@ -882,6 +986,27 @@ fn default_local_object_store_root(state: &InstallAppState) -> String {
.to_string()
}
fn resolve_install_sandbox_state(
current: Option<&InstallSandboxState>,
input: InstallSandboxInput,
) -> Result<InstallSandboxState, String> {
match input.provider {
InstallSandboxProvider::Docker => Ok(InstallSandboxState::Docker),
InstallSandboxProvider::Daytona => {
let api_key = match trim_install_field(input.api_key) {
Some(value) => InstallSecret::new(value),
None => match current {
Some(InstallSandboxState::Daytona { api_key }) => {
InstallSecret::new(api_key.expose_secret())
}
_ => return Err("api_key is required for daytona".to_string()),
},
};
Ok(InstallSandboxState::Daytona { api_key })
}
}
}
fn resolve_install_object_store_state(
current: Option<&InstallObjectStoreState>,
input: InstallObjectStoreInput,
@ -1337,6 +1462,9 @@ async fn post_install_finish(
let Some(object_store) = pending_install.object_store else {
return missing_step_response("object_store");
};
let Some(sandbox) = pending_install.sandbox else {
return missing_step_response("sandbox");
};
let Some(llm) = pending_install.llm else {
return missing_step_response("llm");
};
@ -1360,7 +1488,19 @@ async fn post_install_finish(
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
if let Err(err) = write_sandbox_settings(&mut settings_doc, sandbox.to_persistence_selection())
{
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
let mut vault_secrets = Vec::new();
if let InstallSandboxState::Daytona { api_key } = &sandbox {
vault_secrets.push(VaultSecretWrite {
name: EnvVars::DAYTONA_API_KEY.to_string(),
value: api_key.expose_secret().to_string(),
secret_type: VaultSecretType::Environment,
description: None,
});
}
for provider in llm.providers {
let credential = AuthCredential {
provider: provider.provider,
@ -1522,6 +1662,15 @@ async fn post_install_finish(
{
*manual_credentials = None;
}
{
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
if matches!(
pending_install.sandbox.as_ref(),
Some(InstallSandboxState::Daytona { .. })
) {
pending_install.sandbox = Some(InstallSandboxState::Docker);
}
}
if let Some(finish_hook) = state.finish_hook.clone() {
let info = InstallFinishInfo {
@ -1698,6 +1847,9 @@ fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> {
if pending_install.object_store.is_some() {
steps.push("object_store");
}
if pending_install.sandbox.is_some() {
steps.push("sandbox");
}
if pending_install.llm.is_some() {
steps.push("llm");
}
@ -1747,6 +1899,13 @@ fn redacted_object_store(pending_install: &PendingInstall) -> serde_json::Value
)
}
fn redacted_sandbox(pending_install: &PendingInstall) -> serde_json::Value {
pending_install.sandbox.as_ref().map_or_else(
|| serde_json::Value::Null,
InstallSandboxState::as_session_value,
)
}
fn missing_step_response(step: &str) -> Response {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,

View file

@ -168,9 +168,31 @@ async fn put_install_object_store_local(app: &axum::Router, token: &str) {
put_install_object_store(app, token, r#"{"provider":"local"}"#).await;
}
async fn put_install_sandbox(app: &axum::Router, token: &str, body: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/sandbox")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("sandbox install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/sandbox").await;
}
async fn put_install_sandbox_docker(app: &axum::Router, token: &str) {
put_install_sandbox(app, token, r#"{"provider":"docker"}"#).await;
}
async fn configure_token_install(app: &axum::Router, token: &str) {
put_install_server(app, token, "https://fabro.example.com").await;
put_install_object_store_local(app, token).await;
put_install_sandbox_docker(app, token).await;
put_install_llm(app, token).await;
put_install_github_token(app, token, "brynary").await;
}
@ -320,6 +342,12 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
"/install/object-store",
Some(r#"{"provider":"local"}"#),
),
(
"POST",
"/install/sandbox/test",
Some(r#"{"provider":"docker"}"#),
),
("PUT", "/install/sandbox", Some(r#"{"provider":"docker"}"#)),
(
"POST",
"/install/github/token/test",
@ -574,6 +602,7 @@ async fn manual_object_store_session_is_redacted_and_blank_resubmit_preserves_cr
)
.await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
@ -650,6 +679,7 @@ async fn switching_object_store_from_manual_to_runtime_clears_saved_manual_crede
assert!(!rendered_session.contains("switch-secret-value"));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
@ -704,6 +734,7 @@ async fn runtime_object_store_finish_removes_managed_aws_keys_but_keeps_unmarked
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
@ -770,6 +801,14 @@ async fn token_install_finish_persists_settings_env_and_vault() {
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(settings.contains("https://fabro.example.com"));
assert!(settings.contains("strategy = \"token\""));
assert!(
settings.contains("[run.sandbox]"),
"settings.toml should contain [run.sandbox]"
);
assert!(
settings.contains("provider = \"docker\""),
"settings.toml should record explicit docker sandbox provider"
);
let resolved = ServerSettingsBuilder::from_toml(&settings)
.expect("settings should resolve")
.server;
@ -918,6 +957,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
.await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_sandbox_docker(&app, "test-install-token").await;
let manifest_response = app
.clone()
@ -1900,6 +1940,7 @@ async fn install_finish_failure_with_manual_credentials_does_not_leak_values() {
),
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
@ -1974,6 +2015,7 @@ async fn install_finish_failure_reports_only_env_keys_actually_removed() {
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
@ -2058,3 +2100,365 @@ async fn install_finish_failure_does_not_create_home_dev_token() {
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={storage_dev_token}")));
}
#[tokio::test]
async fn sandbox_docker_save_records_explicit_provider_in_session() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let validation_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"docker"}"#))
.unwrap(),
)
.await
.unwrap();
let validation_body = response_json(
validation_response,
StatusCode::OK,
"POST /install/sandbox/test",
)
.await;
assert_eq!(validation_body["ok"], true);
put_install_sandbox_docker(&app, "test-install-token").await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["sandbox"]["provider"], "docker");
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "sandbox")
);
}
#[tokio::test]
async fn sandbox_daytona_without_api_key_is_rejected() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/sandbox")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"daytona"}"#))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT /install/sandbox without api_key",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"api_key is required for daytona"
);
}
#[tokio::test]
async fn sandbox_daytona_test_endpoint_without_api_key_is_rejected() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"daytona"}"#))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/sandbox/test without api_key",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"api_key is required for daytona"
);
}
#[tokio::test]
async fn sandbox_daytona_resave_without_api_key_preserves_saved_key() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
let api_key = "dtn_keep_me";
put_install_sandbox(
&app,
"test-install-token",
&format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
)
.await;
put_install_sandbox(&app, "test-install-token", r#"{"provider":"daytona"}"#).await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["sandbox"]["provider"], "daytona");
assert_eq!(session_body["sandbox"]["api_key_saved"], true);
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}
#[tokio::test]
async fn sandbox_switching_from_daytona_to_docker_drops_saved_key() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_sandbox(
&app,
"test-install-token",
r#"{"provider":"daytona","api_key":"dtn_will_be_dropped"}"#,
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(settings.contains("provider = \"docker\""));
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), None);
}
#[tokio::test]
async fn sandbox_daytona_save_redacts_api_key_in_session() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let api_key = "dtn_should_not_leak";
put_install_sandbox(
&app,
"test-install-token",
&format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
)
.await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["sandbox"]["provider"], "daytona");
assert_eq!(session_body["sandbox"]["api_key_saved"], true);
let rendered = session_body.to_string();
assert!(!rendered.contains(api_key));
}
#[tokio::test]
async fn install_finish_requires_sandbox_step() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/finish",
)
.await;
assert_eq!(
finish_body["errors"][0]["detail"],
"install step 'sandbox' is incomplete"
);
}
#[tokio::test]
async fn daytona_install_finish_writes_settings_and_vault_secret() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
let api_key = "dtn_test_secret";
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_sandbox(
&app,
"test-install-token",
&format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
)
.await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(
settings.contains("[run.sandbox]"),
"settings.toml should contain [run.sandbox]"
);
assert!(
settings.contains("provider = \"daytona\""),
"settings.toml should record daytona sandbox provider"
);
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
async fn sandbox_daytona_test_endpoint_validates_real_api_key() {
let api_key = std::env::var(fabro_static::EnvVars::DAYTONA_API_KEY)
.expect("DAYTONA_API_KEY must be set for live test");
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"provider":"daytona","api_key":"{api_key}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::OK,
"POST /install/sandbox/test (live daytona)",
)
.await;
assert_eq!(body["ok"], true);
}

View file

@ -97,6 +97,9 @@ models/install-object-store-input.ts
models/install-object-store-summary.ts
models/install-object-store-validation-response.ts
models/install-prefill.ts
models/install-sandbox-input.ts
models/install-sandbox-summary.ts
models/install-sandbox-validation-response.ts
models/install-server-config-input.ts
models/install-session-response.ts
models/integration-webhooks-settings.ts

View file

@ -46,6 +46,10 @@ import type { InstallObjectStoreInput } from '../models';
// @ts-ignore
import type { InstallObjectStoreValidationResponse } from '../models';
// @ts-ignore
import type { InstallSandboxInput } from '../models';
// @ts-ignore
import type { InstallSandboxValidationResponse } from '../models';
// @ts-ignore
import type { InstallServerConfigInput } from '../models';
// @ts-ignore
import type { InstallSessionResponse } from '../models';
@ -298,6 +302,41 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
options: localVarRequestOptions,
};
},
/**
* Records the sandbox provider selected during browser install. Requires the one-time install token.
* @summary Save install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
putInstallSandbox: async (installSandboxInput: InstallSandboxInput, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'installSandboxInput' is not null or undefined
assertParamExists('putInstallSandbox', 'installSandboxInput', installSandboxInput)
const localVarPath = `/install/sandbox`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(installSandboxInput, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -433,6 +472,41 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(installObjectStoreInput, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Validates the browser-install sandbox-provider selection without persisting it. For Daytona, performs a cheap authenticated call against the Daytona SDK to verify the API key. For Docker, returns ok without further checks. Requires the one-time install token.
* @summary Validate install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
testInstallSandbox: async (installSandboxInput: InstallSandboxInput, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'installSandboxInput' is not null or undefined
assertParamExists('testInstallSandbox', 'installSandboxInput', installSandboxInput)
const localVarPath = `/install/sandbox/test`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(installSandboxInput, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -537,6 +611,19 @@ export const InstallApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['InstallApi.putInstallObjectStore']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Records the sandbox provider selected during browser install. Requires the one-time install token.
* @summary Save install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async putInstallSandbox(installSandboxInput: InstallSandboxInput, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.putInstallSandbox(installSandboxInput, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['InstallApi.putInstallSandbox']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -589,6 +676,19 @@ export const InstallApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['InstallApi.testInstallObjectStore']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Validates the browser-install sandbox-provider selection without persisting it. For Daytona, performs a cheap authenticated call against the Daytona SDK to verify the API key. For Docker, returns ok without further checks. Requires the one-time install token.
* @summary Validate install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async testInstallSandbox(installSandboxInput: InstallSandboxInput, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InstallSandboxValidationResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.testInstallSandbox(installSandboxInput, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['InstallApi.testInstallSandbox']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
@ -667,6 +767,16 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putInstallObjectStore(installObjectStoreInput, options).then((request) => request(axios, basePath));
},
/**
* Records the sandbox provider selected during browser install. Requires the one-time install token.
* @summary Save install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
putInstallSandbox(installSandboxInput: InstallSandboxInput, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putInstallSandbox(installSandboxInput, options).then((request) => request(axios, basePath));
},
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -707,6 +817,16 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): AxiosPromise<InstallObjectStoreValidationResponse> {
return localVarFp.testInstallObjectStore(installObjectStoreInput, options).then((request) => request(axios, basePath));
},
/**
* Validates the browser-install sandbox-provider selection without persisting it. For Daytona, performs a cheap authenticated call against the Daytona SDK to verify the API key. For Docker, returns ok without further checks. Requires the one-time install token.
* @summary Validate install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
testInstallSandbox(installSandboxInput: InstallSandboxInput, options?: RawAxiosRequestConfig): AxiosPromise<InstallSandboxValidationResponse> {
return localVarFp.testInstallSandbox(installSandboxInput, options).then((request) => request(axios, basePath));
},
};
};
@ -790,6 +910,17 @@ export class InstallApi extends BaseAPI {
return InstallApiFp(this.configuration).putInstallObjectStore(installObjectStoreInput, options).then((request) => request(this.axios, this.basePath));
}
/**
* Records the sandbox provider selected during browser install. Requires the one-time install token.
* @summary Save install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public putInstallSandbox(installSandboxInput: InstallSandboxInput, options?: RawAxiosRequestConfig) {
return InstallApiFp(this.configuration).putInstallSandbox(installSandboxInput, options).then((request) => request(this.axios, this.basePath));
}
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -833,5 +964,16 @@ export class InstallApi extends BaseAPI {
public testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig) {
return InstallApiFp(this.configuration).testInstallObjectStore(installObjectStoreInput, options).then((request) => request(this.axios, this.basePath));
}
/**
* Validates the browser-install sandbox-provider selection without persisting it. For Daytona, performs a cheap authenticated call against the Daytona SDK to verify the API key. For Docker, returns ok without further checks. Requires the one-time install token.
* @summary Validate install sandbox configuration
* @param {InstallSandboxInput} installSandboxInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public testInstallSandbox(installSandboxInput: InstallSandboxInput, options?: RawAxiosRequestConfig) {
return InstallApiFp(this.configuration).testInstallSandbox(installSandboxInput, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -67,7 +67,7 @@ import type { TimelineEntryResponse } from '../models';
export const RunsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -189,7 +189,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -231,7 +231,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -318,10 +318,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -401,7 +401,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -541,7 +541,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -625,7 +625,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -787,10 +787,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -832,7 +832,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -874,7 +874,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -914,7 +914,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1003,7 +1003,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = RunsApiAxiosParamCreator(configuration)
return {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1044,7 +1044,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1058,7 +1058,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1083,10 +1083,10 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1110,7 +1110,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1155,7 +1155,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1181,7 +1181,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1231,10 +1231,10 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1247,7 +1247,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1261,7 +1261,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1272,7 +1272,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1307,7 +1307,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
const localVarFp = RunsApiFp(configuration)
return {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1339,7 +1339,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1350,7 +1350,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1369,10 +1369,10 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.deleteRun(id, force, options).then((request) => request(axios, basePath));
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1390,7 +1390,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.getRunPullRequest(id, options).then((request) => request(axios, basePath));
},
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1426,7 +1426,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1446,7 +1446,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1484,10 +1484,10 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.retrieveRunGraph(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1497,7 +1497,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1508,7 +1508,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1516,7 +1516,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.startRun(id, startRunRequest, options).then((request) => request(axios, basePath));
},
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1543,7 +1543,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
*/
export class RunsApi extends BaseAPI {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1578,7 +1578,7 @@ export class RunsApi extends BaseAPI {
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1590,7 +1590,7 @@ export class RunsApi extends BaseAPI {
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1611,10 +1611,10 @@ export class RunsApi extends BaseAPI {
}
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1634,7 +1634,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1673,7 +1673,7 @@ export class RunsApi extends BaseAPI {
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1695,7 +1695,7 @@ export class RunsApi extends BaseAPI {
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1737,10 +1737,10 @@ export class RunsApi extends BaseAPI {
}
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1751,7 +1751,7 @@ export class RunsApi extends BaseAPI {
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1763,7 +1763,7 @@ export class RunsApi extends BaseAPI {
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1772,7 +1772,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1793,3 +1793,4 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).unpauseRun(id, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -15,7 +15,7 @@
/**
* Aggregate `+/-` line counts across all files in a diff (or the unified patch in degraded mode). Binary, sensitive, symlink, and submodule files contribute 0/0 since they have no line-level diff. Both fields are 0 for empty / pre-start envelopes.
* Aggregate `+/-` line counts across all files in a diff (or the unified patch in degraded mode). Binary, sensitive, symlink, and submodule files contribute 0/0 since they have no line-level diff. Both fields are 0 for empty / pre-start envelopes.
*/
export interface DiffStats {
/**
@ -27,3 +27,4 @@ export interface DiffStats {
*/
'deletions': number;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -27,3 +27,4 @@ export interface ForkRequest {
*/
'push'?: boolean | null;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -22,3 +22,4 @@ export interface ForkResponse {
'new_run_id': string;
'target': string;
}

View file

@ -76,6 +76,9 @@ export * from './install-object-store-input';
export * from './install-object-store-summary';
export * from './install-object-store-validation-response';
export * from './install-prefill';
export * from './install-sandbox-input';
export * from './install-sandbox-summary';
export * from './install-sandbox-validation-response';
export * from './install-server-config-input';
export * from './install-session-response';
export * from './integration-webhooks-settings';

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -39,3 +39,5 @@ export const InstallObjectStoreInputCredentialModeEnum = {
} as const;
export type InstallObjectStoreInputCredentialModeEnum = typeof InstallObjectStoreInputCredentialModeEnum[keyof typeof InstallObjectStoreInputCredentialModeEnum];

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -38,3 +38,5 @@ export const InstallObjectStoreSummaryCredentialModeEnum = {
} as const;
export type InstallObjectStoreSummaryCredentialModeEnum = typeof InstallObjectStoreSummaryCredentialModeEnum[keyof typeof InstallObjectStoreSummaryCredentialModeEnum];

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -21,3 +21,4 @@ export interface InstallPrefill {
'canonical_url': string;
'object_store_local_root': string;
}

View file

@ -0,0 +1,32 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Sandbox provider selected during browser install. `api_key` is required for Daytona and ignored for Docker.
*/
export interface InstallSandboxInput {
'provider': InstallSandboxInputProviderEnum;
'api_key'?: string;
}
export const InstallSandboxInputProviderEnum = {
DOCKER: 'docker',
DAYTONA: 'daytona'
} as const;
export type InstallSandboxInputProviderEnum = typeof InstallSandboxInputProviderEnum[keyof typeof InstallSandboxInputProviderEnum];

View file

@ -0,0 +1,32 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Redacted summary of the sandbox provider selected during browser install.
*/
export interface InstallSandboxSummary {
'provider': InstallSandboxSummaryProviderEnum;
'api_key_saved'?: boolean;
}
export const InstallSandboxSummaryProviderEnum = {
DOCKER: 'docker',
DAYTONA: 'daytona'
} as const;
export type InstallSandboxSummaryProviderEnum = typeof InstallSandboxSummaryProviderEnum[keyof typeof InstallSandboxSummaryProviderEnum];

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Successful response from install-time sandbox validation.
*/
export interface InstallSandboxValidationResponse {
'ok': boolean;
}

View file

@ -27,6 +27,9 @@ import type { InstallObjectStoreSummary } from './install-object-store-summary';
import type { InstallPrefill } from './install-prefill';
// May contain unused imports in some cases
// @ts-ignore
import type { InstallSandboxSummary } from './install-sandbox-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { InstallServerConfigInput } from './install-server-config-input';
/**
@ -37,6 +40,7 @@ export interface InstallSessionResponse {
'llm'?: InstallLlmSummary | null;
'server'?: InstallServerConfigInput | null;
'object_store'?: InstallObjectStoreSummary | null;
'sandbox'?: InstallSandboxSummary | null;
'github'?: InstallGithubSummary | null;
'prefill': InstallPrefill;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -21,3 +21,6 @@ export const LogDestination = {
} as const;
export type LogDestination = typeof LogDestination[keyof typeof LogDestination];

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -32,3 +32,4 @@ export interface ManifestArgs {
'preserve_sandbox'?: boolean;
'label'?: Array<string>;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -27,3 +27,4 @@ export interface RewindRequest {
*/
'push'?: boolean | null;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -24,3 +24,4 @@ export interface RewindResponse {
'archived': boolean;
'archive_error'?: string | null;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -44,3 +44,6 @@ export interface RunSummary {
'total_usd_micros'?: number | null;
'superseded_by'?: string | null;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -23,3 +23,4 @@ export interface RunSupersededByProps {
'target_node_id': string;
'target_visit': number;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -21,3 +21,6 @@ export interface ServerLoggingSettings {
'level': string | null;
'destination': LogDestination;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -23,3 +23,4 @@ export interface TimelineEntryResponse {
'visit': number;
'run_commit_sha'?: string | null;
}