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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
SandboxRuntimeManager,
resolveSandboxImage,
resolveSandboxPreviewProxyImage,
resolveSandboxRuntime,
} from "../../sandbox/SandboxRuntimeManager.ts";
import {
T3ProjectFileLoader,
Expand Down Expand Up @@ -414,7 +415,8 @@ export const make = Effect.gen(function* () {
}
return { kind: "legacy-host", cwd: legacyCwd } as const;
}
const runtime = thread.sandboxConfig?.runtime ?? "docker";
// Per-thread config wins, then the deployment default, then docker.
const runtime = thread.sandboxConfig?.runtime ?? resolveSandboxRuntime();
if (runtime !== "docker" && runtime !== "podman") {
return yield* new ProviderAdapterRequestError({
provider: "sandbox",
Expand Down
36 changes: 32 additions & 4 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type ProviderTurnTargetIdentity,
} from "@t3tools/contracts";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Equal from "effect/Equal";
Expand Down Expand Up @@ -68,6 +69,10 @@ import {
unbindAllSandboxProviderTargets,
unbindSandboxProviderTarget,
} from "../../sandbox/SandboxProviderProcess.ts";
import {
provisionThreadCredentialProxy,
refreshThreadCredentialProxy,
} from "../../sandbox/SandboxCredentialProxy.ts";
import { commandCenterProviderIsolationIssue } from "../security/CommandCenterProviderIsolation.ts";
const isModelSelection = Schema.is(ModelSelection);

Expand Down Expand Up @@ -755,6 +760,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const adapter = yield* registry.getByInstance(resolvedInstanceId);
if (executionTarget?.kind === "sandbox") {
bindSandboxProviderTarget(executionTarget, sandboxBindingOwner);
// Push the thread's credential document into its sidecar before the
// CLI starts. The secret goes over `podman exec` stdin and is bound
// to the thread here; the workspace container only ever learns the
// proxy URL and an opaque per-thread token.
const credentialFailure = yield* Effect.promise(() =>
provisionThreadCredentialProxy(threadId).then(
() => undefined,
(cause: unknown) => (cause instanceof Error ? cause.message : String(cause)),
),
);
if (credentialFailure !== undefined) {
return yield* toValidationError(
"ProviderService.startSession",
`Thread-scoped credential proxy is unavailable: ${credentialFailure}`,
);
}
}
yield* prepareMcpSession(threadId, resolvedInstanceId, input.projectId, effectiveCwd);
const session = yield* adapter
Expand Down Expand Up @@ -811,11 +832,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (

return sessionWithInstance;
}).pipe(
Effect.onError(() =>
Effect.sync(() => {
if (executionTarget?.kind === "sandbox") {
unbindSandboxProviderTarget(threadId, sandboxBindingOwner);
Effect.onError((cause) =>
Effect.gen(function* () {
if (executionTarget?.kind !== "sandbox") return;
// An upstream 401 means the injected secret was rotated or expired.
// Re-push the document so the next start picks up the current one;
// the thread token is preserved, so bound env stays valid.
if (/\b401\b|unauthorized/i.test(Cause.pretty(cause))) {
yield* Effect.promise(() =>
refreshThreadCredentialProxy(threadId).catch(() => false),
);
}
unbindSandboxProviderTarget(threadId, sandboxBindingOwner);
}),
),
withMetrics({
Expand Down
36 changes: 34 additions & 2 deletions apps/server/src/sandbox/ContainerSandboxBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,11 @@ export class ContainerSandboxBackend implements ThreadSandboxBackend {
String(limits.memoryBytes),
"--pids-limit",
String(limits.processCount),
"--storage-opt",
`size=${limits.diskBytes}`,
// `podman --remote` rejects `--storage-opt size=`, so deployments that
// talk to a user socket opt out. Dropping it is safe: the container
// rootfs is `--read-only`, and every writable path is either a
// quota'd volume (/workspace, /thread-data) or a size-bounded tmpfs.
...(storageQuotaDisabled() ? [] : ["--storage-opt", `size=${limits.diskBytes}`]),
"--read-only",
"--init",
"--tmpfs",
Expand Down Expand Up @@ -381,6 +384,19 @@ export class ContainerSandboxBackend implements ThreadSandboxBackend {
},
setupTimeoutMs,
);
const gitIdentity = sandboxGitIdentity();
if (gitIdentity !== undefined) {
for (const [key, value] of [
["user.name", gitIdentity.name],
["user.email", gitIdentity.email],
] as const) {
await this.#mustExec(
containerName,
{ executable: "git", args: ["-C", "/workspace/repo", "config", key, value] },
setupTimeoutMs,
);
}
}
if (input.bootstrap.inheritedPatch !== undefined) {
await this.#mustExec(
containerName,
Expand Down Expand Up @@ -884,6 +900,22 @@ function makeReady(
};
}

/** `T3_SANDBOX_CONTAINER_STORAGE_QUOTA=disabled` omits the `--storage-opt` pair. */
function storageQuotaDisabled(): boolean {
return process.env.T3_SANDBOX_CONTAINER_STORAGE_QUOTA?.trim().toLowerCase() === "disabled";
}

/**
* Git identity for in-sandbox commits, applied locally to the cloned repo.
* A fresh clone inherits no identity and the container has no global config,
* so without this every `git commit` inside the sandbox fails.
*/
function sandboxGitIdentity(): { readonly name: string; readonly email: string } | undefined {
const name = process.env.T3_SANDBOX_GIT_USER_NAME?.trim();
const email = process.env.T3_SANDBOX_GIT_USER_EMAIL?.trim();
return name && email ? { name, email } : undefined;
}

function boundedTimeout(seconds: number | undefined, fallback: number): number {
const value = seconds ?? fallback;
if (!Number.isInteger(value) || value < 1 || value > 600)
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/sandbox/DesktopHttpRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage";
import { authenticateRawRouteWithScope } from "../http.ts";
import { desktopGateway } from "./DesktopGatewayService.ts";
import { resolveSandboxDesktopMode } from "./SandboxRuntimeManager.ts";
import { ServerConfig } from "../config.ts";
import * as NodePath from "node:path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
Expand All @@ -38,8 +39,16 @@ export const desktopHttpRouteLayer = HttpRouter.add(
const gateway = desktopGateway;
if (action === "status") {
yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
return HttpServerResponse.jsonUnsafe(gateway.status(threadId));
// Headless deployments have no desktop to report on; say so plainly
// rather than leaving clients polling a readiness that never arrives.
return HttpServerResponse.jsonUnsafe(
resolveSandboxDesktopMode() === "disabled"
? { ...gateway.status(threadId), ready: false, readiness: "unavailable" as const }
: gateway.status(threadId),
);
}
if (resolveSandboxDesktopMode() === "disabled" && action === "view")
return HttpServerResponse.text("Desktop is disabled on this deployment", { status: 409 });
if (action === "automation-target") {
yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
const target = gateway.automationTarget(threadId);
Expand Down Expand Up @@ -82,6 +91,8 @@ export const desktopSignalHttpRouteLayer = HttpRouter.add(
if (!threadId) return HttpServerResponse.text("Not Found", { status: 404 });
if (action === "viewer-ticket") {
yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
if (resolveSandboxDesktopMode() === "disabled")
return HttpServerResponse.text("Desktop is disabled on this deployment", { status: 409 });
const issued = desktopGateway.issueViewerTicket(threadId);
const viewerUrl = `${prefix}${threadId}/view?ticket=${encodeURIComponent(issued.ticket)}`;
return HttpServerResponse.jsonUnsafe(
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/sandbox/DesktopSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@ export const REQUIRED_DESKTOP_BINARIES = [
"t3-desktop-webrtc",
] as const;

/**
* Headless images ship no desktop stack; only a shell and git are load-bearing
* for provisioning. Provider CLIs are probed separately and only warned about,
* because a thread may legitimately use whichever provider the image carries.
*/
export const REQUIRED_HEADLESS_BINARIES = ["sh", "git"] as const;
export const OPTIONAL_HEADLESS_BINARIES = ["claude", "codex"] as const;

export type DesktopCapability = {
readonly ready: boolean;
readonly missing: ReadonlyArray<string>;
/** Soft-probe misses. Present in headless mode; never blocks provisioning. */
readonly degraded?: ReadonlyArray<string>;
};

export type ThreadDesktopSession = {
Expand Down Expand Up @@ -55,16 +65,26 @@ export const desktopSessionForThread = (threadIdValue: string): ThreadDesktopSes

export const detectDesktopCapability = async (
executor: SandboxCommandExecutor,
mode: "enabled" | "disabled" = "enabled",
): Promise<DesktopCapability> => {
const missing: Array<string> = [];
for (const binary of REQUIRED_DESKTOP_BINARIES) {
const probe = async (binary: string) => {
const result = await executor.run({
executable: "sh",
args: ["-lc", 'command -v -- "$1" >/dev/null 2>&1', "sh", binary],
timeoutMs: 5_000,
});
if (result.exitCode !== 0) missing.push(binary);
return result.exitCode === 0;
};
const missing: Array<string> = [];
if (mode === "disabled") {
for (const binary of REQUIRED_HEADLESS_BINARIES)
if (!(await probe(binary))) missing.push(binary);
const degraded: Array<string> = [];
for (const binary of OPTIONAL_HEADLESS_BINARIES)
if (!(await probe(binary))) degraded.push(binary);
return { ready: missing.length === 0, missing, degraded };
}
for (const binary of REQUIRED_DESKTOP_BINARIES) if (!(await probe(binary))) missing.push(binary);
return { ready: missing.length === 0, missing };
};

Expand Down
Loading
Loading