Compare commits

...
Author SHA1 Message Date
jknapp 01e72e4785 Merge pull request 'Let a project's container run a VPN client' (#27) from feat/vpn-support into main
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m37s
Build App / build-linux (push) Successful in 5m33s
Build App / build-windows (push) Successful in 6m11s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 52s
2026-08-14 15:30:03 +00:00
shadow-testandClaude Opus 5 2b35aa8c16 Explain a missing tun device where the failure actually happens
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-linux (pull_request) Successful in 5m30s
Build App (Preview) / build-windows (pull_request) Successful in 5m55s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Review caught that the device guard was wired to the wrong call. The
daemon does not resolve `--device` at create: verified against Docker
29.7, `docker create --device /dev/does-not-exist` succeeds and prints an
id, and runc only resolves the device — and validates sysctls — when it
builds the container. So on a host with no tun module the create returns
fine and `start` fails, which means the explanation never ran and the
user saw the raw daemon string naming a path they would go looking for on
the wrong machine. The unit tests fed the create-side string straight in,
so they confirmed a function no real failure could reach.

Move the guard onto `start_container`, covering create as well in case a
future daemon checks earlier. It no longer takes `vpn_support_enabled` —
`start_container` has a container id and no project, and nothing else in
Triple-C ever requests a device, so an error naming /dev/net/tun is
unambiguous on its own. The test now uses the daemon's verbatim message
via bollard's real Display format.

Also from review:

  * Soften the security claim. Docker does not enable user-namespace
    remapping by default, so this is a real CAP_NET_ADMIN in the initial
    user namespace with only the network namespace confining it. It
    cannot touch host interfaces, but "confers no authority outside the
    container" was too strong: within its namespace it can set
    promiscuous mode and add addresses, routes and NAT on the shared
    docker0 segment, which puts sibling containers — the LiteLLM gateway
    among them — within ARP-spoofing reach, and it can flush netfilter
    rules sandbox mode may rely on. Said plainly in the code, CLAUDE.md
    and HOW-TO-USE.
  * Drop Tailscale from the list of clients needing this. Its
    --tun=userspace-networking mode needs neither the capability nor the
    device, and listing it invites granting NET_ADMIN for nothing.
  * Say in the toggle's own hint that changing it recreates the
    container, matching how every other recreation-triggering setting is
    labelled. The tab's generic "stop the container first" chip does not
    tell the user what is about to happen.
  * Add RuntimeSection tests: saves on, saves off explicitly rather than
    dropping the key, reflects state, is disabled while running, and
    carries the recreation warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:15:05 -07:00
shadow-testandClaude Opus 5 65a3d4eb29 Let a project's container run a VPN client
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-linux (pull_request) Successful in 7m10s
Build App (Preview) / build-windows (pull_request) Successful in 6m33s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
A VPN client installed in a container today starts, runs, and then hangs
until its connection times out. Nothing reports an error: a default
container has no /dev/net/tun to open and no CAP_NET_ADMIN to add an
interface or a route with, and clients surface that as a generic timeout
rather than a permissions failure.

Add an opt-in per-project "VPN support" switch granting the three things
a tunnel needs. They are useless individually, which is why
vpn_host_config() defines the set in one place and the tests assert all
of it:

  * CAP_NET_ADMIN — Docker's default bounding set has net_raw but not
    net_admin, so a client can ping but never connect.
  * /dev/net/tun — passed through from the host so the kernel's tun
    module backs it, rather than mknod-ed inside.
  * net.ipv4.conf.all.src_valid_mark — WireGuard's wg-quick sets this and
    cannot from inside a container, /proc/sys being read-only, so its
    handshakes are dropped by reverse-path filtering.

Off by default and deliberately opt-in: NET_ADMIN lets anything in the
container reconfigure that container's network stack. It is namespaced —
no authority over the host's interfaces or any other container.

Capabilities and devices are fixed when a container is created, so this
is container state and takes the label-and-compare treatment.
triple-c.vpn-support is written unconditionally, false included, for the
usual docker commit reason: a true stamped once would ride the snapshot
image into every future container and make the switch impossible to turn
back off. A missing label reads as false and off is byte-identical to
today, so no existing project is churned.

