Files
Triple-C/app/src/App.tsx
T
shadow-testandClaude Opus 5 2de00b3c55 Fix review findings: secrets in snapshots, URL spoofing, migration data loss
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>
2026-08-09 19:35:39 -07:00

267 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar";
import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome";
import AddProjectDialog from "./components/projects/AddProjectDialog";
import ToastHost from "./components/ui/ToastHost";
import StatusIndicator from "./components/ui/StatusIndicator";
import Button from "./components/ui/Button";
import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects";
import { useUpdates } from "./hooks/useUpdates";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useContainerProgress } from "./hooks/useContainerProgress";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
export default function App() {
const { checkDocker, checkImage, startDockerPolling } = useDocker();
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } =
useAppState(
useShallow(s => ({
sessions: s.sessions,
activeSessionId: s.activeSessionId,
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setProjects: s.setProjects,
setSttToggle: s.setSttToggle,
}))
);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const [shuttingDown, setShuttingDown] = useState(false);
/**
* Everything that can only be done once Docker answers. Called from the
* startup check *and* from the poller when the daemon shows up later — a
* session that launched before Docker was ready otherwise never reconciles
* container state or recovers an interrupted migration.
*/
const onDockerReady = useCallback(async () => {
checkImage();
// Reconcile project statuses against actual Docker container state,
// then refresh the project list so the UI reflects reality.
try {
setProjects(await reconcileProjectStatuses());
} catch {
// If reconciliation fails (e.g. Docker hiccup), just load from store
refresh();
}
}, [checkImage, setProjects, refresh]);
// Single STT instance bound to the active session. The mic lives in the
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
// store (registered below).
const { sendInput } = useTerminal();
const stt = useSTT(activeSessionId ?? "", sendInput);
useEffect(() => {
setSttToggle(stt.toggle);
}, [stt.toggle, setSttToggle]);
useContainerProgress();
useKeyboardShortcuts();
// Initialize on mount
useEffect(() => {
loadSettings();
let stopPolling: (() => void) | undefined;
checkDocker().then((available) => {
if (available) {
onDockerReady();
} else {
setShowInstallDialog(true);
stopPolling = startDockerPolling(onDockerReady);
}
});
refresh();
// Update detection
loadVersion();
const updateTimer = setTimeout(() => {
checkForUpdates();
checkImageUpdate();
}, 3000);
const cleanup = startPeriodicCheck();
return () => {
clearTimeout(updateTimer);
cleanup?.();
stopPolling?.();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// The backend prevents the window closing so it can stop containers first,
// which freezes the UI for several seconds. This says why.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
listen("app-shutting-down", () => setShuttingDown(true))
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.catch((e) => console.error("Failed to listen for shutdown:", e));
return () => {
cancelled = true;
unlisten?.();
};
}, []);
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
return (
<div className="flex flex-col h-screen p-3 gap-3 bg-[var(--bg-primary)]">
<TopBar />
<div className="flex flex-1 min-h-0 gap-3">
<Sidebar />
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
{tabOrder.length === 0 ? (
<WelcomeScreen />
) : (
<div className="w-full h-full">
{homeProjectIds.map((projectId) => (
<ProjectHome
key={projectId}
projectId={projectId}
active={activeTabKey === homeTabKey(projectId)}
/>
))}
{sessions.map((session) => (
<TerminalView
key={session.id}
sessionId={session.id}
active={session.id === activeSessionId}
/>
))}
</div>
)}
</main>
</div>
<StatusBar stt={stt} />
<ToastHost />
{showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)}
{shuttingDown && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
role="status"
aria-live="polite"
data-testid="shutdown-overlay"
>
<div className="flex flex-col items-center gap-2 px-6 text-center">
<StatusIndicator tone="busy" label="Shutting down" className="text-sm" />
<p className="text-[13px] text-[var(--text-secondary)]">
Stopping containers before quitting. This window will close on its own.
</p>
</div>
</div>
)}
</div>
);
}
/**
* First run is a checklist, not a paragraph: it reuses state the app already
* tracks and ends in a real button.
*/
function WelcomeScreen() {
const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState(
useShallow((s) => ({
dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists,
projects: s.projects,
openProjectHome: s.openProjectHome,
})),
);
const [showAdd, setShowAdd] = useState(false);
const steps: {
label: string;
state: boolean | null;
pendingLabel: string;
failLabel: string;
}[] = [
{
label: "Docker detected",
state: dockerAvailable,
pendingLabel: "Checking for Docker…",
failLabel: "Docker not available",
},
{
label: "Container image ready",
state: imageExists,
pendingLabel: "Checking for the image…",
failLabel: "Image not pulled yet — see Settings Container",
},
{
label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`,
state: projects.length > 0 ? true : false,
pendingLabel: "",
failLabel: "No projects yet",
},
];
return (
<div className="flex items-center justify-center h-full p-6">
<div className="w-full max-w-md">
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
Claude Code, sandboxed in a container.
</p>
<ol className="space-y-2 mb-5">
{steps.map((step) => (
<li
key={step.label}
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<StatusIndicator
tone={step.state === true ? "ok" : step.state === false ? "error" : "unknown"}
label={
step.state === true
? step.label
: step.state === false
? step.failLabel
: step.pendingLabel
}
className="text-[13px]"
/>
</li>
))}
</ol>
<div className="flex items-center gap-2">
<Button size="md" variant="primary" onClick={() => setShowAdd(true)}>
{projects.length === 0 ? "Add your first project" : "Add a project"}
</Button>
{projects.length > 0 && (
<Button size="md" onClick={() => openProjectHome(projects[0].id)}>
Open {projects[0].name}
</Button>
)}
</div>
<p className="mt-4 text-xs text-[var(--text-secondary)]">
Then start its container and press{" "}
<kbd className="px-1 py-0.5 font-mono bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[4px]">
Ctrl+T
</kbd>{" "}
to open a Claude terminal.
</p>
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
</div>
</div>
);
}