Commit Graph
234 Commits
Author SHA1 Message Date
shadow-testandClaude Opus 5 63f3c54b95 Publish preview builds as a prerelease instead of workflow artifacts
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m30s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Workflow artifacts do not work on this Gitea, in two different ways:

  * upload-artifact@v4 cannot run at all. @actions/artifact v2's isGhes()
    treats any GITHUB_SERVER_URL that is not github.com / *.ghe.com /
    *.localhost as GitHub Enterprise Server and throws before making a
    single request. act_runner sets it to this instance, so all three
    platforms died with GHESNotSupportedError — after paying for the
    whole Tauri build (run #265).
  * @v3 uploads succeed and the files are downloadable by direct URL,
    but Gitea does not *list* them: /api/v1/…/runs/<id>/artifacts returns
    total_count 0 and the run page shows nothing (verified on run #267).
    A build nobody can find is not a build.

So previews publish the way every other workflow here does: curl to the
releases API. One prerelease per preview, tagged `preview-<sha>`, with
all three platforms' bundles as assets — visible on the Releases page
with stable links.

The release is created in a job the three builds depend on rather than
get-or-created in each. They run concurrently, so per-job creation races
on one tag: the loser gets a 409, and the id parse then yields empty
while the step still reports success — the failure build-app.yml's
macOS job was hardened against after it happened for real. One creator
removes the race instead of handling it.

Asset upload keeps that hardening: delete-then-upload so a re-dispatch
replaces rather than 409s, --http1.1 and retries for the mid-stream
drops the macOS runner has produced (curl exit 92, exit 28), and an
explicit failure when a platform produced no bundles at all.

The `preview-` prefix is load-bearing: cleanup-releases.yml keeps recent
`v<x>.<y>.<z>` releases and separately deletes every release whose tag
does not start with `v[0-9]`, so previews are pruned by the cleanup
already in use and never crowd the real release list. sync-release.yml
is dispatch-only, so none of this reaches GitHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:38:06 -07:00
shadow-testandClaude Opus 5 f68d9c5788 Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The pane could only ever watch a browser something else had published.
This opens one: a URL and a viewport, launched inside the container and
bound so the pane picks it up. Two uses, one action — a sign-in page,
where the callback listener is *in* the container and the loop closes
with no host round trip and no auth bridge, and a dev server on container
loopback, which is how you watch a UI Claude is building.

Reachable from both places the question comes up: "Open a page…" in the
Browser tab, and an "In container" button on the terminal's URL prompt.

Verified first, because it decided the design: a second client cannot
join a bound browser. `chromium.connect()` against the published endpoint
times out in every URL form (`ws+unix://…`, with and without the trailing
path) — that socket speaks the dashboard's own transport, not the public
connect protocol. Whoever launches is therefore the only process that can
drive, so the helper is resident and holds the handle, and live resize
applies to pages we opened and never to `@playwright/mcp`'s. Those take
`--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the
docs now say.

The viewport is the interesting half. Resizing the *window* does nothing
to the page — the viewer is a CDP screencast, so a bigger window is the
same pixels drawn larger, which is why pages have been looking like they
were rendered small. `page.setViewportSize()` genuinely reflows: measured
against a `@media (max-width: 900px)` rule, it fires at 800×600 and
clears at 1440×900. Match-window mode pushes the pop-out's settled size
into it, debounced by generation counter because a drag emits `Resized`
continuously and each one costs a container exec.

Control is a polled JSON file in /tmp: no port, no second listener,
nothing added to the proxy's surface, and URLs travel as argv to `node`
so no shell ever parses one. A re-open with a helper already up
navigates instead of relaunching — otherwise the second page would throw
away the session the first one just signed into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:15:12 -07:00
shadow-testandClaude Opus 5 bd72781482 Install one Playwright tree, and notice when a container has two
Setup installed `playwright@latest` and `@playwright/cli@latest` together.
Verified on a real container, that produces a tree that looks right and is
broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm
hoists it, and `playwright@latest` (1.62.1) nests its own
`playwright-core@1.62.1` beside it. The two cores want different browser
revisions.

The browser step runs the *resolved* — hoisted — CLI, so it downloads
chromium-1237. Every script Claude writes says `require("playwright")`,
gets the nested 1.62.1, and dies with:

  Executable doesn't exist at …/chromium_headless_shell-1234/…

while the pane reports a browser installed, because one is. This is
deterministic, not bad luck: every container set up through the pane
lands in it.

So the viewer package is installed first, and the `playwright` version
installed after it is the one that package pins — read from the manifest
npm just wrote, falling back to `@latest` only if it cannot be read. One
core, one browser revision, both halves agreeing. Re-running "Set up
Playwright" repairs an already-split tree.

Detection now asks the question directly rather than listing a cache: it
asks each resolved copy for `chromium.executablePath()` and whether that
file exists — the viewer's copy *and* the one `require("playwright")`
returns, since those are routinely different. `needs_browser()` covers
"installed but not launchable", and the pane names both halves instead of
saying "install a browser" over a cache that visibly has one.

An absent field is "the probe didn't answer", never "skewed": containers
predating these fields must not be told their browsers are wrong. The
Rust side gets that from Option; the TypeScript mirror needed `!= null`,
which an existing test caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:04:06 -07:00
shadow-testandClaude Opus 5 1207a21aae Pin the preview build's uploads to upload-artifact@v3
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m32s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Run #265 — this workflow's first ever run — built the app on all three
platforms and then lost every bundle at the upload step:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

v4 bundles @actions/artifact v2, whose isGhes() treats any
GITHUB_SERVER_URL that is not github.com, *.ghe.com or *.localhost as
GitHub Enterprise Server and throws before making a single request.
act_runner sets that variable to this Gitea instance, so v4 cannot work
here on any runner or any OS — and it fails *after* the whole Tauri
build has been paid for.

v3 uses the v1 artifact API, which Gitea implements. Both options this
workflow relies on, `if-no-files-found: error` and `retention-days`,
exist in v3.

The other workflows never hit this because they publish by curling the
Gitea releases API instead. Noted at the top of the file, with the
isGhes rule, so the pin is not "upgraded" back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:00:06 -07:00
shadow-testandClaude Opus 5 a41d93ea46 Fix the review's findings: drag on pointer events, read the window back
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Ten findings from the review of the previous commit, all applied.

**The tab drag is now pointer events, not HTML5 drag-and-drop.** Two
independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks
HTML5 drag inside the webview on Windows, and it cannot just be turned
off — `TerminalView` needs Tauri's native drag-drop event, which is the
only one that carries dropped *file paths*. And an HTML5 drag carries a
`DataTransfer`: released over any text field in the app, the default
handler types `term:<uuid>` into it, and in Config that is then saved
with the project. Pointer events have neither problem, and the drag is
measured from the tabs on screen rather than from the event target, so
the marker and the drop agree even over the marker itself. Escape
abandons a drag; a press under 4px stays a click; the click that ends a
drag does not select.

**`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound
on `document` in the capture phase, so in any input — the rename field,
Config, Settings — it was taking the OS's extend-selection chord *and*
silently reordering the strip. Guarded by `inTextField()`, which
excludes xterm's helper textarea: that is an input-method shim, and the
terminal is where the shortcut matters most.

**The pop-out's state is read from the window, never remembered.** The
pane is unmounted whenever another Project Home sub-tab is selected, so
"Keep on top" came back Off over a window still floating on top.
`get_browser_view_popout_state` returns both facts from the window
itself, and the change event carries them. `poppedOut` is tri-state:
until the answer arrives the iframe is not mounted, because guessing
"not popped out" is what flashes a second viewer onto the browser.

Also: `popout::close` and the off-status emit in the supervisor are
behind the same epoch guard as the deregistration above them, so a
supervisor whose teardown outlives a restart can no longer destroy the
*new* session's window; `close()` returns its `destroy()` error instead
of logging it and reporting success, since the pane restores its iframe
on success; the drop marker is `pointer-events-none` and is placed
before the first *visible* tab at or past the slot, so it neither
refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and
the "Keep on top" Toggle's accessible name now matches its visible text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 07:40:51 -07:00
shadow-testandClaude Opus 5 d73096c937 Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two things the UI couldn't do: rearrange the tab strip, and watch the
browser while working somewhere else.

**Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on
the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for
the same thing without a mouse. Reordering deliberately does not select
what it moves, so a drag aimed at a background tab doesn't yank the main
area away from a terminal mid-run. A tab being renamed is not draggable —
a draggable ancestor swallows the mouse-drag that selects text in its
input.

**Pop the browser view out.** `browser_view/popout.rs` opens the view's
existing token-bearing loopback URL as a second OS window, with a
"Keep on top" toggle so it can float above the app. Window-only: the
viewer, the proxy and the container are untouched, so popping out and
back interrupts nothing.

Three things it rests on:

- No capability lists that window, so it has no IPC surface — right for a
  page served out of a container, and it must stay that way.
- The app CSP is irrelevant to it: `frame-src` constrains what the app's
  document may *embed*, and this is a top-level document. The port range
  and the token gate are what actually protect it, unchanged.
- The window is owned by the session, so the supervisor's teardown closes
  it. A window onto a viewer that no longer exists is worse than none.

The pane drops its iframe while popped out — two viewers can both *drive*
the browser, and two cursors on one page is not a feature.

`lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It
fires for every window and its body stops every container and exits, so
without the guard closing a pop-out would quit the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 06:50:40 -07:00
jknapp 57b6b71772 Merge pull request #18: Inject the corporate CA certificate into containers
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m12s
Build App / build-macos (push) Successful in 2m40s
Build App / build-windows (push) Successful in 5m31s
Build App / build-linux (push) Successful in 5m22s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
v0.3.84-mac v0.3.84-win v0.3.84
2026-08-10 18:48:53 +00:00
shadow-test 77567ac2ae Merge remote-tracking branch 'origin/main' into feature/corporate-ca
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
# Conflicts:
#	app/src/lib/tauri-commands.ts
2026-08-10 11:20:23 -07:00
jknapp 247f03b48c Merge pull request #17: Browser view — find every Playwright, set one up in two clicks, bake the runtime libraries
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m18s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m25s
Build App / build-linux (push) Successful in 5m19s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
v0.3.78-mac v0.3.78-win v0.3.78 v0.3.81-mac v0.3.81-win v0.3.81
2026-08-10 18:19:25 +00:00
jknapp 31c73adb13 Merge pull request #16: Fix shared Claude auth — whole sign-in URL, recoverable rejected code
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 6m40s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-08-10 17:58:57 +00:00
shadow-testandClaude Opus 5 4fdfed7955 Bake the browser's runtime libraries into the base image
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m13s
Build Container / build-container (pull_request) Successful in 13m11s
Build App / build-linux (pull_request) Successful in 6m53s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
`npx playwright install chromium` downloaded ~150 MB of browser that then
died with "error while loading shared libraries: libglib-2.0.so.0" —
verified, not inferred, against the current image. The image shipped none
of Chromium's shared libraries, which is why `apt install
google-chrome-stable` looked like the cure: apt was quietly installing the
same set as Chrome's own dependencies.

Installing them at runtime instead converges on the worst possible state.
The libraries land in the container's writable layer, so they are re-paid
after every Reset and *lost* on base-image migration, which replays apt
from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the
home volume, and survive both — leaving a 400 MB browser present with its
libraries gone. So the libraries are baked and the browsers are not: each
half now lives where it already persists.

The layer runs `npx --yes playwright@latest install-deps chromium` rather
than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition
renamed a swathe of these packages (libasound2t64, libatk1.0-0t64,
libglib2.0-0t64, …) and a new Chromium dependency would drift straight back
into the launch failure this exists to prevent; letting Playwright name its
own dependencies is self-maintaining. It sits immediately after Node — npx
is its only prerequisite — and well above the shim COPYs, so editing a shim
does not re-run it.

The `--dry-run` that follows is a build-time assertion, not decoration: on a
platform Playwright has no list for, `install-deps` prints a warning and
returns having installed **nothing, with exit status 0**. Without the
assertion that ships a broken image behind a clean build log.

Measured, on a build of this file with the layer applied over an otherwise
identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed
(2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by
trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which
pulls libllvm20. A chromium-only apt list measures 247 MiB against
install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji
fonts, kept because the base ships no fonts at all and every page this
feature exists to display would otherwise render as tofu.

Verified on real builds, both architectures: a `--platform linux/arm64`
build of this file installs the same 99 packages and passes the same
assertion. On the new amd64 image, `playwright install chromium` with no
`--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34
and loads a page; on the old image the identical script fails on
libglib-2.0.so.0.

`install.rs` no longer runs `install-deps` unconditionally — that would be a
minutes-long apt run for nothing on a current image. It asks
`install-deps --dry-run` first and skips the install when everything is
present, saying which of the two happened on the progress stream. The check
is Playwright's rather than a probe of our own for library names, so check
and fix cannot disagree about what the dependency set is. Note that
`--dry-run` exits 0 both when everything is installed and when Playwright
has no list for the platform, so the verdict is read from its output.

Containers on older images stay the normal case until people migrate, and
they still work: on such an image the simulation cannot even resolve the
package names (the index is cleaned in every base image), which reports as
"couldn't tell" and installs — the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:57:21 -07:00
Claude 7a5823cb2b Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container
fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API
requests. There was no mechanism at all: installing the certificate by hand
inside a container is lost on Reset and had to be repeated per project.

A global CA path in AppSettings with a per-project override on Project, taking
either a single certificate file or a directory. It is bind-mounted read-only
at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by
entrypoint.sh on every start, so it survives recreation, migration and Reset.

Four things this gets right that are easy to get wrong:

* update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely
  copied in is ignored in silence. Certificates are renamed, by
  container_cert_name() in Rust and a mirrored few lines of shell.
* The system store only serves curl/git/apt. Node — and so Claude Code itself —
  needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and
  Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil
  (libnss3-tools, added to the image).
* Those vars are set from Rust at creation, never exported by the entrypoint —
  a terminal is a docker exec and sees nothing the entrypoint exported. They are
  emitted empty when no CA is configured, since docker commit bakes env into the
  snapshot image.
* triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so
  a CA rotated in at the same location still forces a recreation.

Verified end to end against a real container and a self-signed CA: curl, node,
python and git all complete a TLS handshake against a server signed by it and
all three fail in the same container without it; the env vars are visible from
a docker exec session; the store is cleaned when the setting is cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:40:21 -07:00
shadow-testandClaude Opus 5 a5bcc462a7 Browser view: find every Playwright, and set one up in two clicks
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Detection missed the npx cache, so a Playwright installed through Claude
Code's MCP setup (`npx @playwright/mcp@latest`, which unpacks into
~/.npm/_npx/<hash>/node_modules and no node_modules at all) was invisible.
The probe now globs that cache alongside the existing roots and reports
every root it consulted.

It also read `has_bind` off whichever manifest resolved first. Verified
that npm does not hoist for global installs and that the `playwright`
wrapper ships no types/types.d.ts, so `npm i -g playwright` made the pane
call a current build "predates browser.bind()". The probe now hops from
the wrapper to its nested playwright-core.

The messages no longer offer `@playwright/mcp` as a way through setup: it
bundles a playwright-core that binds but never `@playwright/cli`, so that
route could not have worked. It is named only for what it does do.

New `install.rs` + two commands do the setup, streaming on the existing
`container-progress` event and re-probing on success:

  * playwright + @playwright/cli into /workspace as `claude`, --no-save.
    /workspace is not a bind mount (projects mount at
    /workspace/{mount_name}), so nothing of the user's is touched, no sudo
    is needed, and Node resolves it from scripts in the project.
  * A browser, as its own action with the size stated first: apt libraries
    as root, then the download, then a real headless launch to prove it
    works. The base image ships none of Chromium's shared libraries, which
    is why a download could succeed and the browser still not start.
    Chromium and the Chrome channel are both offered — @playwright/mcp
    asks for `chrome` specifically. A certificate failure is reported as a
    container trust-store problem rather than a broken install.

Installing is always user-initiated; opening the tab only probes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:15:02 -07:00
shadow-testandClaude Opus 5 c3f92674b1 Fix the shared Claude auth flow: whole sign-in URL, recoverable rejected code
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two compounding bugs made `claude setup-token` unusable, both measured against
2.1.226 under a pty rather than reasoned about.

**The sign-in URL was truncated.** The CLI emits it as an OSC 8 hyperlink and
slices the *visible* text of that hyperlink to the terminal width: a 346
character URL arrives at 80 columns as five separate hyperlink emissions, each
carrying the whole URL in its parameter and 80 characters of it on screen. The
transcript scraper picked up the first slice — a URL that parses, points at
claude.com, and cannot authorise anything. The ANSI stripper now surfaces the
OSC 8 target and `claude-token-link` carries it to the UI, which prefers it over
the scraped text. It still goes through `sanitizeRelayUrl` with the
ANTHROPIC_SIGN_IN_HOSTS allowlist before display and again before `openUrl` — an
OSC 8 parameter is never rendered, which makes it the easier place to hide a
hostile host, not a trusted one. The wrapped-display fallback is kept for CLI
versions that print a bare URL.

**A rejected code hung the flow.** On a bad paste the CLI prints `OAuth error:
Invalid code…` / `Press Enter to retry.` and blocks on stdin instead of exiting;
nothing recognised that, so the exec sat until the 15-minute timeout with the UI
still saying "Finishing sign-in". Given the first bug handed the user a truncated
URL, an invalid code was the likely first outcome. The streamed output is now
scanned for that message, `claude-token-code-rejected` reopens the input with an
explanation, and the Enter is sent so the next code has a prompt to land in —
bounded by MAX_CODE_ATTEMPTS, after which the flow reports a failure. An
undeterminable exec exit status is logged rather than silently read as success.

**A wrapped token was rejected *and* leaked.** `stty cols` fails silently, and an
80-column fallback splits the ~103 character token across two lines: the parser
saw a too-short fragment and failed, while the redactor masked the first line —
which carries the `sk-ant-` marker — and printed the second, the tail of a live
credential, to the UI in clear. `scan_credential_body` now reassembles a run
across hard wraps and both the parser and the redactor use it, so they cannot
disagree about where a credential ends. A join only happens across a break at a
plausible terminal margin (>= 40 columns) and only for a run not already long
enough to be a whole credential — without that second guard a repainting TUI
welds one frame's token onto the next frame's first word. The length floor is
applied to the reassembled body, so a fragment is still never accepted.

Also: `stty cols` raised 200 -> 400 (the URL alone needs ~350), and `ESC ( B` is
handled as the three-byte charset designation it is — it prefixes every repaint
frame, and treating it as two bytes emitted a stray `B` that could glue itself
onto a token and make the parser refuse it.

`submit_claude_token_code`'s single-write behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 09:57:16 -07:00
jknapp 584fcdd837 Merge pull request #15: create the WOW64 junctions in the workflow instead of by hand
Build App / compute-version (push) Successful in 16s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m21s
Build App / build-linux (push) Successful in 5m48s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 14s
Moves the last hand-made piece of the Windows fix into version control. Verified by deleting the manual junctions from the build VM first, so CI had to recreate them from scratch.
v0.3.76-mac v0.3.76-win v0.3.76
2026-08-10 15:00:25 +00:00
shadow-testandClaude Opus 5 2c014fd752 CI: create the WOW64 junctions in the workflow instead of by hand
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m29s
Build App / build-windows (pull_request) Successful in 5m52s
Build App / build-linux (pull_request) Successful in 6m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The Windows fix was the only part of it living outside git — two
junctions created by hand on the build VM. Rebuild that VM, add a second
Windows runner, or reset the SYSTEM profile and Windows builds break
again with an error that points nowhere near the cause.

Tauri downloads candle.exe, light.exe and makensis.exe, and all three
are 32-bit. A runner running as SYSTEM has %LOCALAPPDATA% under
C:\Windows\System32\config\systemprofile, and WOW64 redirection serves
32-bit processes reading System32 from SysWOW64, where those directories
do not exist. The bundlers cannot see their own folder: candle exits
0x80131700, makensis reports "Unable to start child process, error 0x2",
and Tauri surfaces neither — only "failed to run candle.exe".

The job now junctions the SysWOW64 view onto the System32 originals when
it detects a profile inside System32, and skips entirely otherwise, so a
runner running as a normal user is unaffected. Idempotent, and written
with goto rather than nested blocks to avoid the delayed-expansion trap
that already bit the MSVC step.

Verified rather than assumed: the hand-made junctions were deleted from
the build VM before this was pushed, so this run has to recreate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 07:52:34 -07:00
jknapp 43e9959e40 Merge pull request 'Add llama.cpp + OpenAI backends, URL relay, browser view, and base-image migration' (#14) from feature/model-backends-and-browser into main
Build App / compute-version (push) Successful in 12s
Build Container / build-container (push) Successful in 50s
Build App / build-macos (push) Successful in 2m34s
Build App / build-windows (push) Successful in 5m25s
Build App / build-linux (push) Successful in 6m40s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 14s
2026-08-10 06:23:43 +00:00
jknapp e05156fd0e Merge branch 'main' into feature/model-backends-and-browser
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 33s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m12s
Build App / build-linux (pull_request) Successful in 7m40s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
2026-08-10 06:13:40 +00:00
jknapp aca6c49e3c Merge pull request #13: Drop MCP management; add permission modes, Project Home, Auth Bridge and shared auth
Build App / compute-version (push) Successful in 11s
Build Container / build-container (push) Successful in 1m45s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 5m6s
Build App / build-linux (push) Successful in 6m31s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Claude Code absorbed MCP natively, so Triple-C stops managing it. Also adds permission modes, Project Home, Tier-1 accessibility fixes, the Auth Bridge, shared Claude authentication and the scheduler UI.

8 commits plus review fixes. 87 frontend tests, 34 Rust tests.
2026-08-10 05:43:09 +00:00
shadow-testandClaude Opus 5 0aa8315514 CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m26s
Build App / build-windows (pull_request) Successful in 4m51s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help: makensis.exe is 32-bit like candle.exe
and failed the same way ("Unable to start child process, error 0x2").
The cause was WOW64 redirection sending 32-bit processes reading
C:\Windows\System32 to SysWOW64, where the toolset directory does not
exist.

The build VM now carries junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified on the runner: candle.exe reports WiX
3.14.1.8722 and makensis reports v3.11, both exiting 0 from the path
that previously failed.

Both targets build again, so the .msi comes back. Artifact collection
fails if either installer is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:34:59 -07:00
shadow-testandClaude Opus 5 29fd7de909 CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m40s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m13s
Build App / build-linux (pull_request) Successful in 6m29s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help, because the problem was never WiX.
makensis.exe is 32-bit exactly like candle.exe and light.exe, lives in
the same SYSTEM-profile cache, and failed the same way — "Unable to
start child process, error 0x2" instead of 0x80131700.

The cause is WOW64 redirection: a 32-bit process reading
C:\Windows\System32 is served C:\Windows\SysWOW64, where the toolset
directory does not exist, so the bundlers cannot see their own folder.

The build VM now carries two junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified afterwards on the runner: candle.exe
reports "WiX Toolset Compiler version 3.14.1.8722" and exits 0, and
makensis reports v3.11 and exits 0 — both from the same path that failed
before.

So both targets build again and the .msi asset comes back. Artifact
collection fails if either installer is missing rather than tolerating
an empty directory.

This is a host-side patch for a runner running as SYSTEM. A runner
running as a normal user has a LOCALAPPDATA outside System32 and needs
none of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:34:43 -07:00
shadow-testandClaude Opus 5 fdc161fd9c CI: build NSIS only on Windows, dropping the MSI target
Build App / compute-version (pull_request) Successful in 17s
Build Container / build-container (pull_request) Successful in 1m22s
Build App / build-macos (pull_request) Successful in 2m22s
Build App / build-windows (pull_request) Failing after 4m41s
Build App / build-linux (pull_request) Successful in 5m51s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. On a SYSTEM-run runner Tauri
caches WiX under C:\Windows\system32\config\systemprofile\..., and WOW64
redirection sends 32-bit processes to SysWOW64 where that directory does
not exist, so candle exits 0x80131700. Tauri aborts the whole bundle on
one target's failure, so the MSI was suppressing the NSIS installer too
and Windows produced no artifact at all.

NSIS is what the project already relies on for Windows upgrades. Drops
the .NET 3.5 gate, which existed only for WiX; keeps the MSVC step,
which is what makes the app link. Artifact collection now fails when no
installer is produced rather than tolerating an empty directory.

To restore the MSI, run the runner as a normal user and set
--bundles msi,nsis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:24:42 -07:00
shadow-testandClaude Opus 5 98a6c8fd56 CI: build NSIS only on Windows, dropping the MSI target
The MSI target needs WiX, whose candle.exe and light.exe are 32-bit. On
a runner running as SYSTEM, Tauri caches the WiX toolset under
%LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\..., and WOW64
redirection points 32-bit processes at SysWOW64, where that directory
does not exist. candle.exe cannot see its own folder, the CLR fails to
start, and it exits 0x80131700 — surfaced only as "failed to run
candle.exe".

Proven by running the identical toolset, as the same SYSTEM identity,
from C:\wixtest (exit 0) versus the systemprofile path (0x80131700).

Because Tauri aborts the entire bundle when one target fails, the MSI
was also suppressing the NSIS installer — so Windows produced no
artifact at all. NSIS is what the project already relies on for Windows
upgrades, so dropping MSI costs the .msi asset and nothing else.

Removes the .NET 3.5 gate, which only existed for WiX. The MSVC step
stays: that is what makes the app link, and it works.

Artifact collection now fails when no NSIS installer is present instead
of tolerating an empty directory with 2>nul, so a silent packaging
regression cannot pass as a green build again.

To restore the MSI later, run the runner as a normal user — whose
LOCALAPPDATA sits outside System32 — and set --bundles msi,nsis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:24:29 -07:00
shadow-testandClaude Opus 5 763af91042 Revert: LOCALAPPDATA override does not move Tauri's WiX cache
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Failing after 4m35s
Build App / build-linux (pull_request) Successful in 5m6s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Rust's `dirs` crate resolves LOCALAPPDATA on Windows through
SHGetKnownFolderPath, which reads the process token rather than the
environment, so the override changed nothing and 32-bit candle.exe still
hit WOW64 redirection under the SYSTEM profile.

Removing it rather than leaving a plausible-looking non-fix in the
workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:19:12 -07:00
shadow-testandClaude Opus 5 c71e54a35f Revert: LOCALAPPDATA override does not move Tauri's WiX cache
It looked right and did nothing. Rust's `dirs` crate resolves
LOCALAPPDATA on Windows through SHGetKnownFolderPath, which reads the
process token rather than the environment, so Tauri still cached the WiX
toolset under the SYSTEM profile and 32-bit candle.exe still hit WOW64
redirection.

Removing it rather than leaving a plausible-looking non-fix in the
workflow. The diagnosis in the previous commit stands; only the remedy
was wrong. Running the runner as a normal user, whose token resolves
LOCALAPPDATA outside System32, is the actual fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:18:58 -07:00
shadow-testandClaude Opus 5 03384409e7 CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
Build App / compute-version (pull_request) Successful in 13s
Build Container / build-container (pull_request) Successful in 44s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / build-windows (pull_request) Failing after 4m39s
Build App / build-linux (pull_request) Successful in 6m44s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. A SYSTEM-run runner has
%LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, where
Tauri caches the WiX toolset — and WOW64 redirection sends 32-bit
processes reading System32 to SysWOW64, which has no such directory. The
CLR then fails to start with 0x80131700 and Tauri reports only "failed
to run candle.exe".

Verified: the same binary and identity exits 0 from C:\wixtest and
0x80131700 from the systemprofile path.

Pointing LOCALAPPDATA outside System32 avoids redirection, needs no
stored credential, and is a no-op for runners already running as a
normal user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:12:27 -07:00
shadow-testandClaude Opus 5 b41077e799 CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
build-windows compiled and linked fine but died at bundling with only
"failed to run candle.exe". The real cause was neither .NET nor the
runner identity, both of which I chased first and was wrong about.

candle.exe and light.exe are 32-bit. A runner running as SYSTEM has
%LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\AppData\Local,
which is where Tauri caches the WiX toolset. WOW64 redirection sends any
32-bit process reading C:\Windows\System32 to C:\Windows\SysWOW64 — and
the WixTools directory exists only in the 64-bit view. So candle.exe
could not see its own directory, the CLR failed to start, and the
process exited 0x80131700, surfaced in the Application event log as
".NET Runtime version 4.0.30319.0 - This application could not be
started."

Proven rather than assumed: copying the identical toolset to C:\wixtest
and running it as the same SYSTEM identity exits 0, while the
systemprofile path exits 0x80131700. Test-Path confirms the WOW64 view
of that directory does not exist.

Pointing LOCALAPPDATA at a path outside System32 avoids redirection.
This fixes it for any runner running as a service or as SYSTEM, without
needing a stored user credential, and is a no-op where the runner
already runs as a normal user.

For the record, two earlier theories were wrong. .NET 3.5 was missing
and is now installed from the ISO payload, but candle targets .NET 4.x
(its config uses loadFromRemoteSources, a 4.0-only element) so that was
never the blocker. Adding explicit supportedRuntime entries changed
nothing. Both are documented here so the next person does not repeat
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:12:11 -07:00
shadow-testandClaude Opus 5 c6bb7fdf1d CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-windows (pull_request) Failing after 5m14s
Build App / build-linux (pull_request) Successful in 6m2s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
%VSEXIT% and %ERRORLEVEL% inside a parenthesised cmd block are
substituted at parse time, not run time, so the installer's real exit
code was never read. Uses delayed expansion now.

Also checks for the .NET 3.5 runtime before building: WiX candle.exe
needs it, and Tauri aborts the whole bundle when the MSI target fails,
which silently suppresses the NSIS installer too. Fails early with the
exact dism command rather than at bundle time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:50:15 -07:00
shadow-testandClaude Opus 5 704d3b8f79 CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Two follow-ups to the provisioning step.

The exit-code check never worked. %VSEXIT% and %ERRORLEVEL% inside a
parenthesised cmd block are substituted when the block is PARSED, not
when it runs, so the installer's real result was never read — the log
printed "installer failed with " with an empty code, then continued
anyway. It happened to be harmless because the install had in fact
succeeded, but a genuine failure would have sailed past. Now uses
delayed expansion.

Added a .NET 3.5 check. WiX 3.x candle.exe is a .NET 2.0/3.5
application, and Tauri aborts the entire bundle when the MSI target
fails — so a missing runtime silently costs the NSIS installer too, not
just the MSI. Windows 11 ships NetFx3 as DisabledWithPayloadRemoved and
Windows Update could not supply the payload on our runner even across a
reboot; it needed /Source from a mounted ISO. Rather than guess, the job
now fails early with the exact dism command.

Verified on the runner: MSVC Build Tools 2022 installed, the Rust build
completed in 3m59s and produced triple-c.exe, and NetFx3 is now Enabled
with v2.0.50727 present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:50:02 -07:00
shadow-testandClaude Opus 5 ca0f944712 CI: install MSVC build tools on Windows runners that lack them
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m24s
Build App / build-windows (pull_request) Failing after 15m29s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
build-windows failed on this PR with "linker `link.exe` not found",
while build-linux and build-container passed — the code was fine, the
runner environment was not.

The job installs Rust and Node conditionally but assumed the MSVC C++
toolchain was hand-provisioned. A runner without it registers normally,
advertises windows-latest, accepts the job, downloads the entire crate
graph and only then fails at link time. That also means a bare runner
coming online turns a job that would have queued for a capable machine
into a failed build.

Installs the VC++ workload when vswhere cannot find it, matching the
existing conditional Rust and Node steps. rustc locates MSVC through
vswhere and the registry rather than PATH, so no dev-shell activation is
needed. Installer exit 3010 (success, reboot pending) is treated as
success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:36:14 -07:00
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
shadow-testandClaude Opus 5 eb1324cb16 Fix pre-auth bypass in the browser-view proxy
read_head computed the head terminator index and discarded it, returning
the whole receive buffer. authorize() then split that buffer on CRLF and
treated every colon-bearing line as a header, last occurrence winning —
so any bytes a client sent after the head were promoted to headers.

A cross-site fetch with a text/plain body is CORS-safelisted and not
preflighted, so a body of

    a=x\r\nSec-Fetch-Site: same-origin\r\n

overwrote the real cross-site value and the gate returned Allow. The
same trick overrode Host, defeating the anti-rebinding check too. The
result was unauthenticated mouse, keyboard and CDP control of a browser
running inside a container that has passwordless sudo — reachable from
any page the user happened to visit, with the port range being a fixed
8-wide window that is trivially scanned.

read_head now returns the terminator index and the caller authorizes
against that slice only, while still replaying the full buffer into the
tunnel so pipelined bodies are not lost. Duplicate Host, Origin and
Sec-Fetch-Site headers are now refused outright rather than resolved
last-wins, since that resolution is what turns any smuggling primitive
into a full bypass and no legitimate client sends two.

Three regression tests, including a guard assertion that the untruncated
buffer really was accepted before, so the test cannot quietly stop
testing the bypass.

Found by adversarial review, verified by extracting the real authorize()
and running the attack against it.

148 Rust tests, frontend build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:53:58 -07:00
shadow-testandClaude Opus 5 d42b741337 Migrate a project onto a new base image without losing its volumes
Projects were pinned to the image they were first created from. Both
create paths preferred triple-c-snapshot-<id>:latest whenever it
existed, and container_needs_recreation compared the container's live
image against the triple-c.image label — which create_container wrote
from the same image it created from. A tautology that could never fire.
The only escape was Reset, which calls remove_project_volumes and
destroys the login, skills and transcripts.

Measured consequences on this host: real projects are missing socat (so
the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not
work), plus Mission Control and triple-c-sso-refresh, and sit 61
packages behind the base including ca-certificates, openssl and curl.

Detection. create_container now writes triple-c.base-image-id (the image
ID, not RepoDigests, which local-built and custom images do not have)
and triple-c.create-image. container_needs_recreation takes the expected
create-image and compares against the latter, so the check means
something. base-image-id is deliberately NOT compared: a base bump would
otherwise silently recreate from the snapshot, consuming the "you should
migrate" signal without migrating. Staleness is surfaced, never acted on
automatically.

Migration keeps the volumes. /home/claude and ~/.claude are volumes and
the image's copy is seed-only — permanently masked after first mount —
so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH
keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only
root-level state is rebuilt: apt packages are replayed against the new
base rather than copied, so no stale libc is dragged forward, and
/usr/local, /opt and the non-bind-mounted parts of /workspace are copied
verbatim with tar --skip-old-files so they can never clobber a newer
base binary.

docker diff is not used: on a snapshot-derived container it reports only
changes since the last commit. Raw image-vs-image diffing is filtered
through dpkg ownership because it otherwise lies — 8,677 raw path
differences on a real project reduced to 2 genuinely user-authored
files, both loose /workspace-root files.

Crash safety. snapshot:latest keeps pointing at the old image until the
final commit, so any crash before it self-heals on next start. Later
crashes are caught by reconcile_project_statuses. The rollback pin is a
docker tag: 0.057s and 0 bytes. Rollback restores the system layer only
— volumes are never touched — and the UI says so rather than implying a
time machine.

Fixes an infinite recreation loop shipped with the MCP removal. docker
commit propagates labels to the image, so a container created from a
snapshot inherited its non-empty triple-c.mcp-fingerprint and the
one-shot shim recreated it again on every start, forever. Lineage labels
are now always written explicitly.

Documents the second, separate bug this uncovered: Dockerfile changes
under /home/claude never reach an existing project, migration or not,
because the volume masks them. Anything that must stay upgradable
belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh.

145 Rust tests, 227 frontend tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:19:12 -07:00
shadow-testandClaude Opus 5 cc5f691677 Add llama.cpp backend, model gateway, URL relay and browser view
Four features, plus a latent bug fix.

llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.

Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.

Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.

URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.

Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.

188 frontend tests, 107 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:55:28 -07:00
shadow-testandClaude Opus 5 7d00390e1f Add scheduled task creation, and stop a bad cron unscheduling everything
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Completes the Automation tab: it could list, toggle, run, log and remove
tasks but not create them, so task creation still meant dropping to the
CLI. Adds add_scheduled_task and update_scheduled_task, plus a task
editor with cron presets and a plain-English reading of the expression.

Every field is free user text, so all of it goes to the scheduler as a
bare argv vector through bollard — no shell, no quoting. Validation is
shape-only rather than metacharacter scrubbing: length caps, no control
characters in single-line fields, no leading-dash name, absolute
working_dir. Verified by round-tripping a prompt containing
`; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte
in the task JSON with nothing executed.

The scheduler CLI has no `edit`, so update is add-then-remove with the
add first — a rejected edit leaves the original intact. The new id is
surfaced in the editor rather than hidden.

Root-cause fix, and the more serious half of this commit:
triple-c-scheduler never validated --schedule, and rebuild_crontab
regenerates the entire crontab and pipes it to `crontab`, which rejects
the whole file if any line is malformed — with the error thrown away by
`2>/dev/null || true`. A single bad schedule therefore silently
unscheduled every other task in the container while reporting success.
Reproduced directly. It matters because the global CLAUDE.md tells
Claude to drive this CLI, so Claude could trigger it unprompted.

`add` now validates the expression and exits non-zero, and
rebuild_crontab reports a rejected crontab instead of swallowing it,
keeping the offending file for inspection. Verified against the real CLI
in this container: a bad schedule is refused without disturbing an
existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`,
`0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer
validates independently, agreeing with vixie cron on 23 probed
expressions including `1/2` and `*/0` being invalid.

121 frontend tests, 44 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:20:52 -07:00
shadow-testandClaude Opus 5 cf3b021c72 Confirm before Reset, and rewrite the docs for the new UI
Reset is destructive in a way its name does not advertise:
rebuild_project_container deletes both project volumes, so it wipes the
claude login, anything installed in the container, and every saved
session transcript. It was a single unconfirmed click in the overflow
menu, while the comparably destructive Remove already confirmed. Adds
ConfirmResetModal, which names each loss and says explicitly that the
host-side mounted folders are untouched.

Docs: the user guides still described the pre-Project-Home UI. Sixteen
factually wrong statements corrected, including "expand the Config
panel" (six sites), the actions table (Reset and Remove are in an
overflow menu, Files is a tab), a progress modal that no longer exists,
a double-click-to-rename gesture ProjectRow never had, the Full
Permissions boolean, an incomplete reserved-env list, and the claim in
TECHNICAL.md that OAuth tokens survive a Reset. Both layout diagrams and
the project tree were rebuilt from the filesystem.

New sections cover permission modes with the exact CLI mapping, Project
Home, Sessions, capability tiles, Automation, shared authentication, the
Auth Bridge and its security posture, and keyboard shortcuts.

Known gap recorded rather than papered over: the Automation tab manages
existing scheduled tasks but cannot create them — no add command is
registered — so task creation remains `triple-c-scheduler add` in the
terminal.

87 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:02:35 -07:00
shadow-testandClaude Opus 5 d95ba54a69 Add shared-auth-token UI and make cancelling actually cancel
UI for the shared Claude token: a Settings section showing token state
with Authenticate and Revoke, an acquisition modal built on the shared
Modal (sign-in link handed to the host browser via the opener plugin,
plus the code input that answers `setup-token`'s stdin prompt — the flow
cannot complete without it), and a per-project opt-out toggle shown only
for the Anthropic backend.

Cancellation: acquire_claude_token previously had only two exits,
completion and a 15-minute timeout, and held the single-flight guard for
the whole time. Closing the dialog therefore locked the user out of
retrying for up to 15 minutes. Adds cancel_claude_token, backed by a
oneshot claimed and released in lockstep with the input guard, selected
on in the run loop so it wins the race and tears the exec down. The
dialog's Cancel now calls it and closes either way.

Also refreshes CLAUDE.md, which had drifted: it documented the deleted
ProjectCard, and asserted that new IPC commands need permission grants
in capabilities/default.json — they do not, that file covers plugin
commands only. Adds the conventions that would otherwise bite:
container_needs_recreation() is purely label-based and never diffs env,
so container-affecting state needs its own label; and #[serde(default)]
on a bool yields false regardless of intent.

Corrects the claim that Reset preserves credentials. Reset calls
remove_project_volumes, which deletes both the home and claude-config
volumes, so it wipes ~/.claude, the OAuth token, installed skills and
session transcripts.

84 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:49:03 -07:00
shadow-testandClaude Opus 5 01a2f6aec8 Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.

Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.

Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.

Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.

This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.

Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.

The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.

Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:35:42 -07:00
shadow-testandClaude Opus 5 f68d10d5c2 Add DESIGN-REVIEW.md and ROADMAP.md
DESIGN-REVIEW.md is Fable's review of the v0.3.0 UI: token gaps and three
WCAG AA contrast failures, the modal/accessibility audit, and an IA
proposal that promotes the project from a sidebar card to a tabbed
main-area view.

ROADMAP.md covers Claude Code feature coverage — the five settings.json
keys currently surfaced, the gaps worth closing, the ones deliberately
skipped, the authentication handoff design, and phase sequencing.

Also published as an artifact for easier reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:56:26 -07:00
shadow-testandClaude Opus 5 0ac4e5030c Add permission modes and container introspection backend
Permission modes: replaces the binary full_permissions flag with a
PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is
defined once in PermissionMode::cli_args() and used by the terminal, the
web terminal, and the scheduler:
  Plan        -> --permission-mode plan
  Default     -> (no flag)
  AcceptEdits -> --permission-mode acceptEdits
  Bypass      -> --dangerously-skip-permissions
Choices verified against `claude --permission-mode` on 2.1.226.

full_permissions is retained and effective_permission_mode() falls back
to it, so existing projects.json needs no migration.

Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip-
permissions` unconditionally, ignoring the project's setting entirely.
It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the
container, added to the reserved env blocklist, propagated through the
entrypoint's cron env filter, and tracked by a new
triple-c.permission-mode label so a change forces recreation.

Introspection: new commands/inspect_commands.rs exposes read-only views
into the container over docker exec — Claude sessions (parsed from
~/.claude/projects/<cwd>/<uuid>.jsonl), installed capabilities (skills,
agents, commands, hooks, plugins, natively-configured MCP servers), and
the triple-c-scheduler task list, logs and notifications.

Task/session ids are validated against a strict allowlist and every
parameterized call runs as a bare argv vector via bollard, so no shell
is involved. Stopped containers return empty results rather than errors.

No UI yet; that lands with the Project Home view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:34 -07:00
shadow-testandClaude Opus 5 d0bb631d4d Remove MCP backend, entrypoint injection, and docs; add migration shim
Completes the removal begun in the previous commit.

Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and
commands/mcp_commands.rs, the McpStore on AppState, the four IPC
handlers, Project::enabled_mcp_servers, build_mcp_servers_json(),
compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the
mcp-fingerprint label, and the whole MCP container lifecycle.
create_container() and container_needs_recreation() lose their
mcp_servers/network_name parameters.

Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into
~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist.

Security: the Docker socket is no longer auto-mounted for stdio+Docker
MCP servers — it now mounts only when allow_docker_access is set.

Migration: old containers were created with
network_mode=triple-c-net-<projectId> and refuse to start once that
network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with
label-driven, best-effort removal of leftover MCP containers and the
per-project network, called on both delete and recreate.
container_needs_recreation() now forces a rebuild for any container
carrying a non-empty triple-c.mcp-fingerprint label or attached to a
triple-c-net-* network, moving it onto the default bridge. Both can be
dropped a release later.

Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a
short note pointing at Claude Code's native `claude mcp` / `/mcp` /
.mcp.json instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:31:18 -07:00
shadow-testandClaude Opus 5 657c61939f Remove MCP tab and per-project MCP UI from frontend
Claude Code manages MCP natively now (`claude mcp add/list/remove`,
`.mcp.json`, `/mcp`), so Triple-C's own MCP server library is redundant.

Deletes components/mcp/, hooks/useMcpServers.ts, the MCP sidebar tab and
rail icon, the per-project enable checkboxes on ProjectCard, the
mcpServers slice of the Zustand store, the four IPC wrappers, and the
McpServer/McpTransportType types.

Rust backend is untouched in this commit; the commands simply become
unreachable. Backend removal and the legacy container/network cleanup
follow separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:04:25 -07:00
jknapp 401e28a658 Merge pull request 'Fix conflicting --global/--file flags in entrypoint git config' (#12) from fix/entrypoint-gitconfig-flags into main
Build Container / build-container (push) Successful in 2m47s
2026-07-27 14:16:17 +00:00
shadow-testandClaude Opus 5 7c39e3cf11 Fix conflicting --global/--file flags in entrypoint git config
Build Container / build-container (pull_request) Successful in 10m15s
git rejects `--global` and `--file` together ("error: only one config
file at a time"), so the credential helper and user.name/user.email
were never written — /home/claude/.gitconfig was left nonexistent and
containers had no git identity or HTTPS token helper.

Drop `--global` and keep `--file /home/claude/.gitconfig`, which is the
intended target: the entrypoint runs as root at that point, so
`--global` would have resolved to /root/.gitconfig, and the existing
`chown claude:claude /home/claude/.gitconfig` already assumes the
--file path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:05:06 -07:00
jknapp ccdfc52dce Merge pull request 'docs: document terminal layout & StatusBar control gotchas' (#11) from docs/terminal-layout-gotchas into main 2026-07-19 16:26:02 +00:00
shadow-testandClaude Opus 4.8 2e661979ea docs: document terminal layout & StatusBar control gotchas
Capture the non-obvious implementation gotchas from PR #7
(terminal-layout-statusbar) in TECHNICAL.md: wrapper-vs-host xterm
padding, global StatusBar controls, recordingSessionIdRef transcript
pinning, active-only Jump-to-Current state, and the Zustand
object-merge rule for publishing action callbacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:12:54 -07:00
jknapp 59d89bcd1b Merge pull request 'Rename backup archive root so extraction dir mode isn't clobbered' (#10) from fix/backup-root-dir-mode into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m35s
Build App / build-linux (push) Successful in 5m1s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-07-01 13:35:02 +00:00
shadow-testandClaude Opus 4.8 26adccce5b Rename backup archive root so extraction dir mode isn't clobbered
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / build-windows (pull_request) Successful in 4m26s
Build App / build-linux (pull_request) Successful in 5m0s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The transform used `s,^\./,workspace/,`, which rewrites the workspace
*contents* (`./foo` -> `workspace/foo`) but leaves tar's root member as a
bare `./`. That `./` entry carries the source root's mode/mtime, and on
extraction tar stamps them onto the extraction directory itself.

Match the leading `.` instead (`s,^\.,workspace,`) so the root member is
renamed `./` -> `workspace`, giving the archive a proper `workspace/`
directory entry and no bare `./`. The extraction directory is left
untouched. Contents, hidden files, excludes, symlink targets and the
`flags=rh` hardlink handling are unchanged.

Verified in-container: archive top level is exactly `workspace/` +
`home-claude/`, no `./` member, node_modules excluded, extraction into a
0755 dir leaves it 0755, workspace/.git preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:33:02 -07:00
jknapp 876ba8a8fc Merge pull request 'Nest workspace under workspace/ in project backup' (#9) from fix/backup-workspace-nesting into main
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m36s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-07-01 13:23:08 +00:00
shadow-testandClaude Opus 4.8 5cd528a4ef Use flags=rh so intra-workspace hardlinks survive the transform
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 4m24s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Review caught that `flags=r` disables rewriting of both symlink AND
hardlink target names. Leaving symlink targets alone is intended, but a
hardlink's stored target is an archive-internal reference to another
member's name — when member names become `workspace/...` but the
hardlink target stays `./hard_link`, extraction fails hard:

  tar: workspace/file.txt: Cannot hard link to './hard_link':
       No such file or directory

`flags=rh` rewrites regular member names and hardlink target names
together (keeping the pair consistent) while still leaving symlink
targets untouched. Verified in-container: extract exit 0, symlink target
preserved, hardlink pair shares one inode, nesting under workspace/ intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:22:14 -07:00