Requesting the device fails at creation when the host kernel has no tun
module, which would otherwise surface as a project that simply refuses to
start. explain_create_failure() rewrites that one error to name the
switch and the Docker-Desktop-VM-versus-your-machine distinction, and
leaves every other failure untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:06:39 -07:00
jknapp 2b2d9da606 Merge pull request 'Number releases by the highest one already published, not by drift' (#26) from fix/monotonic-release-version into main
Build App / compute-version (push) Successful in 12s
Build App / build-macos (push) Successful in 2m37s
Build App / build-windows (push) Successful in 5m44s
Build App / build-linux (push) Successful in 6m3s
Build App / sync-to-github (push) Successful in 11s
Build App / create-tag (push) Successful in 24s
2026-08-14 06:00:50 +00:00
shadow-testandClaude Opus 5 3741e0fef5 Number releases by the highest one already published, not by drift
The Linux release upload failed with a bare "exitcode '1'" and no output.
The cause was not the upload: compute-version handed it a version that
had already been released three days earlier.

The patch number was `git rev-list --count <highest tag>..HEAD` — how far
HEAD has drifted from whichever tag sorts highest, which resets to zero
every time a tag is cut. It is not a counter, and the published history
is what the old formula returned at each point:

  v0.4.0 -> 3 commits -> v0.4.3    looked fine
  v0.4.3 -> 4 commits -> v0.4.4    fine by luck, 4 > 3
  v0.4.4 -> 2 commits -> v0.4.2    went backwards
  v0.4.4 -> 6 commits -> v0.4.6    jumped, skipping .5
  v0.4.6 -> 3 commits -> v0.4.3    already taken

So the line published 0.4.0, 0.4.3, 0.4.4, 0.4.2, 0.4.6 in that order,
never used 0.4.1 or 0.4.5, and then came back round to 0.4.3.

The patch is now one past the highest already used. Suffixed tags count
towards that: create-tag is skipped whenever a platform job fails, so a
run can publish v0.4.7-mac and never create the plain v0.4.7, and reading
only unsuffixed tags would hand the same number out twice. A commit that
is already tagged reuses its own tag, so re-running a build does not mint
a version.

Reusing a number was doing real damage, not just failing. macOS and
Windows delete-then-upload each asset, so they took the duplicate in
their stride and rewrote v0.4.3-mac and v0.4.3-win — public since
Aug 11 — with today's binaries. Linux is the only platform that failed,
and failing was the correct outcome; its v0.4.3 assets are the only ones
still original.

Linux also gets the idempotent get-or-create the other two already had,
plus `set -euo pipefail` and `-fsS`. Its `curl -s` with no `-f` is why a
409 produced no diagnostic at all: the HTTP error was swallowed, the id
grep came back empty, and the step died without ever printing why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:57:36 -07:00
jknapp 0e6566d903 Merge pull request 'Fix Playwright setup destroying its own install, and the migration notice that stayed silent' (#25) from fix/playwright-container-setup into main
Build Container / build-container (push) Successful in 57s
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m37s
Build App / build-windows (push) Successful in 5m45s
Build App / build-linux (push) Failing after 5m18s
Build App / create-tag (push) Skipped
Build App / sync-to-github (push) Skipped
2026-08-14 04:34:20 +00:00
shadow-testandClaude Opus 5 84a67fcd0d Stop an empty base-image label from silencing the migration notice
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m31s
Build App (Preview) / build-windows (pull_request) Successful in 6m24s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
A project can be out of date and say nothing about it, in two ways that
compound: the lineage lookup treats "unknown" as an answer, and the
fallback that exists for unknown lineage disappears when its probe fails.

`create_container` always writes triple-c.base-image-id, even when the
value is unknown — deliberately, so an inherited image label cannot ride
a snapshot forever. That makes Some("") the ordinary reading from a
container whose lineage was never established. The lookup filtered for
emptiness only on the final result, so that empty string satisfied the
container branch and skipped the snapshot entirely: a snapshot that had
recorded a real lineage was never consulted, and the project reported
"unknown" with the answer one lookup away. Each source is now filtered
before it can answer, in pick_recorded_lineage, which is a plain function
so the case has a test that fails against the old logic.

A genuinely pre-label project stays unknown, and should: its ancestor is
not knowable, and inventing one would make it look permanently current.
The probe is the intended signal for those — but if the probe failed,
get_container_staleness returned early with nothing populated, the banner
found no gaps and rendered null, and the probe_error it already knew how
to display sat behind a gate that returned before reaching it. Silence
there is indistinguishable from "up to date", and it is likeliest for the
oldest and largest projects, whose manifests are the ones apt to exceed
the inspection limit — one real project measured 6.93 MB against an 8 MB
cap. An unknown-lineage container whose probe failed now says the check
could not be completed, with the reason, under the tone that means
unresolved rather than the one that means something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:51 -07:00
shadow-testandClaude Opus 5 f3cc1c4c17 Stop Playwright setup from deleting the package it just installed
Setting up the browser view failed on every container, and re-running it
reproduced the same broken state, because the setup destroyed its own work.

`install_packages` ran two `npm install --no-save` commands into
/workspace, which has no package.json. With no manifest, npm treats the
command line as the whole statement of what the tree should contain and
prunes the rest, so installing `playwright` second removed the
`@playwright/cli` installed first: "removed 3 packages", leaving an empty
node_modules/@playwright/ behind playwright and playwright-core. That
empty directory is exactly what the pane then reported as missing. The
second install now names both specs; the first one is already present, so
it costs nothing and is only there to stop npm pruning it.

Two failures were waiting behind that one:

Nothing in the tree ever configured the browser, so playwright-cli fell
back to channel `chrome` — system Google Chrome — with the Chromium
sandbox on. These containers forbid unprivileged user namespaces, so it
aborted with "Failed to move to new namespace ... Operation not
permitted"; on a base image without Google Chrome the same default failed
as "Chromium distribution 'chrome' is not found". entrypoint.sh now seeds
~/.playwright/cli.config.json on every start, which is the only way to
reach existing projects: ~/.playwright is inside the home volume, so an
image copy would reach new projects only.

The launch check passed for a configuration the viewer never uses. It
launched bundled chromium with no channel, which resolves to
chromium-headless-shell, while the viewer's config pins
chrome-for-testing — the full chromium build, a separate download. A
container could pass every check and still fail in the pane with 'Browser
"chrome-for-testing" is not installed', which is what a stale
chromium-1217 against a wanted chromium-1237 did. Chromium is now
verified on both channels, the sandbox setting is stated rather than
inherited from a default, and a failure names the channel.

triple-c-playwright-heal repairs all of it on a container that is already
broken, including the missing socat that makes the pane report
"127.0.0.1 sent an invalid response" while the container side is
perfectly healthy. It verifies by launching a browser rather than
trusting the preceding steps — which is how the stale-revision case was
found — and lives in /usr/local/bin so a fix to it can still reach an
existing project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:37 -07:00
jknapp 7265f55f27 Merge pull request 'Fix scheduled tasks failing to authenticate, and show when one is running' (#24) from fix/scheduler-home-clobber into main
Build Container / build-container (push) Successful in 1m52s
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m40s
Build App / build-linux (push) Successful in 7m30s
Build App / create-tag (push) Successful in 11s
Build App / sync-to-github (push) Successful in 17s
Reviewed-on: #24
2026-08-12 13:55:54 +00:00
jknapp 88f2e73474 Merge branch 'main' into fix/scheduler-home-clobber
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 34s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Canceled after 0s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-macos (pull_request) Canceled after 21s
Build App (Preview) / build-windows (pull_request) Canceled after 22s
2026-08-12 13:55:37 +00:00
shadow-testandClaude Opus 5 fa4940dd7d Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".

triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.

On top of that:

- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
  elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
  task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
  polls while anything is in flight — including the second or two between
  firing a run and the runner registering it, which is the exact window that
  used to read as dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:33:49 -07:00
shadow-testandClaude Opus 5 9027fa9ad4 Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
Every scheduled task failed with "Not logged in · Please run /login" while
the container's OAuth credential sat there, valid, the whole time.

The entrypoint snapshots the environment into ~/.claude/scheduler/.env so
cron jobs get more than cron's minimal env. It runs as root, and HOME was
in the capture list, so the file recorded HOME=/root. The task runner then
sources that file with `set -a`, overwriting the HOME cron gave the job.
`claude -p` looks for its credential under $HOME, finds no /root/.claude,
and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the
sourcing — which is why this presents as a well-formed log of a task that
never authenticated.

Drop HOME from the captured set and write it explicitly instead; cron does
still need one. Then restore HOME across the source in the task runner too:
.env lives on the home volume, so every project created before this ships
keeps a stale copy of it until its container restarts, and the runner is
what has to survive that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:13:59 -07:00
jknapp be37723c38 Merge pull request 'Sweep the snapshot commits recreation leaves behind' (#23) from sweep-orphaned-snapshots into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m44s
Build App / build-linux (push) Successful in 6m18s
Build App / create-tag (push) Successful in 12s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #23
2026-08-12 02:06:24 +00:00
23 changed files with 1448 additions and 77 deletions
+95 -11
View File
@@ -39,13 +39,48 @@ jobs:
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]') MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}" echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes) # The patch number is **one past the highest patch already used**, and
# `|| true` so an empty grep result doesn't fail the step under pipefail. # never a distance.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true) #
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
# not a counter at all: it measures how far HEAD has drifted from
# whichever tag sorts highest, and that resets to zero every time a
# tag is cut. The published history is the proof — each of these is
# exactly what the old formula returned at the time:
#
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
#
# Reusing a version is worse than failing to publish one: the macOS
# and Windows steps replace assets in place, so a duplicate silently
# rewrote a release that had been public for three days. Monotonic
# numbering is what stops that at the source.
#
# Suffixed tags count too. `create-tag` is skipped when any platform
# job fails, so a run can publish v0.4.7-mac and never create the
# plain v0.4.7 — reading only unsuffixed tags would then hand the
# same number out twice.
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
| sort -n | tail -1 || true)
if [ -n "$LATEST_TAG" ]; then # A re-run of a commit that already released must not mint a new
echo "Latest matching tag: ${LATEST_TAG}" # version just because its own tag now exists.
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") EXISTING=$(git tag --points-at HEAD \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
| sort -n | tail -1 || true)
if [ -n "$EXISTING" ]; then
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — reusing it"
PATCH="${EXISTING}"
elif [ -n "$HIGHEST" ]; then
echo "Highest patch already used on this line: ${HIGHEST}"
PATCH=$((HIGHEST + 1))
else else
# A minor line nobody has tagged yet is a *new* line, and a new line # A minor line nobody has tagged yet is a *new* line, and a new line
# starts at .0 — that is what "we are moving to 0.4.x" means. The # starts at .0 — that is what "we are moving to 0.4.x" means. The
@@ -165,21 +200,70 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}" TAG="v${{ needs.compute-version.outputs.version }}"
# Create release
curl -s -X POST \ # Idempotent get-or-create, matching build-macos. This step used to
# POST /releases unconditionally: against a tag that already existed
# Gitea answered 409, the grep below found no id, and the run died
# with a bare "exitcode '1'" and not one line of output explaining
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
# pipefail so a failure cannot be stepped over.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Creating release ${TAG}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') ;;
*)
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
cat release.json >&2
exit 1
;;
esac
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
if [ -z "${RELEASE_ID}" ]; then
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
# Replace-not-conflict, so a retry after a partial upload succeeds.
# Versions are monotonic now (see compute-version), so this can only
# ever be replacing an asset from a failed run of this same commit —
# never one belonging to an already-published version.
for file in artifacts/*; do for file in artifacts/*; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
filename=$(basename "$file") filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..." echo "Uploading ${filename}..."
curl -s -X POST \ curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \ --data-binary "@${file}" \
+35
View File
@@ -273,6 +273,41 @@ migration and Reset. Four things here are not obvious:
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
project's snapshot image, so turning the feature off has to undo, not merely stop. project's snapshot image, so turning the feature off has to undo, not merely stop.
### VPN support (`vpn_support_enabled`, `docker/container.rs`)
An opt-in per-project switch granting the container what a VPN client needs to build a tunnel.
`vpn_host_config()` is the single definition of what that means, and it is unit-tested because a
container is created once by a very long function where a dropped capability is invisible.
- **All three pieces or none.** `CAP_NET_ADMIN` (Docker's default set has `net_raw` but *not*
`net_admin`, so a client can ping but never connect), the `/dev/net/tun` device (absent
entirely from a default container — nothing to open even with the capability), and
`net.ipv4.conf.all.src_valid_mark=1` (WireGuard's `wg-quick` sets it and cannot from inside a
container, since `/proc/sys` is read-only, so handshake packets die to reverse-path filtering).
Any two without the third still presents as a connection that hangs to a timeout, which is why
the tests assert the whole set.
- **The device is passed through from the host, never `mknod`-ed inside.** The kernel's `tun`
module has to back it.
- **A missing device fails at `start`, not `create` — verified against Docker 29.7.** `docker
create --device /dev/does-not-exist` succeeds and prints an id; runc resolves the device (and
validates sysctls) only when it builds the container. So the guard belongs on the start path:
`explain_container_failure()` covers both and is called from `start_container`, where it has a
container id and no project — which is why it keys off the error naming `/dev/net/tun` rather
than off `vpn_support_enabled`. Nothing else in Triple-C requests a device, so that is
unambiguous. A version of this check wired to `create` alone is dead code that looks correct.
- **`NET_ADMIN` here is not user-namespaced.** Docker does not enable userns remapping by default,
so only the *network* namespace confines it: no reach onto host interfaces, but promiscuous
mode, arbitrary addresses/routes/NAT on the shared `docker0` segment (sibling containers, the
LiteLLM gateway among them, are ARP-spoofable), netlink-triggered host module auto-load, and
enough authority to flush in-container netfilter rules that sandbox mode may rely on. Keep the
code comments honest about this — an earlier draft claimed it "confers no authority" outside the
container, which is too strong.
- **`triple-c.vpn-support` is written unconditionally, including `false`.** The usual
`docker commit` reason: a `true` stamped once would ride the snapshot image into every future
container and make the switch impossible to turn off.
- Off is byte-identical to a container created before the feature existed, and a missing label
reads as `false`, so no existing project is churned.
### Container Lifecycle ### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
+42 -1
View File
@@ -471,6 +471,37 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
> Toggling this requires stopping and restarting the container to take effect. > Toggling this requires stopping and restarting the container to take effect.
### VPN Support
When enabled, the container is given the three things a VPN client needs to build a tunnel:
the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark`
sysctl that WireGuard requires. This is **off by default**.
Without it, a client such as PIA, WireGuard or OpenVPN installs and its daemon starts normally, but
the connection attempt **hangs until it times out** — a default container has no tun device to open
and no permission to add an interface or a route, and most clients report that as a generic timeout
rather than a permissions error.
Things worth knowing:
- Tailscale is the exception: in its `--tun=userspace-networking` mode it needs neither the
capability nor the device, so leave this off if that is all you want.
- `NET_ADMIN` applies to the container's **own** network namespace — it cannot touch the host's
interfaces. It is not nothing, though: within that namespace anything in the container can set
promiscuous mode and add arbitrary addresses, routes and firewall rules on the Docker bridge it
shares with your other containers, and it can flush firewall rules that sandbox mode relies on.
Grant it per project, to projects that need it.
- The **Docker host's** kernel must have the `tun` module available. With Docker Desktop that is
the Linux VM, not your own machine. If it is missing, the container is created but fails to
**start**, with an error naming `/dev/net/tun` and pointing back at this setting.
- A VPN client's kill switch applies to everything in the container, Claude Code included. If the
tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off.
> This setting can only be changed when the container is stopped. Capabilities and devices are
> fixed when a container is created, so toggling it recreates the container on the next start.
> Recreation preserves the home and `.claude` volumes — it is not a Reset.
### Mission Control ### Mission Control
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled: Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
@@ -1139,13 +1170,23 @@ triple-c-scheduler list # List all tasks
triple-c-scheduler enable --id abc123 # Enable a task triple-c-scheduler enable --id abc123 # Enable a task
triple-c-scheduler disable --id abc123 # Disable a task triple-c-scheduler disable --id abc123 # Disable a task
triple-c-scheduler remove --id abc123 # Delete a task triple-c-scheduler remove --id abc123 # Delete a task
triple-c-scheduler run --id abc123 # Trigger a task immediately triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
triple-c-scheduler status # What is running right now, and for how long
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
triple-c-scheduler logs --id abc123 # View logs for a task triple-c-scheduler logs --id abc123 # View logs for a task
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks) triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
triple-c-scheduler notifications # View completion notifications triple-c-scheduler notifications # View completion notifications
triple-c-scheduler notifications --clear # Clear notifications triple-c-scheduler notifications --clear # Clear notifications
``` ```
`list` carries a status column, and the Automation tab marks a task **Running** with
its elapsed time, so a triggered run is visible rather than silent.
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
writes its answer in one go when it finishes, so a healthy run shows nothing but its
header for as long as it is thinking. `status` is what distinguishes a slow run from
a dead one — it reports the run only while the runner's process is genuinely alive.
### Cron Schedule Format ### Cron Schedule Format
Standard 5-field cron: `minute hour day-of-month month day-of-week` Standard 5-field cron: `minute hour day-of-month month day-of-week`
+74 -26
View File
@@ -194,11 +194,24 @@ impl BrowserTarget {
} }
} }
/// The `channel` a launch check must pass. `None` means the bundled build. /// Every `channel` a launch check must pass, comma-separated, where
fn channel(self) -> Option<&'static str> { /// `default` means "no channel — the bundled build".
///
/// Chromium is checked twice because the two consumers of this install do
/// not launch the same binary. A script calling `chromium.launch()` with
/// no channel gets `chromium-headless-shell`; the viewer reads
/// `~/.playwright/cli.config.json`, which pins channel
/// `chrome-for-testing`, and that resolves to the *full* `chromium-<rev>`
/// build — a separate download under the same `install chromium`.
///
/// Checking only the first is how a container reaches "verified" and then
/// fails in the pane with `Browser "chrome-for-testing" is not installed`.
/// Observed on a real project, where a stale `chromium-1217` satisfied the
/// headless-shell launch while the viewer wanted `chromium-1237`.
fn channels(self) -> &'static str {
match self { match self {
Self::Chromium => None, Self::Chromium => "default,chrome-for-testing",
Self::Chrome => Some("chrome"), Self::Chrome => "chrome",
} }
} }
} }
@@ -235,7 +248,7 @@ pub async fn install_packages(
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
); );
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?; let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
if step.exit_code != 0 { if step.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
@@ -246,9 +259,15 @@ pub async fn install_packages(
// Second, `playwright` at the version the viewer package pins — see // Second, `playwright` at the version the viewer package pins — see
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree. // `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
//
// The viewer package is named *again* here. It is already installed, so
// this adds no work, but omitting it is what made npm prune it back out —
// see the note on `npm_install`. The pin can only be read after the first
// install has written the manifest, which is why this stays two commands
// rather than one.
let spec = pinned_playwright_spec(container_id).await; let spec = pinned_playwright_spec(container_id).await;
emit_progress(app, project_id, &format!("Installing {}", spec)); emit_progress(app, project_id, &format!("Installing {}", spec));
let second = npm_install(app, project_id, container_id, &spec).await?; let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
if second.exit_code != 0 { if second.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
@@ -290,7 +309,7 @@ pub async fn install_packages(
}) })
} }
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`. /// One `npm install` of one or more specs, into [`INSTALL_DIR`], as `claude`.
/// ///
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in /// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is /// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
@@ -298,13 +317,24 @@ pub async fn install_packages(
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at /// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
/// all), but if a future release brings the browser download back, this step /// all), but if a future release brings the browser download back, this step
/// must stay small and the download must stay the step the user asked for. /// must stay small and the download must stay the step the user asked for.
///
/// **Every package that must survive has to appear in `specs`.** `--no-save`
/// in a directory with no `package.json` — which [`INSTALL_DIR`] is — leaves
/// npm with the command line as its only statement of what the tree should
/// contain, and npm ≥7 reconciles the tree against that on every run by
/// removing whatever it now considers extraneous. Installing `@playwright/cli`
/// and then installing `playwright` in a second command therefore *deletes the
/// first one*: verified in a container, `removed 3 packages`, leaving an empty
/// `node_modules/@playwright/` behind `playwright` and `playwright-core`. That
/// empty directory is why a fresh setup could report success and still leave
/// the pane saying `@playwright/cli` was not installed.
async fn npm_install( async fn npm_install(
app: &AppHandle, app: &AppHandle,
project_id: &str, project_id: &str,
container_id: &str, container_id: &str,
spec: &str, specs: &[&str],
) -> Result<StepResult, String> { ) -> Result<StepResult, String> {
let cmd = vec![ let mut cmd = vec![
"env".to_string(), "env".to_string(),
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(), "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
"npm".to_string(), "npm".to_string(),
@@ -313,8 +343,8 @@ async fn npm_install(
"--no-save".to_string(), "--no-save".to_string(),
"--no-fund".to_string(), "--no-fund".to_string(),
"--no-audit".to_string(), "--no-audit".to_string(),
spec.to_string(),
]; ];
cmd.extend(specs.iter().map(|s| s.to_string()));
run_step( run_step(
app, app,
project_id, project_id,
@@ -704,7 +734,7 @@ async fn verify_launch(
], ],
vec![ vec![
format!("TRIPLE_C_PW_DIR={}", dir), format!("TRIPLE_C_PW_DIR={}", dir),
format!("TRIPLE_C_PW_CHANNEL={}", target.channel().unwrap_or("")), format!("TRIPLE_C_PW_CHANNELS={}", target.channels()),
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL), format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
], ],
); );
@@ -795,27 +825,43 @@ fn parse_launch_output(output: &str) -> LaunchVerdict {
/// The launch check. One `argv` element, no newlines, same contract as the /// The launch check. One `argv` element, no newlines, same contract as the
/// detection probe. /// detection probe.
/// ///
/// Playwright leaves the Chromium sandbox disabled by default, which is what /// `chromiumSandbox` is set explicitly rather than left to Playwright's
/// makes this work in a container at all. The timeout exists so a browser that /// default, so this check states the same thing the seeded
/// hangs on a missing library still returns a verdict rather than sitting there /// `cli.config.json` does instead of agreeing with it by coincidence. The
/// until the exec is torn down. The navigation is best-effort and never decides /// containers forbid unprivileged user namespaces, so a sandboxed Chromium
/// `ok` — it exists to tell a TLS-intercepted network apart from a broken /// aborts on launch; nothing here should be able to drift back into testing a
/// install. /// configuration the viewer will not use.
///
/// Each channel in `TRIPLE_C_PW_CHANNELS` is launched in turn — see
/// [`BrowserTarget::channels`] for why Chromium needs two — and a failure
/// names the channel that failed, because "is not installed" is meaningless
/// without it. Only the last launch loads a page: the navigation is
/// best-effort, never decides `ok`, and exists to tell a TLS-intercepted
/// network apart from a broken install, so doing it once is enough.
///
/// The timeout exists so a browser that hangs on a missing library still
/// returns a verdict rather than sitting there until the exec is torn down.
const LAUNCH_PROBE: &str = concat!( const LAUNCH_PROBE: &str = concat!(
r#"const d=process.env.TRIPLE_C_PW_DIR,ch=process.env.TRIPLE_C_PW_CHANNEL||undefined,u=process.env.TRIPLE_C_PW_URL;"#, r#"const d=process.env.TRIPLE_C_PW_DIR,chs=process.env.TRIPLE_C_PW_CHANNELS||"default",u=process.env.TRIPLE_C_PW_URL;"#,
r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#, r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#, r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#, r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#, r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
r#"(async()=>{let b=null;try{const {chromium}=require(d);b=await chromium.launch(ch?{channel:ch}:{});"#, r#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
r#"let v="";try{v=b.version();}catch(e){}"#, r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
r#"let nav={ok:true,cert:false,detail:""};"#, r#"let v="",nav={ok:true,cert:false,detail:""};"#,
r#"for(let i=0;i<list.length;i++){cur=list[i];const c=cur==="default"?undefined:cur;"#,
r#"b=await chromium.launch(Object.assign({chromiumSandbox:false},c?{channel:c}:{}));"#,
r#"try{v=b.version();}catch(e){}"#,
r#"if(i===list.length-1){"#,
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#, r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
// A certificate failure is classified here, next to the message, because // A certificate failure is classified here, next to the message, because
// Chromium's wording is the only place the distinction exists. // Chromium's wording is the only place the distinction exists.
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}"#, r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}}"#,
r#"await b.close();clearTimeout(t);say(true,v,nav);}"#, r#"await b.close();b=null;}"#,
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#, r#"clearTimeout(t);say(true,v,nav);}"#,
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}"#,
r#"say(false,(cur&&cur!=="default"?"channel "+cur+": ":"")+one(e));}"#,
r#"process.exit(0);})();"#, r#"process.exit(0);})();"#,
); );
@@ -984,8 +1030,10 @@ mod tests {
// `@playwright/mcp` asks for the chrome channel specifically, so the UI // `@playwright/mcp` asks for the chrome channel specifically, so the UI
// must be able to say so. // must be able to say so.
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp")); assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome")); assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
assert_eq!(BrowserTarget::Chromium.channel(), None); // Both of Chromium's consumers, or the check passes for a browser the
// viewer cannot open — see `channels`.
assert_eq!(BrowserTarget::Chromium.channels(), "default,chrome-for-testing");
// And a size, before the click, for both. // And a size, before the click, for both.
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] { for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t); assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
+23 -2
View File
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron /// Only known for enabled one-shot tasks (their `at` time). Recurring cron
/// expressions are not evaluated here. /// expressions are not evaluated here.
pub next_run: Option<String>, pub next_run: Option<String>,
/// Whether a run is in flight right now, from the runner's state file in
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
pub running: bool,
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
pub running_since: Option<String>,
} }
/// A completion notice written by `triple-c-task-runner` after a task ran. /// A completion notice written by `triple-c-task-runner` after a task ran.
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
set -u set -u
TASKS="$HOME/.claude/scheduler/tasks" TASKS="$HOME/.claude/scheduler/tasks"
LOGS="$HOME/.claude/scheduler/logs" LOGS="$HOME/.claude/scheduler/logs"
RUNNING="$HOME/.claude/scheduler/running"
[ -d "$TASKS" ] || { echo '[]'; exit 0; } [ -d "$TASKS" ] || { echo '[]'; exit 0; }
for f in "$TASKS"/*.json; do for f in "$TASKS"/*.json; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
id=$(jq -r '.id // ""' "$f") || continue id=$(jq -r '.id // ""' "$f") || continue
[ -n "$id" ] || id=$(basename "$f" .json) [ -n "$id" ] || id=$(basename "$f" .json)
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1) last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{ # Live-run state. The pid is checked, not trusted: a container stopped
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
# "running" forever is a worse lie than showing nothing.
started=""
state="$RUNNING/$id.json"
if [ -f "$state" ]; then
pid=$(jq -r '.pid // empty' "$state")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
started=$(jq -r '.started_epoch // empty' "$state")
fi
fi
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
id: (if (.id // "") == "" then $fallback_id else .id end), id: (if (.id // "") == "" then $fallback_id else .id end),
name: (.name // ""), name: (.name // ""),
prompt: (.prompt // ""), prompt: (.prompt // ""),
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
enabled: (.enabled == true), enabled: (.enabled == true),
working_dir: (.working_dir // "/workspace"), working_dir: (.working_dir // "/workspace"),
created_at: (.created_at // null), created_at: (.created_at // null),
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end) last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
}' "$f" }' "$f"
done | jq -s 'sort_by(.name, .id)' done | jq -s 'sort_by(.name, .id)'
"#; "#;
@@ -673,6 +691,7 @@ struct RawScheduledTask {
working_dir: String, working_dir: String,
created_at: Option<String>, created_at: Option<String>,
last_run_epoch: Option<i64>, last_run_epoch: Option<i64>,
running_since_epoch: Option<i64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
created_at: t.created_at, created_at: t.created_at,
last_run: t.last_run_epoch.map(epoch_to_iso), last_run: t.last_run_epoch.map(epoch_to_iso),
next_run, next_run,
running: t.running_since_epoch.is_some(),
running_since: t.running_since_epoch.map(epoch_to_iso),
} }
}) })
.collect()) .collect())
@@ -73,6 +73,25 @@ use crate::AppState;
/// Report how far behind the current base image a project's container is, and /// Report how far behind the current base image a project's container is, and
/// what migrating it would actually carry across. /// what migrating it would actually carry across.
/// ///
/// Choose the recorded lineage from the two places it can be written, most
/// authoritative first: the live container's label, then the snapshot image's.
///
/// **An empty label is absence, not an answer.** `create_container` always
/// writes `triple-c.base-image-id`, even when the value is unknown — that is
/// deliberate, because Docker merges an image's labels into a container's and
/// an inherited value would otherwise ride a snapshot forever. The consequence
/// is that `Some("")` is the *common* reading from a container whose lineage
/// was never established, so treating it as an answer silently skips the
/// snapshot, which may well have recorded a real one.
fn pick_recorded_lineage(
from_container: Option<String>,
from_snapshot: Option<String>,
) -> Option<String> {
from_container
.filter(|v| !v.is_empty())
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
}
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to /// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
/// be called on demand, not polled. /// be called on demand, not polled.
#[tauri::command] #[tauri::command]
@@ -98,20 +117,24 @@ pub async fn get_container_staleness(
// Lineage, most authoritative source first: the live container's label, // Lineage, most authoritative source first: the live container's label,
// then the snapshot image's. Both are written by `create_container` and // then the snapshot image's. Both are written by `create_container` and
// propagated onto the snapshot by `docker commit`. // propagated onto the snapshot by `docker commit`.
// Each source is filtered for emptiness *before* it is allowed to satisfy
// the lookup. `create_container` always writes this label, even when the
// value is unknown — deliberately, so an inherited image label cannot ride
// a snapshot forever — which means the container's copy is very often
// `Some("")`. Filtering only the final result let that empty string count
// as an answer and skip the snapshot entirely, so a snapshot that *did*
// record a lineage was never consulted and the project reported "unknown"
// with the information sitting one lookup away.
let container_id = docker::find_existing_container(&project).await.unwrap_or(None); let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
let recorded = match &container_id { let from_container = match &container_id {
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await, Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
None => None, None => None,
} };
.or_else(|| None); let from_snapshot = mig::image_labels(&snapshot_image)
let recorded = match recorded {
Some(v) => Some(v),
None => mig::image_labels(&snapshot_image)
.await .await
.get(mig::LABEL_BASE_IMAGE_ID) .get(mig::LABEL_BASE_IMAGE_ID)
.cloned(), .cloned();
} let recorded = pick_recorded_lineage(from_container, from_snapshot);
.filter(|v| !v.is_empty());
out.base_image_id = recorded.clone(); out.base_image_id = recorded.clone();
out.known = recorded.is_some(); out.known = recorded.is_some();
@@ -1671,6 +1694,32 @@ fn summarize(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn an_empty_lineage_label_is_absence_and_falls_through_to_the_snapshot() {
let some = |s: &str| Some(s.to_string());
// The regression: the container always carries the label, so an
// unknown lineage reads as `Some("")`. Letting that satisfy the lookup
// skipped a snapshot that had recorded the real thing.
assert_eq!(
pick_recorded_lineage(some(""), some("sha256:base")),
some("sha256:base")
);
// Ordinary precedence still holds: the container wins when it has one.
assert_eq!(
pick_recorded_lineage(some("sha256:container"), some("sha256:snapshot")),
some("sha256:container")
);
assert_eq!(pick_recorded_lineage(None, some("sha256:snap")), some("sha256:snap"));
// Genuinely unknown stays unknown — "probe instead", never a lineage
// invented to make the comparison succeed.
assert_eq!(pick_recorded_lineage(None, None), None);
assert_eq!(pick_recorded_lineage(some(""), some("")), None);
assert_eq!(pick_recorded_lineage(some(""), None), None);
}
#[test] #[test]
fn byte_sizes_read_the_way_a_disk_warning_should() { fn byte_sizes_read_the_way_a_disk_warning_should() {
assert_eq!(human_bytes(512), "512 B"); assert_eq!(human_bytes(512), "512 B");
+228 -5
View File
@@ -18,11 +18,12 @@ This container supports scheduled tasks via `triple-c-scheduler`. You can set up
### Commands ### Commands
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task - `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task - `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
- `triple-c-scheduler list` — List all scheduled tasks - `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column
- `triple-c-scheduler remove --id ID` — Remove a task - `triple-c-scheduler remove --id ID` — Remove a task
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks - `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs - `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately - `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications - `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
### Cron format ### Cron format
@@ -36,7 +37,7 @@ Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically re
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace). Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
### Checking results ### Checking results
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`. While a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
### Timezone ### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#; Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
@@ -797,6 +798,111 @@ async fn resolve_base_image_id(image_name: &str, base_image_name: &str) -> Strin
.unwrap_or_default() .unwrap_or_default()
} }
/// The `/dev/net/tun` character device, as it is named on both sides.
const TUN_DEVICE: &str = "/dev/net/tun";
/// The `HostConfig` fields "VPN support" contributes: `CapAdd`, `Devices`,
/// `Sysctls` — in that order.
type VpnHostConfigParts = (
Option<Vec<String>>,
Option<Vec<bollard::models::DeviceMapping>>,
Option<HashMap<String, String>>,
);
/// The three host-config pieces a VPN client needs, or all-`None` when the
/// project has not opted in.
///
/// Returned as a triple rather than set inline so the exact shape is unit
/// testable — a container is created once, by a very long async function, and a
/// silently-dropped capability looks identical to a VPN server that is simply
/// unreachable.
///
/// All three are required together and each fails differently on its own:
/// * **`CAP_NET_ADMIN`** — without it the client cannot create an interface or
/// write a route. Docker's default bounding set grants `net_raw` but not
/// `net_admin`, which is why a client can ping but never connect.
/// * **`/dev/net/tun`** — the device is absent from a default container, so
/// there is nothing to open even with the capability. It is passed through
/// from the host rather than `mknod`-ed inside, so the kernel's `tun` module
/// backs it.
/// * **`net.ipv4.conf.all.src_valid_mark`** — WireGuard's own `wg-quick` sets
/// this, and cannot from inside a container (`/proc/sys` is read-only), so
/// its handshake packets are dropped by reverse-path filtering. Harmless for
/// OpenVPN-based clients, so it is set unconditionally with the rest.
///
/// What it costs, stated accurately: Docker does not enable user-namespace
/// remapping by default, so this is a real `CAP_NET_ADMIN` in the *initial*
/// user namespace and only the **network** namespace confines it. It cannot
/// touch the host's interfaces, but within its own namespace it can set
/// promiscuous mode and add arbitrary addresses, routes and NAT rules on the
/// shared `docker0` L2 segment — which puts sibling containers (the LiteLLM
/// gateway among them) within reach of ARP spoofing, and lets netlink trigger
/// host-kernel module auto-loading. It is also enough to flush netfilter rules
/// inside the container, so pair it with `sandbox_mode_enabled` advisedly.
/// Hence opt-in, per project, rather than on for everyone.
fn vpn_host_config(enabled: bool) -> VpnHostConfigParts {
if !enabled {
return (None, None, None);
}
let devices = vec![bollard::models::DeviceMapping {
path_on_host: Some(TUN_DEVICE.to_string()),
path_in_container: Some(TUN_DEVICE.to_string()),
cgroup_permissions: Some("rwm".to_string()),
}];
let sysctls = HashMap::from([(
"net.ipv4.conf.all.src_valid_mark".to_string(),
"1".to_string(),
)]);
(
Some(vec!["NET_ADMIN".to_string()]),
Some(devices),
Some(sysctls),
)
}
/// Turn the daemon's device-passthrough failure into an explanation.
///
/// **This fires on `start`, not `create`.** Verified against Docker 29.7:
/// `docker create --device /dev/does-not-exist` succeeds and prints an id; the
/// device is only resolved when runc builds the container, so the failure lands
/// on the *next* call. Sysctls validate at the same point. Anything that
/// inspects only the create path will never see it — which is why both paths
/// route through here and the tests exercise the start-side string.
///
/// Unmapped, this reads as `Failed to start container: Docker responded with
/// status code 500: error gathering device information while adding custom
/// device "/dev/net/tun": no such file or directory` — a path the user will go
/// looking for on the wrong machine, since with Docker Desktop the relevant
/// host is the Linux VM rather than their own, and with nothing pointing back
/// at the switch that caused it.
///
/// Deliberately not gated on `vpn_support_enabled`: nothing else in Triple-C
/// ever asks for a device, so an error naming `/dev/net/tun` can only have come
/// from a container created with the switch on. That keeps the check usable
/// from [`start_container`], which has a container id and no project.
fn explain_container_failure(action: &str, err: &str) -> String {
let device_missing = err.contains(TUN_DEVICE)
&& (err.contains("no such file or directory")
|| err.contains("No such file or directory")
|| err.contains("error gathering device information"));
if device_missing {
return format!(
"Failed to {} container: the Docker host has no {} device, which \
\"VPN support\" requires. The host kernel needs the `tun` module \
loaded (on Docker Desktop that is the Linux VM, not your own \
machine). Turn VPN support off in Config → Runtime to start this \
project without it. Original error: {}",
action, TUN_DEVICE, err
);
}
format!("Failed to {} container: {}", action, err)
}
pub async fn create_container( pub async fn create_container(
project: &Project, project: &Project,
docker_socket_path: &str, docker_socket_path: &str,
@@ -1374,6 +1480,13 @@ pub async fn create_container(
labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
// Capabilities, devices and sysctls are fixed at creation, so this is
// container state and gets the label-and-compare treatment. Written
// unconditionally (`false`, not omitted) because `docker commit` copies
// container labels onto the snapshot image: a `true` stamped once would
// otherwise ride that snapshot into every future container and make the
// switch impossible to turn back off.
labels.insert("triple-c.vpn-support".to_string(), project.vpn_support_enabled.to_string());
labels.insert("triple-c.permission-mode".to_string(), labels.insert("triple-c.permission-mode".to_string(),
project.effective_permission_mode().as_env_value().to_string()); project.effective_permission_mode().as_env_value().to_string());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
@@ -1442,10 +1555,15 @@ pub async fn create_container(
labels.insert((*key).to_string(), (*value).to_string()); labels.insert((*key).to_string(), (*value).to_string());
} }
let (cap_add, devices, sysctls) = vpn_host_config(project.vpn_support_enabled);
let host_config = HostConfig { let host_config = HostConfig {
mounts: Some(mounts), mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
init: Some(true), init: Some(true),
cap_add,
devices,
sysctls,
..Default::default() ..Default::default()
}; };
@@ -1475,7 +1593,7 @@ pub async fn create_container(
let response = docker let response = docker
.create_container(Some(options), config) .create_container(Some(options), config)
.await .await
.map_err(|e| format!("Failed to create container: {}", e))?; .map_err(|e| explain_container_failure("create", &e.to_string()))?;
Ok(response.id) Ok(response.id)
} }
@@ -1485,7 +1603,7 @@ pub async fn start_container(container_id: &str) -> Result<(), String> {
docker docker
.start_container(container_id, None::<StartContainerOptions<String>>) .start_container(container_id, None::<StartContainerOptions<String>>)
.await .await
.map_err(|e| format!("Failed to start container: {}", e)) .map_err(|e| explain_container_failure("start", &e.to_string()))
} }
pub async fn stop_container(container_id: &str) -> Result<(), String> { pub async fn stop_container(container_id: &str) -> Result<(), String> {
@@ -2366,6 +2484,19 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── VPN support (NET_ADMIN + /dev/net/tun + sysctl) ───────────────────
// A container's capabilities, devices and sysctls are set at creation and
// cannot be changed on a running or stopped container, so recreation is the
// only way a toggle here takes effect. A missing label means the container
// predates the feature, which is the same thing as having it off — so
// existing projects are not churned until someone actually turns it on.
let expected_vpn = project.vpn_support_enabled.to_string();
let container_vpn = get_label("triple-c.vpn-support").unwrap_or_else(|| "false".to_string());
if container_vpn != expected_vpn {
log::info!("VPN support mismatch (container={:?}, expected={:?})", container_vpn, expected_vpn);
return Ok(true);
}
// ── Permission mode ──────────────────────────────────────────────────── // ── Permission mode ────────────────────────────────────────────────────
// The mode is injected as the TRIPLE_C_PERMISSION_MODE env var, and // The mode is injected as the TRIPLE_C_PERMISSION_MODE env var, and
// container env can only change by recreating the container. A missing // container env can only change by recreating the container. A missing
@@ -2615,6 +2746,98 @@ mod tests {
assert_eq!(fp, ""); assert_eq!(fp, "");
} }
#[test]
fn vpn_support_off_touches_nothing_in_the_host_config() {
// The default must stay byte-identical to a container created before the
// feature existed, or every project recreates on the next start.
let (cap_add, devices, sysctls) = vpn_host_config(false);
assert_eq!(cap_add, None);
assert_eq!(devices, None);
assert_eq!(sysctls, None);
}
#[test]
fn vpn_support_on_grants_all_three_pieces() {
// Each is useless without the others — a client with the capability but
// no device, or the device but no capability, still times out — so this
// asserts the whole set rather than any one of them.
let (cap_add, devices, sysctls) = vpn_host_config(true);
assert_eq!(cap_add, Some(vec!["NET_ADMIN".to_string()]));
let devices = devices.expect("the tun device must be passed through");
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].path_on_host.as_deref(), Some(TUN_DEVICE));
assert_eq!(devices[0].path_in_container.as_deref(), Some(TUN_DEVICE));
assert_eq!(devices[0].cgroup_permissions.as_deref(), Some("rwm"));
assert_eq!(
sysctls
.expect("wireguard needs src_valid_mark")
.get("net.ipv4.conf.all.src_valid_mark")
.map(String::as_str),
Some("1")
);
}
#[test]
fn vpn_support_never_grants_more_than_net_admin() {
// NET_ADMIN is already a step out of the sandbox. Anything else added
// here (SYS_ADMIN, or a blanket privileged flag) would be a much larger
// one, so pin the set.
let (cap_add, _, _) = vpn_host_config(true);
assert_eq!(cap_add.unwrap(), vec!["NET_ADMIN"]);
}
/// What bollard actually hands us when a tun-less host rejects the device.
///
/// Captured verbatim from Docker 29.7: `docker create` with a missing
/// device **succeeds**, and this arrives from the subsequent `start`.
/// `DockerResponseServerError`'s Display is
/// `"Docker responded with status code {code}: {message}"` with the
/// daemon's message unaltered.
const REAL_TUN_ERROR: &str = "Docker responded with status code 500: error \
gathering device information while adding custom device \
\"/dev/net/tun\": no such file or directory";
#[test]
fn a_missing_tun_device_is_explained_on_the_path_that_actually_fails() {
// The start path is the one that matters: the daemon defers device
// resolution to runc, so create returns an id on a host with no tun
// module and only start fails. A version of this that checked create
// alone would be dead code.
let msg = explain_container_failure("start", REAL_TUN_ERROR);
assert!(msg.starts_with("Failed to start container:"), "{}", msg);
assert!(msg.contains("VPN support"), "should name the switch: {}", msg);
assert!(msg.contains("tun` module"), "should name the cause: {}", msg);
assert!(msg.contains("Config → Runtime"), "should say where to fix it: {}", msg);
assert!(msg.contains(REAL_TUN_ERROR), "should keep the original: {}", msg);
}
#[test]
fn the_same_explanation_covers_create_if_the_daemon_ever_checks_earlier() {
// Belt and braces — older and future daemons may validate at create.
let msg = explain_container_failure("create", REAL_TUN_ERROR);
assert!(msg.starts_with("Failed to create container:"), "{}", msg);
assert!(msg.contains("VPN support"), "{}", msg);
}
#[test]
fn unrelated_failures_are_left_alone() {
for (action, err) in [
("create", "Conflict. The container name \"/triple-c-x\" is already in use"),
("start", "Docker responded with status code 404: No such container"),
("start", "error gathering device information while adding custom device \"/dev/dri/card0\""),
] {
assert_eq!(
explain_container_failure(action, err),
format!("Failed to {} container: {}", action, err),
"{} should pass through untouched",
err
);
}
}
#[test] #[test]
fn the_orphan_sweep_only_ever_looks_at_our_own_untagged_images() { fn the_orphan_sweep_only_ever_looks_at_our_own_untagged_images() {
// Both conditions are load-bearing. Without `dangling` the sweep would // Both conditions are load-bearing. Without `dangling` the sweep would
+17
View File
@@ -145,6 +145,22 @@ pub struct Project {
/// container-recreation label. /// container-recreation label.
#[serde(default)] #[serde(default)]
pub browser_view_enabled: bool, pub browser_view_enabled: bool,
/// Grant the container what a VPN client needs to build a tunnel:
/// `CAP_NET_ADMIN`, the `/dev/net/tun` device, and the WireGuard
/// `src_valid_mark` sysctl. Without all three a client (PIA, WireGuard,
/// OpenVPN) installs and runs but its connection attempt hangs until it
/// times out, because it cannot create the tunnel interface or touch the
/// routing table.
///
/// Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the
/// container reconfigure its own network stack, which reaches further than
/// it sounds — see `vpn_host_config` for what it does and does not confer.
/// Unlike `auth_bridge_enabled` this *is*
/// container state, so it carries a `triple-c.vpn-support` label and is
/// compared in `container_needs_recreation` — capabilities and devices are
/// fixed at creation and can only change by recreating the container.
#[serde(default)]
pub vpn_support_enabled: bool,
/// Use the shared, long-lived Claude Code OAuth token (from /// Use the shared, long-lived Claude Code OAuth token (from
/// `claude setup-token`, held in the OS keychain) for this project instead /// `claude setup-token`, held in the OS keychain) for this project instead
/// of requiring its own `claude login`. Only consulted when `backend` is /// of requiring its own `claude login`. Only consulted when `backend` is
@@ -366,6 +382,7 @@ impl Project {
mission_control_enabled: false, mission_control_enabled: false,
auth_bridge_enabled: false, auth_bridge_enabled: false,
browser_view_enabled: false, browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: default_use_shared_auth_token(), use_shared_auth_token: default_use_shared_auth_token(),
full_permissions: false, full_permissions: false,
permission_mode: None, permission_mode: None,
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import AutomationTab from "./AutomationTab";
import type { Project, ScheduledTask } from "../../../lib/types";
const listScheduledTasks = vi.fn(async () => tasks);
const getSchedulerNotifications = vi.fn(async () => []);
const runScheduledTaskNow = vi.fn(async () => "started");
const pushToast = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listScheduledTasks: () => listScheduledTasks(),
getSchedulerNotifications: () => getSchedulerNotifications(),
runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t),
clearSchedulerNotifications: vi.fn(async () => {}),
getScheduledTaskLog: vi.fn(async () => ""),
removeScheduledTask: vi.fn(async () => {}),
setScheduledTaskEnabled: vi.fn(async () => {}),
}));
vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const baseTask: ScheduledTask = {
id: "a1b2c3d4",
name: "nightly",
prompt: "Run the suite",
schedule: "0 3 * * *",
task_type: "recurring",
at: null,
enabled: true,
working_dir: "/workspace",
created_at: null,
last_run: null,
next_run: null,
running: false,
running_since: null,
};
let tasks: ScheduledTask[] = [];
async function renderTab() {
render(<AutomationTab project={project} />);
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
tasks = [baseTask];
listScheduledTasks.mockClear();
runScheduledTaskNow.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
describe("AutomationTab run state", () => {
it("offers Run now for an idle task and says nothing about running", async () => {
await renderTab();
expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled();
expect(screen.queryByText(/Running/)).toBeNull();
});
it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => {
const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString();
tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }];
await renderTab();
// The whole point: a detached run is visible rather than silent.
expect(screen.getByText(/Running for 1m/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
});
it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => {
await renderTab();
const callsAfterLoad = listScheduledTasks.mock.calls.length;
// The runner needs a moment to write its state file; until then the task
// still reads as idle, which is exactly the window that used to look dead.
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
await Promise.resolve();
});
expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4");
tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }];
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
});
expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad);
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
});
it("stops polling once nothing is running", async () => {
await renderTab();
// No trigger, nothing running: the interval must not be armed at all.
const before = listScheduledTasks.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
});
expect(listScheduledTasks.mock.calls.length).toBe(before);
});
});
@@ -15,7 +15,7 @@ import Toggle from "../../ui/Toggle";
import Modal from "../../ui/Modal"; import Modal from "../../ui/Modal";
import StatusIndicator from "../../ui/StatusIndicator"; import StatusIndicator from "../../ui/StatusIndicator";
import TaskEditorModal from "./TaskEditorModal"; import TaskEditorModal from "./TaskEditorModal";
import { formatAge } from "./format"; import { formatAge, formatRunningFor } from "./format";
interface Props { interface Props {
project: Project; project: Project;
@@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) {
useEffect(load, [load]); useEffect(load, [load]);
// A task in flight is the one state this view cannot sit still for: runs are
// detached, so without polling "Run now" looks like it did nothing until the
// user reaches for Refresh. Polling stops as soon as nothing is running.
//
// `justTriggered` covers the gap between firing a run and the runner writing
// its state file — a second or two in which the task still reads as idle, and
// where giving up on polling would reproduce the exact silence this fixes.
const anyTaskRunning = tasks.some((t) => t.running);
const [justTriggered, setJustTriggered] = useState(0);
useEffect(() => {
if (!running) return;
if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return;
const timer = setInterval(load, anyTaskRunning ? 5000 : 1500);
return () => clearInterval(timer);
}, [running, anyTaskRunning, justTriggered, load]);
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => { const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
setBusyTaskId(taskId); setBusyTaskId(taskId);
try { try {
@@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) {
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]"> <span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
{task.task_type} {task.task_type}
</span> </span>
{task.running && (
<StatusIndicator
tone="busy"
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
/>
)}
</div> </div>
<div className="text-xs text-[var(--text-secondary)] font-mono truncate"> <div className="text-xs text-[var(--text-secondary)] font-mono truncate">
{task.at ?? task.schedule} {task.at ?? task.schedule}
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
} }
/> />
<Button <Button
disabled={busyTaskId === task.id} disabled={busyTaskId === task.id || task.running}
onClick={() => onClick={() =>
withTask(task.id, "Run now", () => withTask(task.id, "Run now", async () => {
runScheduledTaskNow(project.id, task.id), await runScheduledTaskNow(project.id, task.id);
) setJustTriggered(Date.now());
})
} }
> >
Run now {task.running ? "Running…" : "Run now"}
</Button> </Button>
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}> <Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
Edit Edit
@@ -127,6 +127,46 @@ describe("ContainerMigrationBanner", () => {
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it("speaks up when an unlabelled container could not be probed at all", () => {
// The probe is the only signal a container with no lineage label has. If
// it fails and the banner stays silent, that is indistinguishable from
// "up to date" — the exact reading that let an out-of-date project go
// unnoticed indefinitely.
renderBanner(
migration({
staleness: {
...FRESH,
known: false,
stale: false,
probe_error: "output exceeded the inspection limit",
},
probeSettled: false,
}),
);
expect(
screen.getByText(/Container base could not be checked/i),
).toBeInTheDocument();
expect(
screen.getByText(/output exceeded the inspection limit/i),
).toBeInTheDocument();
// And it must not pose as a finding about the container itself.
expect(
screen.queryByText(/Container is missing things/i),
).not.toBeInTheDocument();
});
it("stays quiet when a labelled container's probe fails but its lineage is current", () => {
// `known` means the version comparison already answered the question, so
// a failed probe is not grounds to raise anything.
const { container } = renderBanner(
migration({
staleness: { ...FRESH, probe_error: "could not exec in the container" },
probeSettled: false,
}),
);
expect(container).toBeEmptyDOMElement();
});
it("disables the action and explains why while the container is running", () => { it("disables the action and explains why while the container is running", () => {
renderBanner(migration({ staleness: STALE }), false); renderBanner(migration({ staleness: STALE }), false);
expect( expect(
@@ -131,7 +131,15 @@ export default function ContainerMigrationBanner({
const probeFoundGaps = const probeFoundGaps =
!staleness.known && !staleness.known &&
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0); (staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
if (!staleness.stale && !probeFoundGaps) return null;
// The probe is the *only* signal a container with no lineage label has, so
// when it fails there is nothing left to be quiet about. Staying silent here
// is indistinguishable from "everything is fine" — and it is the likeliest
// outcome for the oldest, largest projects, whose manifests are the ones apt
// to exceed the inspection limit. Say that the check did not run instead.
const probeUnavailable = !staleness.known && !!staleness.probe_error;
if (!staleness.stale && !probeFoundGaps && !probeUnavailable) return null;
const snapshot = formatSnapshotDate(staleness.snapshot_created_at); const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
const features = joinFeatures(staleness.missing_features); const features = joinFeatures(staleness.missing_features);
@@ -139,15 +147,24 @@ export default function ContainerMigrationBanner({
return ( return (
<section <section
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`} className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
aria-label="Container base is out of date" aria-label={
probeUnavailable
? "Container base could not be checked"
: "Container base is out of date"
}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<StatusIndicator <StatusIndicator
tone="error" // A check that could not run is not a finding: it gets the
// "unresolved" tone rather than the one that says something is
// wrong with the container.
tone={probeUnavailable ? "unknown" : "error"}
label={ label={
staleness.known staleness.known
? "Container base is out of date" ? "Container base is out of date"
: probeUnavailable
? "Container base could not be checked"
: "Container is missing things the current base ships" : "Container is missing things the current base ships"
} }
className="text-[13px] font-semibold" className="text-[13px] font-semibold"
@@ -158,6 +175,8 @@ export default function ContainerMigrationBanner({
? snapshot ? snapshot
? `Running on a saved image from ${snapshot}.` ? `Running on a saved image from ${snapshot}.`
: "Running on a saved image older than the current base." : "Running on a saved image older than the current base."
: probeUnavailable
? "This container predates base-image tracking, so probing it is the only way to tell whether it is behind — and that did not complete."
: "This container predates base-image tracking, so it was probed directly."} : "This container predates base-image tracking, so it was probed directly."}
</p> </p>
@@ -120,6 +120,15 @@ export default function OverviewTab({
{project.mission_control_enabled ? "ON" : "OFF"} {project.mission_control_enabled ? "ON" : "OFF"}
</span> </span>
</span> </span>
{/* Only when granted. It is off for nearly every project and an
always-present "VPN OFF" would be noise, but where it *is* on the
container holds NET_ADMIN, which is worth seeing at a glance. */}
{project.vpn_support_enabled && (
<span className="text-[var(--text-secondary)]">
VPN support{" "}
<span className="text-[var(--text-primary)] font-medium">ON</span>
</span>
)}
<button <button
type="button" type="button"
onClick={() => onOpenTab("config")} onClick={() => onOpenTab("config")}
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
created_at: null, created_at: null,
last_run: null, last_run: null,
next_run: null, next_run: null,
running: false,
running_since: null,
}; };
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) { async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import RuntimeSection from "./RuntimeSection";
import type { Project } from "../../../../lib/types";
const baseProject: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/src/api", mount_name: "api" }],
container_id: null,
status: "stopped",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
llamacpp_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
ca_cert_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const VPN = "VPN support";
const save = vi.fn().mockResolvedValue(true);
function renderSection(over: Partial<Project> = {}, disabled = false) {
return render(
<RuntimeSection
project={{ ...baseProject, ...over }}
save={save}
disabled={disabled}
disabledReason="Container must be stopped to change this setting."
/>,
);
}
describe("RuntimeSection — VPN support toggle", () => {
beforeEach(() => vi.clearAllMocks());
it("saves only the VPN flag when switched on", () => {
renderSection();
fireEvent.click(screen.getByRole("switch", { name: VPN }));
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: true });
});
it("saves the flag off again, rather than dropping the key", () => {
// Off has to be written explicitly: the container carries a
// `triple-c.vpn-support` label either way, and an absent value would leave
// the capability granted.
renderSection({ vpn_support_enabled: true });
fireEvent.click(screen.getByRole("switch", { name: VPN }));
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: false });
});
it("reflects the project's current state", () => {
renderSection({ vpn_support_enabled: true });
expect(screen.getByRole("switch", { name: VPN })).toBeChecked();
});
it("cannot be changed while the container is running", () => {
// Capabilities and devices are fixed at creation, so this setting is gated
// on the container being stopped along with the rest of the tab.
renderSection({}, true);
const toggle = screen.getByRole("switch", { name: VPN });
expect(toggle).toBeDisabled();
fireEvent.click(toggle);
expect(save).not.toHaveBeenCalled();
});
it("warns that the change recreates the container", () => {
renderSection();
expect(
screen.getByText(/recreates the container on its next start/i),
).toBeInTheDocument();
});
});
@@ -57,6 +57,19 @@ export default function RuntimeSection({
} }
/> />
<SwitchRow
label="VPN support"
hint="Grants NET_ADMIN and the /dev/net/tun device so a VPN client (PIA, WireGuard, OpenVPN) can build a tunnel inside the container. Without it a client installs and runs but its connection hangs until it times out. Anything in the container can then reconfigure the container's own network stack; the host's is untouched. Changing this recreates the container on its next start — the home and .claude volumes are preserved."
control={
<Toggle
label="VPN support"
checked={project.vpn_support_enabled}
disabled={disabled}
onChange={(v) => save({ vpn_support_enabled: v })}
/>
}
/>
<SwitchRow <SwitchRow
label="Mission Control" label="Mission Control"
hint="A web dashboard for monitoring and managing Claude sessions remotely." hint="A web dashboard for monitoring and managing Claude sessions remotely."
@@ -26,6 +26,21 @@ export function formatElapsed(ms: number): string {
return `${days}d ago`; return `${days}d ago`;
} }
/** "for 42s" / "for 4m" / "for 1h 12m" elapsed phrasing for a run in flight.
* Seconds are kept below a minute because the first thing anyone wants from a
* freshly triggered run is evidence that it started at all. */
export function formatRunningFor(iso: string | null | undefined): string | null {
if (!iso) return null;
const started = Date.parse(iso);
if (Number.isNaN(started)) return null;
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
if (seconds < 60) return `for ${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `for ${minutes}m`;
const hours = Math.floor(minutes / 60);
return `for ${hours}h ${minutes % 60}m`;
}
/** Uptime phrasing for a known start timestamp. */ /** Uptime phrasing for a known start timestamp. */
export function formatUptime(startedAtMs: number | undefined): string | null { export function formatUptime(startedAtMs: number | undefined): string | null {
if (startedAtMs === undefined) return null; if (startedAtMs === undefined) return null;
+8
View File
@@ -33,6 +33,10 @@ export interface Project {
auth_bridge_enabled: boolean; auth_bridge_enabled: boolean;
/** Opt in to the browser-view pane. Host-side only, like `auth_bridge_enabled`. */ /** Opt in to the browser-view pane. Host-side only, like `auth_bridge_enabled`. */
browser_view_enabled: boolean; browser_view_enabled: boolean;
/** Grant NET_ADMIN, /dev/net/tun and the WireGuard `src_valid_mark` sysctl so
* a VPN client inside the container can build a tunnel. Unlike the two flags
* above this is container state changing it recreates the container. */
vpn_support_enabled: boolean;
/** Use the shared long-lived Claude Code token (from `claude setup-token`, /** Use the shared long-lived Claude Code token (from `claude setup-token`,
* held in the OS keychain) instead of this project's own `claude login`. * held in the OS keychain) instead of this project's own `claude login`.
* Defaults to true; only applies when `backend` is "anthropic" and a token * Defaults to true; only applies when `backend` is "anthropic" and a token
@@ -388,6 +392,10 @@ export interface ScheduledTask {
last_run: string | null; last_run: string | null;
/** Known only for enabled one-shot tasks; cron is not evaluated. */ /** Known only for enabled one-shot tasks; cron is not evaluated. */
next_run: string | null; next_run: string | null;
/** A run is in flight right now (the runner's pid was verified live). */
running: boolean;
/** When that run started. Null unless `running`. */
running_since: string | null;
} }
/** Mirrors Rust `ScheduleKind` which of the scheduler's two `add` flags to /** Mirrors Rust `ScheduleKind` which of the scheduler's two `add` flags to
+7
View File
@@ -318,6 +318,13 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
RUN chmod +x /usr/local/bin/triple-c-scheduler RUN chmod +x /usr/local/bin/triple-c-scheduler
# Lives in /usr/local/bin rather than under /home/claude on purpose: the home
# directory is the mount point of the project's home volume, so an image copy
# of it is masked after the project's first start and can never be updated
# again. /usr/local/bin rides the snapshot and is replaced on migration, which
# is what lets a fix to this script reach an existing project at all.
COPY triple-c-playwright-heal /usr/local/bin/triple-c-playwright-heal
RUN chmod +x /usr/local/bin/triple-c-playwright-heal
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
RUN chmod +x /usr/local/bin/triple-c-task-runner RUN chmod +x /usr/local/bin/triple-c-task-runner
+36 -1
View File
@@ -425,6 +425,32 @@ if [ -x /usr/local/bin/triple-c-open ]; then
export BROWSER=/usr/local/bin/triple-c-open export BROWSER=/usr/local/bin/triple-c-open
fi fi
# ── Playwright browser config ───────────────────────────────────────────────
# Seed ~/.playwright/cli.config.json on every start.
#
# Without it `playwright-cli` resolves to channel `chrome` — system Google
# Chrome — with the Chromium sandbox ON, and these containers do not permit
# unprivileged user namespaces, so the browser aborts with "Failed to move to
# new namespace ... Operation not permitted". On a base image that no longer
# ships Google Chrome the same default fails the other way, with "Chromium
# distribution 'chrome' is not found". One cause, two error messages, and
# neither of them looks like a configuration problem.
#
# Seeded here rather than baked into the image because ~/.playwright is inside
# the home volume: an image copy would reach new projects only, and every
# existing project would stay broken forever. Written on every start from a
# source outside the volume, the way CLAUDE_INSTRUCTIONS and the Mission
# Control skills already are.
#
# --seed-config-only is the cheap path: no npm install, no browser download, no
# apt, no verify launch, nothing over the network. It writes one small file if
# it is absent and returns. Measured at ~2 ms. The heavier repairs stay
# on-demand — run `triple-c-playwright-heal` with no arguments for those.
if [ -x /usr/local/bin/triple-c-playwright-heal ]; then
/usr/local/bin/triple-c-playwright-heal --seed-config-only --quiet || \
echo "entrypoint: warning — playwright config seeding failed (browser view may not launch)"
fi
# ── Scheduler setup ───────────────────────────────────────────────────────── # ── Scheduler setup ─────────────────────────────────────────────────────────
SCHEDULER_DIR="/home/claude/.claude/scheduler" SCHEDULER_DIR="/home/claude/.claude/scheduler"
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications" mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
@@ -434,17 +460,26 @@ chown -R claude:claude "$SCHEDULER_DIR"
cron cron
# Save environment variables for cron jobs (cron runs with a minimal env) # Save environment variables for cron jobs (cron runs with a minimal env)
#
# HOME is deliberately NOT captured here. This entrypoint runs as root, so the
# snapshot would record HOME=/root — and the task runner sources this file with
# `set -a`, which would overwrite the HOME cron gives the job. Claude Code then
# looks for its OAuth credential at /root/.claude/.credentials.json instead of
# /home/claude/.claude/.credentials.json and every scheduled task dies with
# "Not logged in · Please run /login". Cron still needs a HOME, so it is written
# explicitly below with the value the `claude` user actually has.
ENV_FILE="$SCHEDULER_DIR/.env" ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE" : > "$ENV_FILE"
env | while IFS='=' read -r key value; do env | while IFS='=' read -r key value; do
case "$key" in case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE) ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
# Escape single quotes in value and write as KEY='VALUE' # Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
;; ;;
esac esac
done done
printf "HOME='/home/claude'\n" >> "$ENV_FILE"
chown claude:claude "$ENV_FILE" chown claude:claude "$ENV_FILE"
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
+272
View File
@@ -0,0 +1,272 @@
#!/bin/bash
# triple-c-playwright-heal — make Playwright usable in a Triple-C container.
#
# Idempotent: safe to run on every start and safe to re-run after a partial
# failure. Each step checks for its own result first, so a healthy container is
# a fast no-op that still prints why it is healthy. The last step is the only
# one that means anything: it launches a browser for real.
#
# The things that go wrong, in the order they bite:
#
# 1. @playwright/cli missing — including the case where it was installed and
# then silently removed again. `npm install --no-save <pkg>` in /workspace,
# which has no package.json, prunes packages npm considers extraneous, so
# installing @playwright/cli and then installing playwright wipes the
# first one and leaves an empty node_modules/@playwright/. That directory
# reads as "installed" to a naive check, which is why this script tests
# the package *entry point*.
#
# 2. Bundled chromium missing or the wrong revision. Browsers live in the
# home volume and outlive any single @playwright/cli install, so a stale
# chromium-<old> is routinely present while the installed playwright-core
# wants a newer one. Must be installed AS claude: run as root it lands in
# /root/.cache/ms-playwright where the agent cannot see it.
#
# 3. No cli.config.json — the one that breaks an otherwise clean install.
# With no config, playwright-cli resolves to channel `chrome` (system
# Google Chrome) with the sandbox ON. These containers forbid unprivileged
# user namespaces, so Chrome aborts with "Failed to move to new namespace
# ... Operation not permitted". On newer base images Chrome is not present
# at all and it fails with "Chromium distribution 'chrome' is not found".
# Same root cause both ways: the default channel is wrong here.
#
# 4. The storage-state file the config points at is missing. Playwright
# treats an unreadable storageState as a hard error on every launch, not
# as "no saved state", so the file has to exist from the very first run.
#
# 5. xvfb or socat missing (older base images only). Headless Playwright
# needs neither; the `playwright-cli show` dashboard needs xvfb, and the
# browser-view pane needs socat — without it the pane reports
# "127.0.0.1 sent an invalid response" while the container side is fine.
#
# Usage: triple-c-playwright-heal [--seed-config-only] [--force-config] [--quiet]
# --seed-config-only only ensure the config and its storage-state file
# exist. No npm install, no browser download, no apt, no
# verify launch. Cheap and offline — this is the mode
# entrypoint.sh runs on every container start.
# --force-config overwrite an existing config instead of keeping it
# --quiet print only problems and repairs, not healthy no-ops
set -u
TARGET_USER=claude
TARGET_HOME=/home/claude
PW_DIR=/workspace
CONFIG_DIR="$TARGET_HOME/.playwright"
CONFIG_FILE="$CONFIG_DIR/cli.config.json"
STATE_FILE="$CONFIG_DIR/storage-state.json"
CLI_ENTRY="$PW_DIR/node_modules/@playwright/cli/playwright-cli.js"
FORCE_CONFIG=0
QUIET=0
SEED_ONLY=0
for arg in "$@"; do
case "$arg" in
--force-config) FORCE_CONFIG=1 ;;
--quiet) QUIET=1 ;;
--seed-config-only) SEED_ONLY=1 ;;
*) echo "playwright-heal: unknown option: $arg" >&2; exit 2 ;;
esac
done
changed=0
failed=0
say() { [ "$QUIET" = 1 ] || echo "playwright-heal: $*"; }
warn() { echo "playwright-heal: $*" >&2; }
did() { changed=1; echo "playwright-heal: $*"; }
# Run as claude whether we were invoked as root (docker exec / entrypoint) or
# as claude (terminal session). Nothing user-visible may end up root-owned.
as_claude() {
if [ "$(id -u)" = 0 ]; then
su "$TARGET_USER" -s /bin/sh -c "$1"
else
sh -c "$1"
fi
}
# ── 1. @playwright/cli ───────────────────────────────────────────────────────
if [ "$SEED_ONLY" = 1 ]; then
:
elif [ -f "$CLI_ENTRY" ]; then
say "@playwright/cli present"
else
# An empty leftover @playwright/ can make npm consider the tree settled.
if [ -d "$PW_DIR/node_modules/@playwright" ]; then
say "clearing partial @playwright install"
rm -rf "$PW_DIR/node_modules/@playwright"
fi
say "installing @playwright/cli..."
if as_claude "cd $PW_DIR && npm install --no-save --no-audit --no-fund @playwright/cli" >/tmp/pw-heal-npm.log 2>&1; then
did "installed @playwright/cli"
else
warn "npm install failed; see /tmp/pw-heal-npm.log"
failed=1
fi
fi
# ── 2. bundled chromium ──────────────────────────────────────────────────────
# Ask Playwright where *this* version's chromium belongs rather than globbing
# chromium-*, which would call a stale revision "present" and then fail at
# launch with 'Browser "chrome-for-testing" is not installed'. --dry-run prints
# the install location for the installed version and downloads nothing.
if [ "$SEED_ONLY" = 1 ]; then
:
else
chromium_dir=""
if [ -f "$PW_DIR/node_modules/playwright-core/cli.js" ]; then
chromium_dir=$(as_claude "cd $PW_DIR && node node_modules/playwright-core/cli.js install --dry-run chromium 2>/dev/null" \
| awk '/Install location:/ { print $3; exit }')
fi
if [ -n "$chromium_dir" ] && [ -d "$chromium_dir" ]; then
say "chromium present ($(basename "$chromium_dir"))"
elif [ -f "$PW_DIR/node_modules/playwright-core/cli.js" ]; then
say "downloading chromium (~300 MB)..."
if as_claude "cd $PW_DIR && node node_modules/playwright-core/cli.js install chromium" >/tmp/pw-heal-browser.log 2>&1; then
did "installed chromium"
else
warn "chromium install failed; see /tmp/pw-heal-browser.log"
failed=1
fi
else
warn "playwright-core missing, cannot install chromium"
failed=1
fi
fi
# ── 3. cli.config.json ───────────────────────────────────────────────────────
# The *global* config, not a project-level .playwright/, because the project
# one resolves relative to the current working directory and silently stops
# applying the moment you cd elsewhere.
#
# `chrome-for-testing` is the only recognised chromium alias — "chromium" is
# not one and falls back to system Chrome. chromiumSandbox:false is what
# actually appends --no-sandbox.
write_config() {
mkdir -p "$CONFIG_DIR" || return 1
cat > "$CONFIG_FILE" <<EOF
{
"browser": {
"browserName": "chromium",
"launchOptions": {
"channel": "chrome-for-testing",
"chromiumSandbox": false,
"args": ["--no-sandbox", "--disable-dev-shm-usage"]
},
"contextOptions": {
"storageState": "$STATE_FILE"
}
}
}
EOF
chown -R "$TARGET_USER:$TARGET_USER" "$CONFIG_DIR" 2>/dev/null || true
}
# storageState is a *load* path, and Playwright reads it at context creation.
# A path that does not exist is not treated as "no saved state" — it is a hard
# error, "Error reading storage state from …", on every single launch. So the
# file has to exist before the config that names it can be used at all, and it
# has to be recreated if anything deletes it. An empty state is valid and
# behaves exactly like no state.
#
# The path is read back out of the config rather than assumed, so a
# hand-edited config pointing somewhere else still gets its file created
# instead of being silently broken by ours.
ensure_state_file() {
[ -f "$CONFIG_FILE" ] || return 0
state_path=$(grep -o '"storageState"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" 2>/dev/null \
| sed 's/.*"\([^"]*\)"[[:space:]]*$/\1/')
[ -n "$state_path" ] || return 0
[ -f "$state_path" ] && return 0
mkdir -p "$(dirname "$state_path")" 2>/dev/null
printf '{\n "cookies": [],\n "origins": []\n}\n' > "$state_path" || return 1
chown "$TARGET_USER:$TARGET_USER" "$state_path" 2>/dev/null || true
did "created empty $state_path (storageState needs it to exist)"
}
if [ ! -f "$CONFIG_FILE" ]; then
if write_config; then did "wrote $CONFIG_FILE"; else warn "could not write $CONFIG_FILE"; failed=1; fi
elif [ "$FORCE_CONFIG" = 1 ]; then
if write_config; then did "overwrote $CONFIG_FILE (--force-config)"; else warn "could not write $CONFIG_FILE"; failed=1; fi
elif grep -q '"chromiumSandbox"[[:space:]]*:[[:space:]]*false' "$CONFIG_FILE" 2>/dev/null; then
say "config present and disables the sandbox"
else
# Present but hand-edited into a state that will not launch. Do not clobber
# deliberate config silently; say what is wrong and how to replace it.
warn "config at $CONFIG_FILE does not set chromiumSandbox:false — the browser will likely fail to launch. Re-run with --force-config to replace it."
fi
# Unconditional: the config may name a storageState this run did not write —
# one seeded by an older version of this script, or edited by hand — and a
# missing file there breaks every launch.
ensure_state_file || { warn "could not create the storage-state file"; failed=1; }
if [ "$SEED_ONLY" = 1 ]; then
[ "$failed" = 1 ] && exit 1
exit 0
fi
# ── 4. xvfb (headed dashboard only) ──────────────────────────────────────────
# Current base images get this from `playwright install-deps` (its `tools`
# group); older ones predate that layer. Headless never needs it, so a missing
# xvfb is a note, not a failure.
if command -v Xvfb >/dev/null 2>&1; then
say "xvfb present"
elif [ "$(id -u)" = 0 ]; then
say "installing xvfb (needed only for the headed dashboard)..."
if (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq xvfb) >/tmp/pw-heal-xvfb.log 2>&1; then
did "installed xvfb"
else
warn "xvfb install failed (headless still works); see /tmp/pw-heal-xvfb.log"
fi
else
say "xvfb missing and not running as root — skipping (headless still works)"
fi
# ── 4b. socat (the browser-view pane's tunnel) ───────────────────────────────
# Not Playwright's, but the same class of failure and it presents as a
# Playwright problem: the pane's host-side proxy reaches the dashboard by
# running `socat` *inside* the container over a Docker exec. On a container old
# enough to predate socat in the base image, that exec produces something that
# is not an HTTP response, and the webview reports "127.0.0.1 sent an invalid
# response" — with the container side working perfectly. A project keeps the
# base image it was first built from until it is migrated, so this is the
# normal case on an older project, not an exotic one.
if command -v socat >/dev/null 2>&1; then
say "socat present"
elif [ "$(id -u)" = 0 ]; then
say "installing socat (needed by the browser-view pane)..."
if (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq socat) >/tmp/pw-heal-socat.log 2>&1; then
did "installed socat"
else
warn "socat install failed; the browser-view pane will report an invalid response. See /tmp/pw-heal-socat.log"
failed=1
fi
else
warn "socat missing and not running as root — the browser-view pane will report an invalid response"
fi
# ── 5. verify by actually launching ──────────────────────────────────────────
# Every step above can report success while the browser still refuses to
# start — that is precisely how this broke. A dedicated session name keeps
# this clear of whatever the agent already has open.
if [ -f "$CLI_ENTRY" ]; then
verify_out=$(as_claude "cd /tmp && timeout 90 node $CLI_ENTRY -s=heal-verify open 'data:text/html,<h1>ok</h1>' 2>&1")
if printf '%s' "$verify_out" | grep -q 'opened with pid'; then
say "verified: browser launches"
as_claude "cd /tmp && timeout 30 node $CLI_ENTRY -s=heal-verify close" >/dev/null 2>&1
else
warn "browser still fails to launch:"
printf '%s\n' "$verify_out" | grep -m4 -E 'namespace|Check failed|is not installed|is not found|missing dependencies|Error' >&2
failed=1
fi
else
warn "@playwright/cli not installed — nothing to verify"
failed=1
fi
[ "$failed" = 1 ] && exit 1
[ "$changed" = 1 ] && say "done — repairs applied" || say "done — nothing to repair"
exit 0
+180 -6
View File
@@ -8,17 +8,59 @@ SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks" TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs" LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications" NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
ensure_dirs() { ensure_dirs() {
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
} }
generate_id() { generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n' head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
} }
# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
# triple-c-task-runner writes the file and removes it from an EXIT trap, but a
# trap cannot fire for SIGKILL or a container stop mid-run. So the pid is
# checked rather than believed, and a state file whose process is gone is
# cleared here — otherwise one hard stop leaves a task reading as "running"
# forever, which is worse than no indicator at all.
run_state() {
local id="$1"
local state_file="${RUNNING_DIR}/${id}.json"
[ -f "$state_file" ] || return 1
local pid
pid=$(jq -r '.pid // empty' "$state_file" 2>/dev/null)
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
rm -f "$state_file"
return 1
fi
printf '%s\t%s\t%s\n' \
"$pid" \
"$(jq -r '.started_epoch // 0' "$state_file")" \
"$(jq -r '.log // ""' "$state_file")"
}
# Compact elapsed time since an epoch, e.g. "8s", "4m12s", "1h07m".
elapsed_since() {
local start="$1" now delta
now=$(date +%s)
delta=$(( now - start ))
[ "$delta" -lt 0 ] && delta=0
if [ "$delta" -ge 3600 ]; then
printf '%dh%02dm' $(( delta / 3600 )) $(( (delta % 3600) / 60 ))
elif [ "$delta" -ge 60 ]; then
printf '%dm%02ds' $(( delta / 60 )) $(( delta % 60 ))
else
printf '%ds' "$delta"
fi
}
# Reject a malformed cron expression at the point of entry. # Reject a malformed cron expression at the point of entry.
# #
# Without this an invalid schedule is written to a task file, and the next # Without this an invalid schedule is written to a task file, and the next
@@ -85,8 +127,9 @@ Commands:
enable Enable a disabled task enable Enable a disabled task
disable Disable a task disable Disable a task
list List all tasks list List all tasks
status Show which tasks are running right now
logs Show execution logs logs Show execution logs
run Manually trigger a task now run Manually trigger a task now (streams its log)
notifications Show or clear completion notifications notifications Show or clear completion notifications
Add options: Add options:
@@ -99,6 +142,10 @@ Add options:
Remove/Enable/Disable/Run options: Remove/Enable/Disable/Run options:
--id ID Task ID (required) --id ID Task ID (required)
Status options:
--id ID Show one task, including its last result when idle
--watch, -w Refresh every 5s until the run finishes
Logs options: Logs options:
--id ID Show logs for a specific task (optional) --id ID Show logs for a specific task (optional)
--tail N Show last N lines (default: 50) --tail N Show last N lines (default: 50)
@@ -313,8 +360,8 @@ cmd_disable() {
cmd_list() { cmd_list() {
local found=false local found=false
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT" printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "STATUS" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────" printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "────────────" "──────────────────────────────"
for task_file in "$TASKS_DIR"/*.json; do for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue [ -f "$task_file" ] || continue
@@ -333,12 +380,21 @@ cmd_list() {
display_schedule="at $at" display_schedule="at $at"
fi fi
local status state started
if state=$(run_state "$id"); then
started=$(printf '%s' "$state" | cut -f2)
status="running $(elapsed_since "$started")"
else
status="idle"
fi
# Truncate long fields for display # Truncate long fields for display
[ ${#name} -gt 20 ] && name="${name:0:17}..." [ ${#name} -gt 20 ] && name="${name:0:17}..."
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..." [ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..." [ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt" printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
"$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
done done
if [ "$found" = "false" ]; then if [ "$found" = "false" ]; then
@@ -346,6 +402,78 @@ cmd_list() {
fi fi
} }
# Is anything running, and how far along is it?
#
# This is the command for the question "did my `run` do anything, or has it
# stalled?" — `logs` alone cannot answer it, because a log that stops growing
# looks identical whether Claude is thinking or the run is dead.
cmd_status() {
local id="" watch=false
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--watch|-w) watch=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
while true; do
local any=false
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local tid
tid=$(jq -r '.id' "$task_file")
[ -z "$id" ] || [ "$tid" = "$id" ] || continue
local name state
name=$(jq -r '.name' "$task_file")
if state=$(run_state "$tid"); then
any=true
local pid started log
pid=$(printf '%s' "$state" | cut -f1)
started=$(printf '%s' "$state" | cut -f2)
log=$(printf '%s' "$state" | cut -f3)
echo "● RUNNING $name ($tid)"
echo " elapsed: $(elapsed_since "$started") pid: $pid"
echo " log: $log"
# `claude -p` writes its answer in one go at the end, so a log
# with only its header is the normal state of a healthy run —
# print the tail only when there is something to show, rather
# than an empty "last output:" that reads like a stall.
# `|| true` throughout: under `set -e` a grep matching nothing
# would otherwise abort the whole command.
local tail_out=""
if [ -f "$log" ]; then
tail_out=$({ grep -v '^===' "$log" || true; } \
| { grep -v '^$' || true; } | tail -n 3)
fi
if [ -n "$tail_out" ]; then
echo " last output:"
printf '%s\n' "$tail_out" | sed 's/^/ /'
fi
elif [ -n "$id" ]; then
echo "○ idle $name ($tid)"
local latest
latest=$(ls -t "$LOGS_DIR/$tid"/*.log 2>/dev/null | head -1) || true
if [ -n "$latest" ]; then
echo " last run: $(basename "$latest" .log) $(grep -o 'Exit code: [0-9]*' "$latest" | tail -1)"
fi
fi
done
if [ "$any" = "false" ] && [ -z "$id" ]; then
echo "Nothing running."
fi
[ "$watch" = "true" ] || break
# Stop watching once the thing being watched has finished.
[ "$any" = "true" ] || break
sleep 5
echo ""
done
}
cmd_logs() { cmd_logs() {
local id="" tail_n=50 local id="" tail_n=50
@@ -413,8 +541,53 @@ cmd_run() {
local name local name
name=$(jq -r '.name' "$task_file") name=$(jq -r '.name' "$task_file")
if run_state "$id" >/dev/null; then
echo "Task '$name' ($id) is already running — see: triple-c-scheduler status --id $id"
return 0
fi
echo "Manually triggering task '$name' ($id)..." echo "Manually triggering task '$name' ($id)..."
/usr/local/bin/triple-c-task-runner "$id"
# Run in the background and stream its log. A task can easily think for
# minutes, and the previous behaviour — block with no output until it is
# over — is indistinguishable from a hang.
/usr/local/bin/triple-c-task-runner "$id" &
local runner_pid=$!
local state="" waited=0
while [ "$waited" -lt 20 ]; do
if state=$(run_state "$id"); then
break
fi
kill -0 "$runner_pid" 2>/dev/null || break
sleep 0.5
waited=$(( waited + 1 ))
done
local log=""
[ -n "$state" ] && log=$(printf '%s' "$state" | cut -f3)
if [ -n "$log" ]; then
echo " log: $log"
echo " elsewhere: triple-c-scheduler status --id $id --watch"
echo ""
# --pid stops the follow when the runner exits, so this returns on its own.
tail -n +1 -f --pid="$runner_pid" "$log" 2>/dev/null
fi
local rc=0
wait "$runner_pid" || rc=$?
# A run short enough that its state file was never observed still deserves
# its output shown rather than swallowed.
if [ -z "$log" ]; then
local latest
latest=$(ls -t "$LOGS_DIR/$id"/*.log 2>/dev/null | head -1) || true
[ -n "$latest" ] && tail -n 20 "$latest"
fi
return $rc
} }
cmd_notifications() { cmd_notifications() {
@@ -464,6 +637,7 @@ case "$command" in
enable) cmd_enable "$@" ;; enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;; disable) cmd_disable "$@" ;;
list) cmd_list ;; list) cmd_list ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;; logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;; run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;; notifications) cmd_notifications "$@" ;;
+30
View File
@@ -9,6 +9,7 @@ SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks" TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs" LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications" NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
ENV_FILE="${SCHEDULER_DIR}/.env" ENV_FILE="${SCHEDULER_DIR}/.env"
TASK_ID="${1:-}" TASK_ID="${1:-}"
@@ -34,11 +35,19 @@ if ! flock -n 200; then
fi fi
# ── Source saved environment ───────────────────────────────────────────────── # ── Source saved environment ─────────────────────────────────────────────────
# The env file is a snapshot taken by the entrypoint, which runs as root. A
# snapshot written before the entrypoint stopped capturing HOME still carries
# HOME=/root, and `set -a` would apply it to `claude` below — which then finds no
# credential under /root/.claude and exits with "Not logged in". The env file
# lives on the home volume, so those stale copies outlive an image update until
# the container is restarted; keep our own HOME regardless of what it says.
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
REAL_HOME="${HOME:-/home/claude}"
set -a set -a
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$ENV_FILE" source "$ENV_FILE"
set +a set +a
HOME="$REAL_HOME"
fi fi
# ── Read task definition ──────────────────────────────────────────────────── # ── Read task definition ────────────────────────────────────────────────────
@@ -69,6 +78,27 @@ mkdir -p "$TASK_LOG_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S") TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log" LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
# ── Publish run state ───────────────────────────────────────────────────────
# A scheduled run is detached — cron has no terminal, and the app fires it as a
# detached exec — so without this there is no way to tell a task that is still
# thinking from one that died, and a long run reads as a stall. `list`, `status`
# and the app's Automation tab all read this file.
#
# flock above is what actually prevents overlapping runs; this is purely an
# observability record, which is why readers verify the pid rather than trust
# the file. The EXIT trap covers the crash paths (OOM, container stop, SIGTERM)
# that would otherwise leave a task looking like it had been running for days.
mkdir -p "$RUNNING_DIR"
RUN_STATE="${RUNNING_DIR}/${TASK_ID}.json"
trap 'rm -f "$RUN_STATE"' EXIT
jq -n \
--arg pid "$$" \
--arg started "$(date +%s)" \
--arg log "$LOG_FILE" \
--arg name "$TASK_NAME" \
'{pid: ($pid | tonumber), started_epoch: ($started | tonumber), log: $log, name: $name}' \
> "$RUN_STATE"
# ── Execute Claude agent ──────────────────────────────────────────────────── # ── Execute Claude agent ────────────────────────────────────────────────────
{ {
echo "=== Task: $TASK_NAME ($TASK_ID) ===" echo "=== Task: $TASK_NAME ($TASK_ID) ==="