Adversarial review of the branch produced findings across four areas. This addresses them, plus the Windows CI environment. Secrets. commit_container_snapshot baked the container's full env into the per-project snapshot image, so the shared OAuth token — and the AWS keys, git token and gateway master key — outlived revocation and were readable via docker inspect. Verified against Engine 29.6 that a commit body's config merges over the container's: keys cannot be dropped but can be overwritten, so all of them now commit as KEY=. clear_claude_token additionally rewrites images from earlier builds and reports honestly when a tag could not be rewritten. The recommendation to move the token out of env entirely was not taken, with reasoning: apiKeyHelper is a different auth method that outranks CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no file-based delivery exists. The durable exposure — the image — is what is closed here. Separately noted, not fixed: entrypoint.sh captures the token into the scheduler's .env inside the persisted volume. URL spoofing. Three call sites reached openUrl with container-controlled strings, one of which the review missed (the WebLinksAddon handler). The sign-in URL was scraped from container output with a longest-match tie-break and no userinfo check, so claude.ai@evil.tld rendered as "claude.ai…" in a truncating element. There is now one sanitizer in front of every sink — scheme allowlist, no userinfo, C0/C1 and quote rejection, host allowlist for the sign-in case, first-match — and the origin renders un-truncated. The toast is keyed so a changed URL remounts, closing a bait-and-switch where the user read one URL and clicked another. Migration. The rollback pin was best-effort: a tag failure was logged and the migration continued past remove_container, after which the final commit overwrote the only copy of the old system layer. It now aborts before anything destructive and reads the tag back. /var was destroyed while the ordinary recreate path preserves it — making the "safe" alternative to Reset more destructive than Reset's alternative; data-bearing subtrees are now detected and disclosed in the pre-flight rather than copied, since tarring a live database onto a different base's packages is a corruption risk. resume_migration now verifies the migration-state label instead of reporting success for a container that never swapped. dismiss actually resolves the record rather than leaving the feature permanently refusing to migrate. Start and Reset are guarded while a migration is live. Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and advertised URL are derived together so they cannot drift. Disabling it now stops it. App exit runs teardown concurrently under a budget with a visible shutting-down state instead of blocking for minutes. Auto-starts retry when Docker is not up yet, and the polling-recovery path now reconciles, so interrupted migrations are still recovered. Auth-bridge forwards are capped, closing a container-driven fd exhaustion. Windows CI. build-windows failed on this branch with "linker link.exe not found". The runner had no MSVC build tools and the workflow assumed a hand-provisioned machine, so a bare runner registers, accepts jobs and fails at link time after downloading the whole crate graph. The job now installs the VC++ workload when vswhere cannot find it, matching how it already conditionally installs Rust and Node. 192 Rust tests, 274 frontend tests, both builds clean, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useAppState } from "../../../store/appState";
|
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
|
import { useProjects } from "../../../hooks/useProjects";
|
|
import { useProjectSave } from "../../../hooks/useSaveState";
|
|
import { useContainerMigration } from "../../../hooks/useContainerMigration";
|
|
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
|
import Button from "../../ui/Button";
|
|
import OverflowMenu from "../../ui/OverflowMenu";
|
|
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
|
import ConfirmResetModal from "../ConfirmResetModal";
|
|
import MigrateContainerModal from "../MigrateContainerModal";
|
|
import OverviewTab from "./OverviewTab";
|
|
import SessionsTab from "./SessionsTab";
|
|
import AutomationTab from "./AutomationTab";
|
|
import ConfigTab from "./ConfigTab";
|
|
import FilesTab from "./FilesTab";
|
|
import BrowserTab from "./BrowserTab";
|
|
import { formatUptime } from "./format";
|
|
|
|
const TABS = [
|
|
{ id: "overview", label: "Overview" },
|
|
{ id: "sessions", label: "Sessions" },
|
|
{ id: "automation", label: "Automation" },
|
|
{ id: "config", label: "Config" },
|
|
{ id: "files", label: "Files" },
|
|
{ id: "browser", label: "Browser" },
|
|
] as const;
|
|
|
|
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
active: boolean;
|
|
}
|
|
|
|
/**
|
|
* The project promoted from a sidebar card to a first-class main-area view.
|
|
* Everything that used to spray out of `ProjectCard` as a modal lives here.
|
|
*/
|
|
export default function ProjectHome({ projectId, active }: Props) {
|
|
const { projects, remove } = useProjects();
|
|
const project = projects.find((p) => p.id === projectId);
|
|
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
const [confirmReset, setConfirmReset] = useState(false);
|
|
const [showMigration, setShowMigration] = useState(false);
|
|
const { runningSince, progress } = useAppState(
|
|
useShallow((s) => ({
|
|
runningSince: s.runningSince[projectId],
|
|
progress: s.containerProgress[projectId],
|
|
})),
|
|
);
|
|
|
|
// Re-render once a minute so the uptime line stays honest.
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
if (!active || runningSince === undefined) return;
|
|
const timer = setInterval(() => setTick((t) => t + 1), 60_000);
|
|
return () => clearInterval(timer);
|
|
}, [active, runningSince]);
|
|
|
|
const actions = useProjectActions(
|
|
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
|
);
|
|
const { save, saveState } = useProjectSave(
|
|
project ?? ({ id: projectId, name: "" } as never),
|
|
);
|
|
// Owned here, not in the modal: the run outlives the dialog, and the Overview
|
|
// banner has to keep showing progress and the report after it is dismissed.
|
|
const migration = useContainerMigration(
|
|
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
|
);
|
|
|
|
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className={`h-full flex items-center justify-center ${active ? "" : "hidden"}`}>
|
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
|
This project is no longer available.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isRunning = project.status === "running";
|
|
const isTransitioning =
|
|
project.status === "starting" || project.status === "stopping";
|
|
const isStopped = project.status === "stopped" || project.status === "error";
|
|
// Rebuilding on a new base swaps the container out, so it gates exactly like
|
|
// Reset does — with the extra condition that there is a container to migrate.
|
|
// An interrupted migration is excluded too: its action is Resume, on the
|
|
// Overview banner, not a fresh pre-flight.
|
|
//
|
|
// `probeSettled` is the fourth condition and it is not cosmetic. The probe
|
|
// takes ~6 s, and until it lands every delta the pre-flight renders reads as
|
|
// empty — so the dialog would tell the user there was nothing to copy while
|
|
// the backend was told not to copy anything.
|
|
const canMigrate =
|
|
isStopped &&
|
|
!actions.busy &&
|
|
!migration.running &&
|
|
!migration.interrupted &&
|
|
migration.probeSettled &&
|
|
!!project.container_id;
|
|
|
|
return (
|
|
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
|
{/* Header */}
|
|
<header className="flex-shrink-0 px-4 pt-3 pb-2 border-b border-[var(--border-color)]">
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div className="min-w-0">
|
|
<h1 className="text-base font-semibold text-[var(--text-primary)] truncate">
|
|
{project.name}
|
|
</h1>
|
|
<div className="mt-0.5 flex items-center gap-2 text-xs">
|
|
<ProjectStatusIndicator status={project.status} />
|
|
{isRunning && uptime && (
|
|
<span className="text-[var(--text-secondary)]">· {uptime}</span>
|
|
)}
|
|
{isTransitioning && progress && (
|
|
<span className="text-[var(--warning)] truncate">· {progress}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{isRunning ? (
|
|
<Button
|
|
size="md"
|
|
variant="primary"
|
|
disabled={actions.busy}
|
|
onClick={actions.openClaudeTerminal}
|
|
>
|
|
Open Claude Terminal
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="md"
|
|
variant="primary"
|
|
disabled={actions.busy || isTransitioning}
|
|
onClick={actions.handleStart}
|
|
>
|
|
Start
|
|
</Button>
|
|
)}
|
|
{isRunning && (
|
|
<>
|
|
<Button size="md" onClick={actions.openShell}>
|
|
Shell
|
|
</Button>
|
|
<Button size="md" onClick={() => setTab("files")}>
|
|
Files
|
|
</Button>
|
|
<Button size="md" disabled={actions.busy} onClick={actions.handleStop}>
|
|
Stop
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isTransitioning && (
|
|
<Button size="md" variant="danger" onClick={actions.handleStop}>
|
|
Force stop
|
|
</Button>
|
|
)}
|
|
<OverflowMenu
|
|
items={[
|
|
{
|
|
label: actions.backingUp ? "Backing up…" : "Back up container",
|
|
onSelect: actions.handleBackup,
|
|
disabled: actions.backingUp || !project.container_id,
|
|
},
|
|
{
|
|
label: "Update container base…",
|
|
onSelect: () => setShowMigration(true),
|
|
disabled: !canMigrate,
|
|
},
|
|
{
|
|
label: "Reset container…",
|
|
onSelect: () => setConfirmReset(true),
|
|
disabled: !isStopped || actions.busy,
|
|
danger: true,
|
|
},
|
|
{
|
|
label: "Remove project…",
|
|
onSelect: () => setConfirmRemove(true),
|
|
danger: true,
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div role="tablist" aria-label="Project sections" className="flex gap-1 mt-3 -mb-2">
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
role="tab"
|
|
id={`project-tab-${projectId}-${t.id}`}
|
|
aria-selected={tab === t.id}
|
|
aria-controls={`project-panel-${projectId}-${t.id}`}
|
|
onClick={() => setTab(t.id)}
|
|
className={`px-3 h-8 text-[13px] font-medium rounded-t-[var(--radius-control)] border-b-2 transition-colors ${
|
|
tab === t.id
|
|
? "text-[var(--text-primary)] border-[var(--accent)]"
|
|
: "text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)]"
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Panel */}
|
|
<div
|
|
role="tabpanel"
|
|
id={`project-panel-${projectId}-${tab}`}
|
|
aria-labelledby={`project-tab-${projectId}-${tab}`}
|
|
className="flex-1 min-h-0 overflow-y-auto"
|
|
>
|
|
{tab === "overview" && (
|
|
<OverviewTab
|
|
project={project}
|
|
save={save}
|
|
saveState={saveState}
|
|
actions={actions}
|
|
onOpenTab={setTab}
|
|
migration={migration}
|
|
canMigrate={canMigrate}
|
|
onOpenMigration={() => setShowMigration(true)}
|
|
/>
|
|
)}
|
|
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
|
{tab === "automation" && <AutomationTab project={project} />}
|
|
{tab === "config" && (
|
|
<ConfigTab project={project} save={save} saveState={saveState} />
|
|
)}
|
|
{tab === "files" && <FilesTab project={project} />}
|
|
{tab === "browser" && (
|
|
<BrowserTab project={project} active={active && tab === "browser"} />
|
|
)}
|
|
</div>
|
|
|
|
{showMigration && (
|
|
<MigrateContainerModal
|
|
projectName={project.name}
|
|
staleness={migration.staleness}
|
|
migration={migration}
|
|
// Closing is not cancelling — the run keeps going and the Overview
|
|
// banner keeps reporting it.
|
|
onClose={() => setShowMigration(false)}
|
|
/>
|
|
)}
|
|
{confirmReset && (
|
|
<ConfirmResetModal
|
|
projectName={project.name}
|
|
onCancel={() => setConfirmReset(false)}
|
|
onConfirm={() => {
|
|
setConfirmReset(false);
|
|
actions.handleReset();
|
|
}}
|
|
/>
|
|
)}
|
|
{confirmRemove && (
|
|
<ConfirmRemoveModal
|
|
projectName={project.name}
|
|
onCancel={() => setConfirmRemove(false)}
|
|
onConfirm={async () => {
|
|
setConfirmRemove(false);
|
|
try {
|
|
await remove(project.id);
|
|
} catch (e) {
|
|
useAppState.getState().pushToast({
|
|
kind: "error",
|
|
message: `Could not remove “${project.name}”`,
|
|
detail: String(e),
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|