Un dessin Excalidraw est une entrée partagée par élément, écrite par un reducer - #61
Un dessin Excalidraw est une entrée partagée par élément, écrite par un reducer#61MChrys wants to merge 163 commits into
Conversation
TAC-175 in one line: `https://ganymede.apollo.test:8443/` was the only thing between the macOS platform and one that responds. CoreDNS, the certificate and nginx were already in place and verified — what was missing was the service behind them. https://apollo.test:8443/ 200 ssl_verify 0 https://ganymede.apollo.test:8443/oauth/public-key 200 an internal route that queries Postgres 200 POST /signup 200 That last one wrote a user and its auto-created organization into Postgres, through nginx, a published port, an Apple container, and a second Apple container holding the database. Which is the ticket's own definition of finished. Three things this needed, and each was found by it not working: **`--publish` is silently ignored on a named network.** The same image published on `default` answers 200; on a named network the port binds on the host and every request times out. nginx reaches Ganymede over that port, so Ganymede has to be on the default network. Both services are there now — the isolation that matters is untouched, because a *user* container still gets a private network of its own from the broker, and neither of these binds anywhere but the loopback. **A named network stopped carrying traffic mid-session.** No route on the host, no reachability between two containers that each held an address on it. Not worth debugging vmnet for a dev harness; the default network has no such trouble. **A cookie domain may not carry a port.** GANYMEDE_FQDN has to — every URL built from it is a link somebody follows, and nginx listens on 8443 because binding under 1024 needs root — but `ganymede.apollo.test:8443` is not a domain and a browser rejects it outright. The session cookie takes the host without the port. One line, in the one place that needs it. The script now follows the other macOS scripts: ENV_NAME, DOMAIN, HTTPS_PORT, GANYMEDE_PORT, and state under ~/.holistix-macos/<env>/ rather than a directory of its own. Refs TAC-175, TAC-174, TAC-129. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From Devin's review of #57, and the first one is a real defect. **`down` destroyed every environment's data.** The app container and the database name became environment-scoped in this branch; the Postgres container name did not. So one container holds every environment's database, and `down` force-deleted it — with no volume behind it, so the data went too. A second environment on the same machine would have lost everything because somebody stopped the first. `down` now removes only this environment's Ganymede and says what it left running. Two commands carry the rest, and neither is reachable by accident: `drop-db` for this environment's database, `down-all` for Postgres and everyone's data, which says so in its own output. Four smaller ones, all correct: - The header still claimed Postgres never leaves a private network, and the run still carried a comment about needing two of them. Both were true of the version this branch replaced. A comment that describes the previous design is worse than none — it is read as current. - `GANYMEDE_APPLE_DB_PASSWORD` was renamed without a fallback. Anyone who had set it would have got a different password and an authentication failure with no obvious cause. The old name is honoured. - French had crept into the status output of an English script. The sixth was a confirmation rather than a defect: stripping the port from the cookie domain is safe for existing deployments, because a deployment without a port in GANYMEDE_FQDN is unaffected by the split. Verified: `down` leaves Postgres running, `up` brings the environment back, and `https://ganymede.apollo.test:8443/` still answers 200. Refs TAC-175. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a hyphen Three defects the review found, each of which only appears on the second use of this script rather than the first. A hyphen in the environment name produced an invalid SQL identifier: `CREATE DATABASE ganymede_dev-001` is a syntax error, and every statement here is redirected to /dev/null, so the first visible symptom was "schema looks incomplete (0 tables)" — pointing at the schema files. create-env.sh:79 has done the same substitution since it was written; now the two paths also agree on the database name for a given environment. Postgres was reused whenever it had any address at all. An earlier version of this script put it on a named network, so a leftover container kept one, and PG_HOST was staged with an address Ganymede cannot route to from the default network. apply_schema would still pass — it goes through `container exec`, not the network — so nothing before the first request would notice. It is now checked, and refused with the command that fixes it, rather than removed: that container holds every environment's database and has no volume. GANYMEDE_PORT defaults to 6100 for everyone, so a second environment could not bind and `container run`'s output is discarded. The port is checked after this environment's own container is removed, so only a real conflict is reported. Measured: apollo on 6100 and dev-001 on 6101 up at once, 21 tables each, `ganymede_dev_001` created, and https://ganymede.apollo.test:8443 still 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS platform had nothing between Ganymede and a browser. This adds the missing link, and everything on the path turned out to be a real defect rather than a translation. `gateway-apple.sh` is the sibling of `gateway-pool.sh`, which does not change: it runs on the Linux host under Docker and nothing here touches it. Three flags do not survive the crossing, and each absence is the answer rather than a workaround. `--device /dev/net/tun` is unnecessary — Apple's guest kernel already has the node, and it is there in a running container. `--restart` does not exist, which is the concession the broker's engine already names, and launchd is still owed. `--dns` takes an IP and no port, so the container works the host out from its own default route instead — as user containers have since dd0d0dd. That default-route trick was half right, and the half it got wrong only shows on macOS. It skipped any name that already resolved, and the host's CoreDNS answers 127.0.0.1 for every platform name: correct for the host, where nginx is listening, and inside a microVM that address is the microVM. A loopback answer is now overridden and a real one is still left alone. Both containers that carry this code are fixed, because leaving one would be worse. Ganymede writes an nginx server block per allocated gateway and reloads nginx. On macOS neither the paths nor the signal survive: nginx is Homebrew's on 8443, its configuration is under the user's home, and Ganymede is in a container no signal leaves. So NginxManager takes its placement and its reload command from the environment, every default being exactly what it did before, and nginx-reload.sh does the reload on the host. Four things that only appear when the whole path runs: The upstream cannot be the loopback. Two things reach a gateway by that address — nginx on the Mac, and Ganymede health-checking from inside its own container — and 127.0.0.1 is right for only one. The container network's gateway is right for both, which is what the docker0 address is on Linux. DOMAIN has to carry the port, because every URL built from it is a link, and must not carry it in `server_name`, which nginx matches with the port already off. A port there matches nothing and every org request lands on the default server. Ganymede had no NODE_TLS_REJECT_UNAUTHORIZED. Its own health check is an HTTPS call to a mkcert certificate nothing told it to trust; `fetch` refused it, the check swallowed the reason, and the allocation rolled back. create-env.sh:289 sets this on Linux for this exact reason. Asking for a reload is not the reload happening. The health check ran before nginx had it, fell through to the default server, got a plausible 200 from the wrong server, and the first thing to notice was the POST handshake answering 405. The request now carries a token and waits for it to come back — which also returns a configuration the host refuses to the caller, since `nginx -t` can only run over there. A row is also written before its container exists, so a failed `container run` left an orphan that made every later `up` fail on a unique constraint with a stack trace and no name in it. The port is checked before the row, and an orphan for a name whose container is gone is removed. Measured, on an M1, with Docker not involved anywhere: POST /gateway/start 200 org-<uuid>.apollo.test:8443/collab/ping 200, through nginx to the gateway gateway log Gateway initialized, 16 modules VPN started successfully Refs TAC-176, TAC-174, TAC-129. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things go stale between iterations here, and each has already cost work: a re-review that landed mid-change, a subsystem guessed at instead of read, and a Linear issue describing an intention rather than what happened. The first is now a script. Devin posts each finding as a root review comment and closes it with a *reply* starting "✅ **Resolved**" — so "open" is a root comment with no such reply, which neither `gh pr view` nor the PR page will tell you once a review has three rounds on it. Run against the live branch it immediately surfaced seven findings from a re-review nobody had looked at. It also carries two failures that already happened in this workspace. `gh pr view` resolves the PR by the *local* branch name, and a Conductor workspace holding `create-pr` while tracking `origin/commit-and-deploy-images-v1` reports "no pull requests found" with the PR sitting open — so the upstream is asked for first. The same mismatch is what sent `git push origin HEAD` to a branch nobody asked for, which the gotchas name along with the one-line fix. Check status prints on the same line as the findings, because a review with nothing open and a red check is not a finished branch and the two are read in different places. The other two are MCP calls rather than code, written down with the exact arguments that answer and with what each is worth: the DeepWiki names files and functions, which is the reason to ask it, and is a map rather than ground truth — one answer here corrected an assumption that would have shaped a whole ticket, and the code still had to be opened. Linear is appended to as things are measured, because the part worth keeping is what is not in the diff: what was tried and rejected, and what is knowingly left undone. Every command in SKILL.md was run in this workspace, including the two that failed first: the skills probe is `find`, because zsh errors on a glob that matches nothing and the `(N)` qualifier that would fix it is disabled here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GANYMEDE_URL` is read at four call sites in app-gateway — project members,
organization members, and the two project role writes — and exported by
nothing. Not by gateway-pool.sh, not by the containerbroker role, not by any
environment file. Every one of them has been going to
`http://app-ganymede:3000`, a compose hostname that resolves in no deployment
this repository builds.
The consequence is quiet in the worst way. `ProjectRooms.initializePermissions`
catches the failure, logs it, and the very next line reports the project fully
initialized — so a collaboration room comes up with nobody authorised in it,
the WebSocket upgrade times out, and nothing above the log says why. This is
not a macOS symptom; the Linux path has the same four call sites and sets the
variable just as little.
They now share one helper next to the client whose docstring already explains
why raw fetches are the problem here. An explicit GANYMEDE_URL still wins, so
anyone working around this keeps their override. Otherwise it is the FQDN — the
one address a gateway can always reach Ganymede at, resolved by CoreDNS on
Linux and from the default route on macOS, and carrying its port so nginx need
not be on 443.
verify-collab-websocket.mjs also learns the second environment layout. It
already creates the two accounts live collaboration needs — one owner, one
admin, because gateway-init assigns no role to a plain member — through the
real /signup, so passwords are hashed by the application rather than written by
a script. It only ever looked for `.env.ganymede` and `jwt-key` under
/root/.local-dev; it now also accepts `ganymede.env` and `jwt.key`, which is
the whole difference between the two.
Measured on macOS, Apple container, no Docker anywhere:
3 clients connected
all 2 peers received the update
awareness converged across 3 clients
PASS collaboration WebSocket is functional
claude@test.local / TestUser123! owner
claude2@test.local / TestUser123! admin
Refs TAC-174, TAC-129.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ved from The login form answered "Failed to fetch" on macOS, which reads like Ganymede being down. It was not: the bundle being served had been built for `dev.test` and was calling `https://ganymede.dev.test`, a name that resolves nowhere on this machine. Nothing in the served page says which environment it was built for, so the only symptom is a fetch that fails before it leaves the browser. build-frontend.sh already writes exactly the right .env — it just could not find a macOS environment to read the domain from. It now looks for the Linux pair first, so that path is unchanged, and falls back to the macOS one, the same two-layout lookup verify-collab-websocket.mjs got. The workspace default does the same: /root/workspace/monorepo when it exists, otherwise the checkout the script itself lives in, because a dev container has both and building from the wrong tree is the failure this is meant to avoid. DOMAIN carries a port on macOS and everything here wants that — VITE_DOMAIN_NAME and VITE_GANYMEDE_URL both become links the browser follows, and nginx is on 8443 because binding under 1024 needs root. Verified against the running environment, as the browser makes them: OPTIONS /login 200, Access-Control-Allow-Origin: https://apollo.test:8443 POST /login 200, Set-Cookie: sessid=…; Domain=ganymede.apollo.test Refs TAC-174. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odeQL Two gaps, both found by running the driver against #53–#57 rather than by reading it: Devin runs three review jobs per PR, not one, and each has its own emoji pair — BUG_ posts 🔴/🟡, ANALYSIS_ posts 🔍/📝, and SEC_ posts 🟨. Only 🟨 was missing from the severity table, so every security finding fell through to `unmarked`: printed with a blank severity and sorted to the very bottom of the list. #57 currently has two of them. The job prefix in the marker is now read as a fallback, so a new emoji degrades into the right bucket instead of off the end. CodeQL is a different bot entirely (github-advanced-security[bot]) posting a body with no Devin marker, so the author filter dropped it silently — three alerts on #54, one on #56, none of them ever shown. They now get their own block, and deliberately do not gate the exit status: a CodeQL comment is closed in the security tab, never by a reply. SKILL.md corrections, all re-measured: the sample output was a stale 7/8 for a PR now at 18/12; the DeepWiki structure was described by the wrong section titles; and the claim that a Conductor branch name breaks the Linear link was too broad — TAC-175 and TAC-176 point at the same PR and only one of them is linked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oud mode
The service card offered "Local" alone and nothing said why. The gateway
registers the platform runner only when it has both a broker URL and a broker
token — config/modules.ts is explicit that it is both or neither, and that is
the right rule, because half a broker puts a button in front of someone that
fails on click. But the consequence of neither is silent: a mode is simply
absent from the picker.
gateway-apple.sh now starts the broker and hands the gateway the pair. Three
of its settings are the same lesson this migration keeps teaching:
It binds on the container network's gateway address. Its only client is a
gateway inside a microVM, for which the loopback is itself.
GANYMEDE_INTERNAL_TOKEN is a signed RS256 `gateway_token`, minted here from
the environment's key. A random string authenticates nothing and the failure
it produces is every project image reported as non-existent rather than as
refused — the defect the containerbroker Ansible role had.
Every concession the apple engine names is listed, or the broker refuses to
start. That refusal is the point of them.
The readiness check asks whether the *port* is listening, not whether a broker
process exists. A broker left over from verify-container-broker.sh binds a
random free port, so the process question answers yes while nothing listens
where the gateway will look — and the gateway then registers a platform runner
whose first request is refused. That is exactly what happened here first.
Measured, on both gateways in the pool:
Available runners for project 33333333-…: local, platform
Available runners for project 8b6fca42-…: local, platform
PASS collaboration WebSocket is functional
Refs TAC-129, TAC-174, TAC-176.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gateway reached the platform runner's broker at `https://…:9443` and got
`fetch failed`, with nothing in the message about TLS. The broker has none: no
certificate option, no secure server, `http.createServer` and nothing more.
Port 9443 means TLS everywhere else, which is exactly why the wrong scheme is
the one you write.
Measured from inside a gateway container, at the address it actually uses:
http ://192.168.65.1:9443/containers 401 (then 404 with the token)
https ://192.168.65.1:9443/containers 000
The 401 is the point: the broker is there and refusing an unauthenticated
call. The 000 is what the gateway saw.
Stated in the script rather than left to be rediscovered: the bearer token
therefore travels in clear. That is tolerable only because both ends sit on a
host-local network — the docker bridge on Linux, vmnet here — and it deserves
saying out loud precisely because the port number claims the opposite.
Refs TAC-129, TAC-176.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng out
The badge had a fixed 18px height and said nothing about overflow, so any
description longer than a couple of words wrapped to a second line and rendered
outside the coloured box — a blue rectangle with text hanging off it. The
catalogue's own entries are sentences ("Minimal Ubuntu 24.04 container exposing
only a web-based terminal"), so this was the normal case, not an edge one.
One line, clipped, with the whole of it plus the image name on hover. The
`minWidth: 0` matters as much as the overflow rule: this sits in a flex row,
and a flex item will not shrink below its content width, so the ellipsis never
engages without it.
Storybook looked right for a reason worth writing down: its stories set
`last_watchdog_at` and `httpServices`, so they render a *running* container,
and its description happens to be two words. The difference from a real card
was state, not style — except here, where it really was style.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects between the click and a running container, each hidden behind the one before it, and the last one mine. The gateway sent hosts entries built straight from the FQDNs. Those carry a port wherever nginx does not listen on 443, and a hosts entry is not a URL, so the broker refused the whole start: "extra_hosts entry has a malformed host". It was right to. Same distinction as the nginx `server_name`, which strips the port for the same reason. With the port gone the refusal became "engine apple cannot set extra hosts" — also right, and not fixable by reformatting. `--add-host` is not something every engine has, and the broker refuses rather than dropping them quietly because a container that needed them and did not get them fails later and further away. The gateway cannot know which engine is on the other end and no longer has to: since dd0d0dd the container finds the host from the gateway of its own network, unprompted, on any engine. So the platform runner sends none, exactly as it sends no devices. The local runner still sends them — it builds a command for somebody else's machine, where nothing has been arranged. Then the broker returned `holistix_…_uc_msgac\n[0/6] [0s]\n[1/6] …` as the container identifier. engineExec joins stderr onto stdout so a refused network delete becomes visible, which is right for callers that decide on the output and wrong for the one that uses it as a value — and I wrote that merge and its comment claiming this case was safe. Measured: `container run --detach` puts the name on stdout and six progress lines on stderr. That identifier is stored and later compared for ownership, removal and reconciliation, so carrying the noise makes a container unremovable by the broker that started it. Measured end to end, on an M1, no Docker anywhere: POST user-container:start 200 broker Started uc_msgac8t5boa5u4 engine holistixforge/ubuntu-terminal:24.04, running kernel inside 6.18.15 (host: Darwin 25.5.0) network holistix_uc_uc_msgac8t5boa5u4, 192.168.67.2/24 platform services are on 192.168.65.0/24 /dev/net/tun present, from the guest kernel reported to the gateway engine apple, runtime container-runtime-linux, isolation microvm A tenant container ran on the platform host, in its own microVM, on its own private network, and said so on the card. That is what TAC-129 asked for. Refs TAC-129, TAC-174, TAC-176. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it leaked
DOMAIN carries a port wherever nginx is not on 443, because every URL built
from it is a link somebody follows. Two places already strip it: the session
cookie domain and the nginx `server_name`. The review found two more, and both
break the feature that just started working.
`validateRedirectUris` compares DOMAIN against `URL.hostname`, which never has
a port — so with one, the comparison was false for every URI ever submitted.
Not a loosened check: a permanent refusal. The caller is the gateway
registering a user container's own sign-in endpoint, so starting a service got
a 400 saying its redirect was not a subdomain of a domain it plainly is one of.
`start_auth_guard` derives `--cookie-domain .${DOMAIN}` from GATEWAY_FQDN. A
cookie domain is a domain; a browser rejects `.apollo.test:8443` outright, so
every container behind the auth guard would sign a user in and hand them a
cookie the browser drops.
Three more the review found in the macOS scripts, each of which only shows up
on a machine that is not this one:
A gateway's address was read once, immediately after `container run --detach`.
It is Apple's to assign and is not there yet — every other place in this
harness already polls for it. Read once, an empty result is the common case
and a healthy gateway is declared dead *after* its row is in the database,
leaving the pool half-built.
`nginx-gateways.d` is bind-mounted into Ganymede and was never created. Apple
`container` refuses to bootstrap on a missing mount source rather than
creating it, and `container run`'s output is discarded — so the only symptom
is "Ganymede did not start", pointing at the bundle. It worked here because
setup-nginx.sh happens to create the directory; the order the scripts were run
in decided whether `up` worked.
The container resolver was deleted and recreated on every `up`. `--dns` is
baked into a container at creation, and that resolver is one per machine while
environments are not — so bringing up a second environment gave it a new
address and left the first pointing at one that no longer answers. Measured
after the fix: the address survives a `restart` unchanged.
Refs TAC-129, TAC-174.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TagsBar's layout effect resets both tag lists and then measures overflow one animation frame per tag. It depended on the `tags` array, and every caller builds that array inline — `resource-list.tsx` and `server-card.tsx` both do — so each render of the parent handed down a new identity and replayed the whole cascade from zero. Keying on the contents instead ends it. Measured, not read: with the array dependency, six parent renders carrying identical tags cost six extra cascades; with the content key, zero. That is what the test asserts, and it was confirmed to fail against the old version. Two dead ends worth recording, because both look like tests and are not: - Counting renders from a parent proves nothing. TagsBar re-renders on its own state, which never reaches the parent, so a parent-side counter stays flat while the component spins. - Rendering <TagsBar/> on its own cannot show the defect at all. Its own setState leaves the `tags` prop referentially identical, so the effect never re-fires. That version of the test passed against both implementations. Not covered: which tags stay visible and which fold into the overflow menu. That branch reads clientWidth, which jsdom always reports as 0, so every tag measures as overflowing and the assertion would be about jsdom. It belongs in the screenshot suite, where a real engine lays the row out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probe.tmp.mjs and shot.tmp.mjs were scratch Playwright scripts, written to find out which Storybook stories rendered and which hung. They were never meant to be committed; ed3f9ae swept them in along with its own change. Nothing depends on them and they carry no findings — what they measured is in the TagsBar fix and in TAC-180. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sweep of all 1001 commits on every branch, answering: what was added to the Storybook since the repository began, what survives, and where each piece belongs under the project's own hierarchy. 269 story files have existed; 117 remain. The 152 missing paths look alarming and mostly are not — they are renames. Deduplicating by identity leaves **21 elements genuinely lost**, and only 5 of those can be restored as they are: their component still exists, only the story file was deleted. Four more are already covered by a renamed equivalent and must not be restored, or they become duplicates. The remaining 12 show code that no longer exists. The reclassification is the other half. `Views > Components > Resource > Assets`, applied inside each group, moves **82 of the 117** — which is the measure of how far the arrangement drifted. It also folds in `Module` vs `Modules`, the trailing space in `Users `, the `reource-list` typo, and the demo titles (`a-b`, `solar system`, `Super title`) still sitting in the sidebar. Three limits are stated in the document rather than papered over: identity is the filename, so renames read as loss and had to be qualified by hand; the title read is the file's, not the sidebar's; and the story count is syntactic. Use/ versus Library/ is deliberately absent — it needs the import graph, and a hand-written answer would be wrong again after the first refactor. TAC-180. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eep's blind spots The first pass answered "what existed and disappeared". It left the neighbouring question open, and that one is larger: of 199 components at HEAD, 137 names have carried a story at some point in the repository's history. **97 never have.** Those are not losses, they are original absences — and they dwarf the 21 lost. Three places hold most of them: whiteboard (22, the largest module, whose Storybook shows only its atoms), app-frontend (21, of which 13 are routed screens, and which has never had a single story), and airtable plus notion (18 between them, each reduced to its `Main`). Also records what the sweep does and does not reach, because an inventory whose blind spots are unknown is worth no more than a guess. `git log --all` covers 416 refs here — 394 of them Conductor checkpoints holding states no branch references any more. Outside that: 3 reflog-only commits, checked one by one and touching no story; no stashes; no other naming convention (the files under `stories/` that are not `.stories.*` are harnesses and fixtures, not sidebar entries). What it cannot see is a commit never written to this clone — and the February 2025 rename of demiurge-ui-components to ui-base hints at an earlier life whose own history is not here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Resource > Assets The sidebar had twelve roots. `Modules` and `Module` both existed; so did `Users` and `Users ` with a trailing space. `root`, `internals`, `icons` and `Palette` sat outside any of them. Nothing enforced the project's own hierarchy, so each new story picked its floor by eye and the arrangement in the original Storybook — Views, Components, Resource, Assets — stopped being true. 97 of the 117 titles move; the other 20 were already right. Three roots remain: Base 107 stories, Modules 91, Mvp 59. Verified against a running Storybook, not inferred: 257 stories served, which is every one of them. The 155 screenshot baselines are renamed with them, because a story's id is derived from its title and renaming the title alone would orphan every file. Checked against the ids the server actually publishes: zero orphans. The 102 stories with no baseline had none before this change either. Also folded in: the `reource-list` typo, `user-bubble` leaving `Mvp` for `Base` where its component lives (16 stories, the most in the repo), `IDCard` leaving `Users` for the socials module that owns it, and the Space/Whiteboard rename finally reaching the titles. One file is deliberately untouched. `tabs-radix.stories.tsx` opens with a data payload whose field is also called `title` — the tab tree's root label. A scripted rewrite that takes the first `title:` in a file corrupts it, and did, before the audit caught it. Its meta title was already correct. The audit that found it now runs over all 117 and reports one case, this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three the review found, all of them things a second user or a second gateway
would hit and a single run never does.
The nginx reload used one `.reload` file as a single slot. Two gateways set up
moments apart and the second request overwrites the first before the watcher
reads it; the first then waits out its full ten seconds for a confirmation
nobody will ever write, and Ganymede turns that into "Nginx reload failed" and
abandons an allocation that was fine — nginx having reloaded correctly all
along. Now one file per request and one acknowledgement per file: requests are
claimed before the reload so one arriving during it stays queued, and a single
reload satisfies everyone waiting. Measured: two concurrent requests, both
exit 0.
BUILD_PORT was configurable on the server side and hardcoded on the consumer's
— `fetch-gateway-build.sh` built `http://${BUILD_SERVER_IP}:8090` — so moving
the build server off the default gave every gateway a fetch failure pointing
at the download rather than at the setting. The port travels now, with 8090
still the default so nothing that does not set it changes.
The image badge tooltip glued an optional description onto the image name, so
a catalogue entry without one showed "ttyd ubuntu — undefined". The badge body
is empty in that case too, which makes the tooltip the only text there is.
And one the review raised as a question rather than a defect, now written down
where it will be found: sending no `extra_hosts` from the platform runner is
correct only while every image it can start resolves the host itself. That
code lives in the user-container base image, so a catalogue image on a
different base has no way to reach its gateway by FQDN in development. Closing
it properly means the broker telling the gateway what its engine can do before
a start rather than refusing after.
Refs TAC-129, TAC-174.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n born empty
Three defects found by loading all 259 stories in a real browser and reading
what the page reported. Failures drop from 17 to 9.
**Node backgrounds 404'd, all of them.** `nodes.scss` reached for
`url('../<name>.svg')`, which resolves inside whiteboard — a package that ships
no SVG at all. The six files live in ui-base. A missing background-image paints
nothing and reports nothing, so this was invisible until a network log showed
it. Eleven references corrected.
**Node components crashed before painting.** Any node reaching a connector
calls `useConnector` → `useDispatcher` → `reducers.dispatcher` off the module
context, and the shared story harness never wrapped anything in
`ModuleProvider`. The context defaults to `{ exports: {} }`, so the read threw
"Cannot read properties of undefined (reading 'dispatcher')" — naming neither
the hook nor the absent provider. It killed fourteen Jupyter stories:
node-vault, node-python, node-screening, node-dataset, node-notebook and
node-notebook-component, every state of each. They were never broken
components; they were rendered without half their context.
**NotebookView opened on a screen that has no content.** `activeView` starts at
`biome-server`, which has no content block, and the only way out is a setter
handed to a child inside a branch you must already be in. `status` is read only
under `biome-notebook`, which nothing can reach — so Stop, Load, Running and
Host rendered byte-identical DOM and identical pixels, both confirmed by hash.
It opens on a view now. Checked across every revision since the file appeared
on 2025-02-03: it was always like this, so there is no regression to bisect.
Two things worth writing down for whoever debugs the next one:
- A story importing a harness from another package gets that package's `dist`,
not its source. Editing the source changes nothing until the package is
rebuilt — the stack trace says `whiteboard/dist/...` and means it.
- `useModuleExports` takes a `from` label for diagnostics and never uses it.
Passing "useDispatcher" buys nothing; the error still names only the property
it failed to read.
Still failing, a different family, untouched here: six module `Main` stories
whose harness predates the collab registry API (`registerSharedData` on
undefined, and one that says so outright — "Collab module frontend now requires
CollabRegistryConfig"), and two Jupyter forms wanting a CollabProjectProvider.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS scripts started every host process with `nohup … &`. Nothing watched them, nothing restarted them, and nothing said when one was gone. On Linux these are systemd units; here they were background jobs. That is not a convenience gap, and this session is the evidence. nginx and the host CoreDNS both died — the disk had filled — and the platform went dark. The symptoms were a page that would not load, a WebSocket with no token, and `fetch failed`, which reads exactly like an application bug: an hour went into chasing the tab bar and a Yjs document that turned out to be my own probe failing to connect, before anybody asked whether the daemons were still there. Six agents under `so.holistix.*`: CoreDNS, nginx, the reload watcher, the build server, the broker, and a one-shot that resumes the gateway pool at login. `install` first stops whatever is running by hand, because otherwise launchd starts a second copy and both fight for the port — which surfaces as a service that flaps rather than as a conflict. `status` prints what launchd holds *and* what is actually listening, and marks the disagreement in red. That pairing is the whole point: a loaded agent whose port is silent is the state this script exists to make visible. Two things the first attempt got wrong, both now measured: nginx daemonises, so launchd saw the master fork, called the job dead and restarted it against a port already bound. It runs with `daemon off`. `exec … &` is not foreground: the `&` forks, so exec never replaces the shell and the broker died anyway. It has an explicit branch now — foreground for launchd, detached for a person at a terminal — and the first version's failure is named there. `resume` starts the gateways that already exist rather than registering new ones. After a reboot the pool is down while its rows still say ready, and the next organization to open a project is handed a gateway that answers nothing. Measured: coredns, nginx, the broker and the build server killed together, all four back within ten seconds, frontend 200 and the collaboration WebSocket still PASS. A stopped gateway resumed at its address. Closes the last item of TAC-174. Refs TAC-129. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every container in an organization shared one client certificate, so with `duplicate-cn` the server saw the common name `clients` for all of them: it could not tell two apart, and could not give a particular one the address its network was allocated. The gateway half — the credentials file, the allocation lookup, the flag — has been there since af5bc94. The client half had never been written, so the flag could not be turned on. The client now presents the pair the server already knew how to check: its container id as the username, and as the password the hosting token the gateway minted for it and handed over in SETTINGS. It sends them whether or not the server asks, because a server without `auth-user-pass-verify` ignores them — which is what makes the rollout safe in this order. Every container learns to send them first; only then can the flag go on. The other order takes every service in every organization offline at once. Five things stood between writing that and a tunnel, and only the first was the feature: `resolve_platform_hosts` read the default route with `ip route`, and iproute2 is not in the ubuntu-terminal image this repository ships. It found nothing, returned early, and wrote no hosts entries at all — silently, because "no default route" and "no `ip` command" look the same. It reads /proc/net/route now, in shell rather than awk, because mawk is what these images have and it has no `strtonum`. The port then leaked twice more. Into /etc/hosts, where the line reads `192.168.68.1 org-….apollo.test:8443` and resolves nothing while looking right at a glance. And into the openvpn `remote`, whose template is `remote GATEWAY_FQDN <port>` — host and port are already two fields — producing "Cannot resolve host address" for a name that was in /etc/hosts, correctly, without it. `via-file` fails outright on OpenVPN 2.6.19: "could not write username/password to file", as root, with `mktemp` in the same directory succeeding by hand. It is deprecated upstream. `via-env` puts the credentials in the script's environment instead and needs no file at all. And the server named `/app/lib/vpn-auth-verify.sh` — the copy baked into the image — while `pack` updates the one at /opt/gateway/app/lib. The running gateway called a stale verifier, which refused every client while the fixed copy sat unused a directory away. The config now names the scripts beside itself. Measured, with VPN_PER_CLIENT_IDENTITY=1: client TUN/TAP device tun0 opened Initialization Sequence Completed server [uc_msghr5y9u3pb86] Peer Connection Initiated MULTI: Learn: 172.16.0.6 -> uc_msghr5y9u3pb86 MULTI: primary virtual IP for uc_msghr5y9u3pb86: 172.16.0.6 The flag stays off by default. Turning it on makes the server *require* credentials, so it waits until every image in a catalogue is rebuilt from this base — start-vpn.sh says the same thing from the other side. Refs TAC-155, TAC-129. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… bare config
Not my work — this is the Storybook pass from a parallel session on the same
workspace, committed here because it was left uncommitted and typechecks
clean across collab, chats, user-containers, whiteboard and socials.
`collab` gains `createLocalCollabRegistry` and exports `CollabProjectProvider`
from its frontend entry, and every module story is moved onto them rather than
handing the module a `{ type: 'none' }` config literal.
The two Playwright probes at the repo root (`probe.tmp.mjs`, `sweep.tmp.mjs`)
are deliberately left untracked: they are throwaway scripts that drive
Storybook on port 6007, the review already flagged them as not belonging in
the tree, and adding them would make that permanent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reachable
The seventh place, and the one that mattered most: it made the whole feature
unusable on macOS while every part of it reported success.
`DOMAIN` carries a port wherever nginx is not on 443, so the gateway builds
`gatewayFQDN = org-<uuid>.apollo.test:8443`, the reducer derives service hosts
from it, and `update-nginx-locations.sh` emitted them verbatim as
`server_name terminal.uc-x.org-y.apollo.test:8443`. nginx matches server_name
against the Host header with the port already stripped, so that block matched
nothing; every request fell through to the catch-all `server_name _` and was
proxied to app-gateway. Opening a service answered — with something else.
Stripped where it is emitted rather than upstream, because the same FQDN is
also the link on the service card and there it needs its port. Same
distinction this branch already draws for the Ganymede `server_name`, the
container hosts entries and the openvpn `remote`.
And new gateways were numbered from a count of the containers that exist. With
gw-pool-<env>-0 deleted and -1 running, the count is 1, so the next one is
named -1 as well — and the loop force-deletes whatever holds that name, taking
a live gateway and its database row with it while the organization using it
loses its connection with nothing said. It takes the next free suffix now.
Measured: with -0 gone and -1 up, adding one produces -2.
Measured end to end, on an M1, no Docker anywhere:
server_name terminal.uc-….org-….apollo.test (no port)
GET https://terminal.uc-….apollo.test:8443/ 200
ttyd - Terminal
That is the web terminal inside the user container, reached by its own FQDN
through host nginx, the gateway's nginx, and the VPN.
Noted, not fixed: a gateway removed outside `down` leaves its allocation row
behind, and the organization holding it gets 502 until the row is cleared.
Refs TAC-129, TAC-174.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dule Two leftovers from the collab-registry work; 3e6bb74 carries the rest. `clearProject` and `clearAll` now test `collab` for a `destroy` method instead of for `instanceof YjsClientCollab`. The class check reads better and is wrong: the suite passes mocks there, so it silently skipped them, and two existing tests said so within a minute of the change. `new-server.stories.tsx` loads the tabs module. user-containers declares tabs as a dependency and `loadModules` refuses to load a module whose dependency is absent — it reported "Module tabs is not loaded, needed by [user-containers]", which was exactly true and had simply never been acted on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…enrol
TAC-154 made Ganymede keep the `code_challenge` it had been dropping, and the
enrolment flow was tested against a fake Ganymede that enforces PKCE. Run
against the real one, it never reaches the route: the OpenAPI validator sits in
front and the document never learned about any of this.
GET /oauth/authorize?…&code_challenge=…
400 Unknown query parameter 'code_challenge'
Past that, the token exchange asks a public client for the one thing it cannot
have:
POST /oauth/token
400 must have required property 'client_secret'
`getClient` already handles a missing secret — "validate secret if provided" —
so the route was always willing. Only the schema refused, and it refused every
public client permanently rather than in some edge case.
Both parameters are optional in the document, because the confidential client
that predates PKCE still exchanges without them, and `code_challenge_method`
is an enum of `S256` alone rather than a free string: `plain` is not something
to accept by omission.
Measured, first enrolment against a real Ganymede:
302 Location: http://127.0.0.1:63255/callback?code=…&state=…
Enrolled as "mac-m1" (7c93e774-3e9f-49bd-8f03-b6bbbb08d077)
The loopback port varies per run and the registered redirect has none, which
RFC 8252 says to ignore for loopback — confirmed rather than assumed.
Refs TAC-154, TAC-156, TAC-129.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rest of the same parallel-session pass already committed in 3e6bb74 — jupyter's two form stories and their shared utils, left behind because they landed after that commit. Typechecks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner drove `docker` in hard. On the machine this platform is migrating
to there is no Docker at all, so a pass got as far as asking for its
containers and stopped:
Cannot connect to the Docker daemon at unix:///…/docker.sock
The same seam the broker has, and for a reason that is not the same one. A
platform host runs tenants beside each other. A laptop runs *its owner's* work
beside services other members of their projects placed there — the confused
deputy pointed at a person's own machine, which is how the ticket puts it.
Under Docker on a Mac every one of those shares the single VM's kernel. Apple
`container` gives each its own guest kernel at level 1, so the owner is
protected from the other members of their own project.
An addition: the Docker entry names the functions that were already in
`docker.ts`, unchanged, and claims no concessions. Nothing on that path
behaves differently for having a table above it.
`listOwned` is one operation rather than a list and an inspect the caller
stitches together, because `container ls` has no `--filter label=` and the
selection has to happen in code — a difference that belongs inside the engine.
Three concessions, reported rather than gated: the broker refuses to start
until an operator accepts its own, but a runner is somebody's laptop and there
is no operator to ask. No `--restart`, so the loop restarts what exited. No
live network attach, so a placement whose networks changed is recreated rather
than corrected in place — and the entry throws instead of quietly doing
nothing, which would leave the container on the wrong network while the pass
reported success. No `--add-host`.
Devices are dropped rather than passed: under a microVM the container has its
own guest kernel and `/dev/net/tun` is already there — measured, `crw------- 10,
200`, with nothing given.
Apple's `inspect` is not Docker's — `configuration.id`, `configuration.labels`,
`status.networks[].network` — and read as Docker's it yields a container with
no labels, which reconciliation calls "not ours" and leaves running forever. An
unparseable answer throws rather than reporting an empty list, because "nothing
here" would make the pass start every container again.
The engine is named, never sniffed: "whichever binary is on the PATH" would
pick one on a machine that has both, and the two do not isolate the same way.
A test caught one of my own: the refusal threw synchronously from a function
typed as returning a promise, so a caller reaching for `.catch()` would have
got an uncaught exception instead of the refusal it was handling.
Refs TAC-156, TAC-129.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he machine
`reconcile` took a `TDockerExec` and called the docker functions directly, so
the engine table added last commit had nothing driving it. It takes an engine
now, and `main.ts` picks one from `RUNNER_ENGINE` — named, never sniffed,
Docker by default so nothing that exists today changes.
An engine that cannot move a network on a live container recreates instead.
The plan stays engine-agnostic: what a placement should look like has nothing
to do with what started it, so the substitution happens once, where the
refusal arrives. It costs a restart the Docker path does not pay — the
`no-hot-network-attach` concession — and that is better than leaving a
container on a network its placement no longer names while the pass reports
success. Only that refusal becomes a recreate; any other error still fails the
pass, or a broken engine would hide behind a service that silently restarts.
The gateway accepted `token <jwt>` and not `Bearer <jwt>`, and the runner
sends Bearer. The schema refused it before the route, so the heartbeat could
not reach any gateway and the machine stayed absent from every project
catalogue with a 400 about a regular expression. The same defect as the one
found in the CSRF gate while wiring the runner, pointing the other way.
Measured on this Mac, with Docker uninstalled:
docker Cannot connect to the Docker daemon at unix:///…/docker.sock
1 project(s) · 0 announced, 1 unreachable
apple Engine apple. Controls it cannot express: restart-policy,
no-hot-network-attach, no-add-host
1 project(s) · 1 announced, 0 unreachable
Found and not fixed, because it is the seam itself and deserves its own
change: `GET /placements` returns the raw collab documents — `{runner: {…},
httpServices, …}` — where the runner expects a `TPlacement` with `machine_id`,
`imageRef`, `settings`, `networks` at the top level. So every placement is
refused with "Placement names no machine", and would be unstartable even if it
were not. Both halves are tested, each against its own idea of the shape, and
they have never agreed. Building the real thing needs the image registry and a
hosting token per container — the same one that is the container's VPN
password — which the route does not have today.
Refs TAC-156, TAC-129.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four causes, each one a story asking a module for something nobody had loaded.
Every remaining failure has changed character as a result: none of them still
reports what it reported before.
**The Jupyter form stories mounted only the init gate.** NewKernel and
NewTerminal wrapped in `JupyterStoryInit` and nothing else, so they reached for
a module context, then for a registry, then for a shared map, one layer at a
time as each was supplied. `JupyterStoryProviders` now mounts the whole
frontend stack once, in the harness rather than in each story, which is what
kept the two drifting apart.
**Jupyter declared `reducers` in its type and not in its dependency array.**
The loader injects from the array; the compiler reads the type. It went
unnoticed because the only story that loaded the module also loaded reducers
first for unrelated reasons. Loading the stack in dependency order finds it
immediately.
**The story `gateway` stub predated `permissionRegistry`.** user-containers
registers its permissions against it at load, so the module read `.register`
off undefined and the story died before painting. The stub now answers.
**`tabs` was loaded after the module that depends on it.** `loadModules` walks
its list in order and refuses a module whose dependency has not been loaded
yet — it named tabs and user-containers, and was simply never read.
Also: `fs` is now overridden inside `vite-plugin-node-polyfills` rather than
aliased around it. The plugin resolves node builtins itself, so a
`resolve.alias` entry never gets a look in — an empty module is `null` once
required, and `const { existsSync } = require('fs')` throws on it before any
component renders.
Still failing, all of them now a dependency-level problem rather than a missing
provider: jupyter's Main and Terminal want `__webpack_public_path__`;
user-containers' Main dispatches without a project_id; notion and airtable
expect a server to answer; socials frames google.com, which sets
X-Frame-Options.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… space Outside a project the space entries were simply gone, so the rail changed length by page — the thing under a given position moved, and someone navigating by muscle memory clicked the wrong one. It also read as loss: whiteboard and resources had *disappeared*, not moved out of reach. Two groups now, in one column, with a rule between them: an organization's places, then a space's. The whole list renders on every page. An entry out of reach is greyed and says why in its tooltip, which is the only room a 56px column has for a reason. And out of reach is now the exception, because the last space anyone opened is remembered. A rail that went half-dead on leaving a project would be dead exactly where someone is most likely to want to go back to work. What gets stored is the route's own parameters — what worked as a URL — so rebuilding it cannot drift from the routing. Grey is left for the first visit, where there is genuinely nothing to point at and saying so beats a link that lands on `/p/undefined`. The space links are absolute where they used to be relative. `..` resolves against the matched route, so a relative link means different things on a project page and on an organization page — and the point of remembering a space is that it means one thing from everywhere. The group rule takes `--color-border` and not `--color-border-muted`: the muted one resolves to the same surface as the rail's own background, so the first version was there and invisible. Seen on the page, not read off the palette. Verified in a browser across six navigations: greyed before any space is opened, live on every page after one, and the rule always in the same place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same reasoning as the space, applied to the level above it. The list of organizations names none, so the two entries under it were dead there — and that page is exactly where someone wants to get back to the organization they were just in. Both levels now fall back to the last one visited, so the only page where any of it is grey is the first one a new account sees. Arriving anywhere records it, and entering a space records its organization too: going straight from the list into a project would otherwise leave the list with nothing to point back to. The route wins over the memory where it says something. Two organizations open in two tabs would otherwise show each other's. `last-space.ts` becomes `last-visited.ts`, holding both, with the storage access wrapped once — it can fail in private mode, and losing the way back is a smaller failure than a rail that throws while rendering. One thing worth knowing, and written down rather than discovered: this outlives a session. Someone who signs in as another user inherits the previous one's last places, and following one lands on a page the server refuses — as it would for a pasted link. A wrong link, not a leak. Verified in a browser across five navigations: grey on a fresh profile, the organization live on the list after visiting one, and everything live after opening a space. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Over a canvas, one panel with a rule through it reads as one thing that has a line in it — which is the opposite of what the two levels are. So on the whiteboard they are two boxes with air between them: the aside becomes the stack, transparent, and each group carries the surface and the rounded edge. Docked as a bar the column is continuous and a rule is the right separator, so that stays. The split had to move into the DOM for this. A border between two list items cannot become two boxes, so `separatorBefore` now cuts the items into groups and each group is its own list. The flat index is carried alongside, because the active mark is held by position and grouping must not renumber it. A rail with nothing in it still renders one list. It used to get one for free; grouping would have left an empty box, and an empty rail is still a rail — the grouping is about where the cuts are, not about whether there is a list at all. Caught by the one test that passes no items. Measured in a browser: two boxes at 56×176 and 56×120, 16px apart, 8px corners, the aside transparent behind them — and unchanged as a bar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…beside it The light carries the placement in its colour — blue for a machine somebody owns, green for the platform — and a colour on its own is a legend nobody was given. A cloud sits to its left now, and a laptop for a local one: the same fact in a form that needs no key. They are handed the same value rather than deriving it twice, so they cannot drift. A green light beside a blue cloud would be worse than no cloud at all. Nothing at all for a runner this build has never heard of, rather than a placeholder. "Running somewhere I cannot name" is what the light already says on its own, and a question mark beside it would add doubt without adding fact. The ink is also written to a data attribute, which is not decoration: jsdom drops a `var()` from a standard property outright — it never even reaches the style attribute — so the inline colour is unreadable from a test, and "the glyph agrees with the light" is the one thing here worth asserting. Measured in a browser: the cloud 22px to the left of the light, on the same row, painted rgb(63,191,134) beside a green LED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A card glows on hover — ninety pixels of blur — and it came out sliced off square on all four sides. Which does not read as a glow with an edge; it reads as a rendering fault. Two clips, both lifted. Excalidraw clips every embed to its element, which is right for an embedded web page: it must not paint outside the rectangle somebody drew. Ours are not pages, they are this application's own cards, and `validateEmbeddable` admits exactly one host — so every embed inside this layer is a node we drew, and lifting it here is safe in a way that lifting it generally would not be. The other clip was ours, and it was a guard against a node outgrowing its box. That guard is now done properly one level up: the box is measured to fit the node instead of cutting it to size, so the clip was insurance against a case that no longer happens, charged against a case that does. No `!important` on the Excalidraw rule — measured. Theirs is two classes and this is three, so it wins on specificity despite loading first. Worth saying because it is not true of everything in this file: the dark-mode entry needed one, for a rule of equal weight loaded later. Verified in a browser: both computed to `visible`, the shadow resolves, and the halo spreads well past the element's box in every direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I read the wrong pastille. What the Storybook shows beside the run control is a round badge with a ring around it — the face of whoever's machine is doing the work — and I put a small glyph down by the light instead. That glyph is gone; it was a second indicator for a fact the light already carried. On the platform there is nobody to show, so the slot was simply empty. That does not read as "nobody owns this", it reads as a card missing a piece. It gets a face of its own now: the same badge, the same ring, a cloud instead of a person and green instead of blue — green because that is what the light says for the platform, and one fact should not be told in two colours. Shown only while the service is actually up, and the person's face now follows the same rule. Written as "the light is blue or green" rather than as a second reading of the same facts, since that is exactly the condition. On a stopped service the badge would answer "where would this run" — a question the runner picker below already owns, and one nobody asked. Verified on the running notebook: a 40px badge ringed rgb(63,191,134), 40px left of the run control and on its row, beside a green light. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults, one badge. The cloud came out black. Its path carries no fill of its own, so it falls to the SVG default, and the parent's `color` never reaches it — on a dark badge that reads as a hole rather than as a cloud. `fill` on the svg, which is inherited, so one declaration covers every path in the icon. And it was still. A running service is a thing in motion; a still badge beside a still light says only "configured". It pulses now, the same way the avatar it stands in for does: a disc the size of the badge growing out of it and fading, behind the badge's own background so only what has grown past the edge is ever seen — a ring rather than a flash over the icon. Its own keyframes rather than the `ping` ui-base declares for avatars. That one is in the document only because this card happens to import an avatar component from the same barrel; a rule that works by accident of an unrelated import is one that stops working without warning. Verified in a browser: fill and border both rgb(63,191,134), the animation running at 1s, and two frames a third of a second apart that differ — which is the only way to tell a pulse from a picture of one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a padlock in the header, sitting beside the settings gear — where it read as a preference rather than as a place you go. It is a place: the rail's upper group, directly above the accesses, which is the neighbour that makes the pair legible as "who I am" and "who else may". Never out of reach, unlike the two entries around it. A wallet belongs to the person, not to an organization, so there is nothing for the route to name and nothing to fall back to — it is the one entry in that group that is live on a fresh account with no organization yet. The wallet page gets a rail too, having had none. It is an account page and names no organization, so the rail falls back to the last one visited, which is what keeps a wallet from being a dead end. Verified in a browser on three pages — the board, the wallet itself and the permissions page — same six entries in the same order, four above the rule and two below, and no padlock left in the header. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a flat list whose nesting was faked with left margins, painted in two saturated gradients — violet for the active layer, pink for everything in it — which made a panel you scan the loudest thing on a screen whose subject is the board. Flat, a group could not be closed, and "inside that group" looked exactly like "below it, at the same level": an indent is not containment when nothing can be collapsed to prove it. It is a tree now. Closing a layer closes what is in it, and a shut branch is not walked at all rather than walked and hidden — a board with two thousand nodes should cost nothing to draw while its layer is shut. The state is "what is shut", not "what is open", so a node created while its layer is open arrives open. The other way round, everything new would appear folded away. One behaviour changed rather than moved: only the ReactFlow layer was clickable and only its nodes selectable, written when it was the only surface. It is not, and Excalidraw is the default one, so the panel spent most of its time listing things nobody could touch. Split in two, which is what makes it storyable: `LayerTree` takes a collection and hands back clicks, and the panel is the wiring that reaches for awareness and the layer registry. Six stories — a board, another layer active, a selection, the flags, empty, and deep enough to show what the indent is for. No eye and no padlock. The board's `onTreeOperation` is a stub that logs, so those would be controls that lie. Locked and hidden are *shown* where they are true, since that costs nothing, and the controls arrive when the operations do. The two flatteners go with the list that needed them. Verified on the running board: 19 rows, the active layer marked, the other layer's three rows dimmed, and collapsing Excalidraw taking it to 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel listed the board twice. A second layer held the graph's nodes, properly named; the drawing surface held the same nodes as the elements it projects them into, named "Embeddable 3". Two listings of one board, disagreeing about what is on it — and the disagreement was the tell that the distinction had stopped being real. The surface is the board. There is one list. So the embeds take their names from the graph, which is where a node's name lives — the element only ever carried the id. The board's own layer is reported only while ReactFlow is the surface, where the nodes really are somewhere else. And a frame's contents go inside it. Excalidraw says which frame an element belongs to and then keeps the scene flat, so a group and its contents were siblings — the one arrangement that makes a group look like a neighbour. Reading `frameId` is reading a fact the scene already carries. "Excalidraw" is gone from the panel. It is the name of a library we happen to draw with: nobody using this board chose it, and it says nothing about what the layer holds. `Layer 1`, because there will be a second. The id stays `excalidraw` — that one is the registry key, and saved payloads name it. Verified on the running board: one layer, sixteen rows, the projected node listed as "notebook" rather than as an embeddable, and no empty second layer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The model and its writer, ahead of the surface that will use them. Nothing sends these events yet, so this changes nothing for anyone — which is the point of landing it on its own: the half that touches people's boards should arrive on top of a settled model, not alongside one. A layer is not a second canvas. Excalidraw's scene is an ordered array and that order *is* the paint order, so a layer is a contiguous block in it and reordering layers reorders their blocks. Two stacked instances would have doubled the cost measured at 2000 nodes and left a focus and a z-order to arbitrate between them. `order` is a number, not a position in a list, because this is a shared map and two people may reorder at once: per-key last-writer-wins on a number converges on *an* order, where a shared list rebuilt by two writers can converge on a list with holes in it. Ties break by id, so two layers made in the same millisecond do not swap places between two readers. Reordering carries the whole stack rather than "move this one there". A move is only meaningful against the list the mover was looking at, and two people moving at once against different lists produce an order neither asked for. And a layer missing from a stale list keeps its own order rather than being dropped — a client must not delete another client's work by omission. An element with no layer is not broken and needs no repair: it belongs to the bottom of the stack, which is where it was when that was the only place there was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rder The half that touches the board. Reading the stack, giving a board its first layer, putting a new stroke on the layer its author was working on, and — the whole point — painting back to front. That last one is three lines, because Excalidraw's array *is* the paint order: sorting the scene by layer is the entire feature, and nothing else in the pipeline has to know layers exist. Stable within a layer, so Excalidraw's own bring-to-front still means something. An element with no layer, or on a layer this client has never heard of, sorts to the bottom — where it was when the bottom was the only place there was, which is why a board that predates layers needs no migration. A first layer is asked for on sight rather than migrated in. The elements of an old board have no layer at all and belong at the bottom whether or not the bottom has a name yet; naming it is what gives the panel something to list and the next stroke somewhere to go. Which layer is active rides in the layer payload — per client, not shared. Two people drawing on one board are usually on different layers, and sharing it would have each of them moving the other's pen. The panel gains `updateLayerTrees`, for a provider that is several layers rather than one. Publishing them one at a time cannot express a removal: a deleted layer would sit in the panel until a reload because nothing said it was gone. One mock had to stop bypassing its selector. It answered the layers key with the raw list, so the filtering by drawing was never exercised — and a test about ignoring another board's layers passed while doing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Excalidraw already has send-to-back and bring-to-front, and they reorder the scene — which is where layers live. So pushing an element past a boundary is how you move it between layers. Membership follows position. The alternative was to sort it back into its block, and that would have made those two commands do nothing across a boundary: click, watch it move, watch it come back. A control that appears to work and then undoes itself, which is the thing this board keeps being caught doing. Within a layer nothing changes. The order of the elements is still what says which is in front, and Excalidraw's own commands are still what change it. An element that broke the order is the one that moved. A first attempt asked its neighbours instead, and that pushed an element which is simply the only one on its layer into its neighbour's — at an end there is one neighbour, and one neighbour is not a vote. Two tests caught it: the boundary between two blocks, and a scene with an untagged element at the front. A move past a boundary changes no version — Excalidraw renumbers on a mutation, not on a reorder — so the version gate alone dropped it and the element snapped back on the next reload. The flush now sends an element whose version *or* whose layer changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The half that makes the rest usable. A `+` in the panel's header, and layer rows you can drag past each other. The panel does not know how a layer is made. Creating one is an Excalidraw event, and a panel that dispatched it would know one provider by name — so the provider publishes two verbs alongside its layers and keeps its own vocabulary. No verbs, no controls: a `+` that does nothing is worse than no `+`, which is the rule the eye and the padlock are still waiting on. A reorder carries the whole stack rather than "move this one there", and the panel hands it back front-first because that is the order it was showing. The flip to paint order happens where both facts are known. Three defects the browser found, none of which a test would have: The panel showed "Layer 1" twice — the placeholder seeded for the provider before it had spoken, beside the real first layer. Publishing now clears the placeholder too. The first layer was requested five times. The write takes a round trip, and the effect ran again on every render until it landed. Once per drawing now. And the `+` appeared to do nothing: the section was published from `onChange` alone, so a layer created in the panel did not reach the panel until the next stroke. It publishes when the stack changes as well. Verified in a browser: the `+` adds Layer 4 with one event, dragging the bottom row to the top gives Layer 1, Layer 4, Layer 3, Layer 2, and one reorder event. No duplicate rows, no page errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing a node This deleted a node from a real board. The service behind it went on running, with nothing left on the board pointing at it — which is the worst shape a data loss can take: nothing throws, nothing is logged, and the thing that is gone is the only way anyone had to reach the thing that is not. `updateScene` replaces the element list. A node missing from a projection — an incomplete write, a graph view that has not loaded yet — comes back tombstoned on the next change, and the write-back read that as a person reaching for the eraser. So the write-back now consults what the last projection actually drew. A tombstone for a node that was not in it is the projection's own doing and is ignored. Erasing a node on the surface still deletes it, which is the behaviour that path exists for and is tested above. Found by testing this branch against the shared board, not by a test: the notebook node vanished from `sync-test` while its container kept reporting green on the Resources page. The node is not recoverable — Yjs has no undo here — and it can be created again from the resources card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he board It was drawn as a card — a border on all four sides, rounded corners — and the collapse control was a 24px strip with a rule of its own. Against a board that has no cards in it, that read as three vertical bands down the left edge: the panel's own border, the strip, and the line between them. The middle one looked blue because `--color-border` is a blue-violet in this palette, so it read as a stray element rather than as an edge. A panel that is docked has one edge that matters, and it is the one where it meets the board. One hairline there, in the muted border, and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting any layer in the panel made the whole surface vanish — no canvas,
no toolbar, nothing. Reported as "the layer system is broken in general", and
it was: this is the bug behind most of what looked like separate faults
today, including a node that seemed to have disappeared.
A row's id carries its provider, `excalidraw:layer-2`, since the surface now
publishes several layers and two providers must not collide on a name. The
board picks which layer renders by comparing the active id to a *provider*
id. Handed the row's, nothing matched, the surface got `active={false}` and
returned null.
Which layer was clicked belongs in the payload, where the surface already
looks for it — that is what the active-layer payload was added for. The
provider id goes where the board expects a provider id.
Verified in a browser: clicking Layer 1 and then Layer 3 leaves the surface
mounted, two canvases, nineteen rows — where before the first click emptied
the page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top-left island was nudged past the panel when that rule was written; the
bottom-left group was not, so the zoom and undo controls sat under the panel.
The one piece of Excalidraw's chrome the panel could cover, and the one
nobody looks for, because everything else had moved.
Padding, not margin: the row is `justify-content: space-between`, so a left
margin would drag the right-hand group with it and push the help button off
the board.
The class named twice, to outweigh `.excalidraw .App-menu_bottom { padding: 0
1rem }` — the same specificity as one mention, and its stylesheet loads after
this one, so the shorthand won and the padding never applied. Measured: the
controls stayed at x=16 until the selector was heavier, then moved to 271,
clear of a 240px panel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`convertToExcalidrawElements` walks a frame skeleton's `children` unconditionally — `element.children.forEach(...)`, no guard — and our group branch never set the key. So the moment a board held one group node, the conversion threw; and because the projection is an async effect, the rejection went nowhere. What that looked like: a canvas that drew, a layers panel that listed every shape, and not one service card. Which reads as "the node was deleted", not as a crash — I read it that way myself, said so, and went looking for a node that had never left. Empty children rather than populated: membership is already ours, set as `frameId` from the view's `parentId` just below. Excalidraw's path wants ids present in the same batch and throws on any it cannot map, which our ids — assigned after the conversion — are not. The conversion now also fails loudly. It stays fatal to the projection, since a skeleton cannot be dropped without dropping the view it is matched to by index, but it names itself in the console instead of leaving a blank board to be interpreted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the layers panel was missing, and one it was only pretending to have. Clicking a layer now selects everything on it, and clicking a row brings the board to what it names. A panel row and its element can be a screen apart — on a board wide enough to need layers they usually are — so a click that only highlighted the row answered "which one" without ever answering "where". Both are published by the surface with its stack, next to `addLayer` and `reorderLayers`, rather than reached for. The panel holds a list of rows; how a row maps to a scene element is the provider's business, and a panel that knew it would know one provider by name. Centring does not go through `api.scrollToContent`, which is the obvious call and does nothing here: this layer owns the viewport and pushes the shared one back into Excalidraw whenever it changes, so Excalidraw's own scroll is overwritten the moment it is set. Measured, after the call ran, found its element, and left the board exactly where it was. It reports a viewport instead — the same road a mouse scroll takes — which also keeps the whiteboard's own viewport in step. And the pretence: elements drawn before layers existed were *read* as the bottom layer's, by a fallback in the grouping, but nothing had ever written it down. The board's data said "no layer" while the panel said "Layer 1", and reordering the stack moved rows that were only there by default. They are adopted once, on first sight of a stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| const elements = pendingElements.current.filter( | ||
| (e) => !embeddedNodeId(e) | ||
| ); |
There was a problem hiding this comment.
🔴 Connector lines drawn between cards get saved as if someone had drawn them by hand
The connecting lines the board draws for itself are kept as part of the saved drawing (filter((e) => !embeddedNodeId(e)) at packages/modules/excalidraw/src/lib/layer.tsx:1163-1165) instead of being left out like the cards they join, so people see duplicated and flickering lines and the saved drawing fills up with lines nobody drew.
Impact: Connector lines appear twice after a reload and blink on and off while two people have the same board open, and the saved board accumulates content the user never created.
How the projection's own arrows leak into `excalidraw:elements`
The projection effect builds an Excalidraw arrow per graph edge and marks it with customData.holistixEdge (packages/modules/excalidraw/src/lib/layer.tsx:992-1013), then writes it into the scene together with the node embeddables.
The save path only excludes elements carrying holistixNodeId:
const elements = pendingElements.current.filter((e) => !embeddedNodeId(e));so every projected arrow is dispatched through excalidraw:upsert-elements and stored in the shared element map as an ordinary drawing element.
Three consequences follow:
- On the next projection the scene is rebuilt from
scene.filter((e) => !embeddedNodeId(e) && !e.customData?.['holistixEdge'])plus freshly converted arrows, andconvertToExcalidrawElementsmints new ids for them — so the previously stored arrow ids disappear fromcurrentand the same flush emitsexcalidraw:delete-elementsfor them while upserting the new ones. Every node move therefore writes and deletes one element per edge. - A second client pulls those arrows into its scene, its own projection strips them (they carry
holistixEdge), and its flush then deletes them from shared state — the two clients fight over the same keys. - Anything that survives is reloaded from the map at mount and re-projected, so the same edge is drawn twice.
User-drawn arrows that are converted into edges (packages/modules/excalidraw/src/lib/layer.tsx:1230-1266) hit the same problem from the other side: the original arrow stays a stored drawing element while the new edge grows a projected arrow of its own.
| const elements = pendingElements.current.filter( | |
| (e) => !embeddedNodeId(e) | |
| ); | |
| const elements = pendingElements.current.filter( | |
| (e) => !embeddedNodeId(e) && !e.customData?.['holistixEdge'] | |
| ); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const newKernels: Kernel[] = (kernelModels ?? this.kernelResources).map( | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| (k: any) => ({ | ||
| kernel_id: k.kernel_id ?? k.id, | ||
| name: k.name, | ||
| type: k.type, | ||
| last_activity: k.last_activity, | ||
| execution_state: k.execution_state || '', | ||
| connections: k.connections || 0, | ||
| notebooks: [], | ||
| }) |
There was a problem hiding this comment.
🟡 A notebook's kernels temporarily lose the notebooks they belong to when one status request fails
When only part of a container's status can be read, the previously known kernels are rebuilt with their notebook list emptied (notebooks: [] at packages/modules/jupyter/src/lib/driver.ts:294-302) and published anyway, so the project briefly forgets which notebook each kernel belongs to.
Impact: Until the next successful poll, kernel cards show no notebook attached even though nothing changed on the machine.
Partial-failure path in `pollResources`
pollResources returns early only when both halves failed. If pollKernels() returns null (request refused) while pollTerminals() succeeds, newKernels is mapped from the cached this.kernelResources — but the mapper hardcodes notebooks: [], and the session lookup that would repopulate it is deliberately skipped because kernelModels === null (packages/modules/jupyter/src/lib/driver.ts:305-317).
deepEqualKernels then reports a change for any kernel that previously had notebooks, the listeners fire, and jupyter:resources-changed pushes the stripped kernels into the project's shared state. The association returns only on the next successful kernel poll, so the state flaps.
Carrying the cached notebooks through when the kernel list is not fresh would keep the last known truth instead of publishing a partial one.
Prompt for agents
In packages/modules/jupyter/src/lib/driver.ts, pollResources falls back to this.kernelResources when pollKernels() returns null, but the mapper always sets notebooks: []. Since the session lookup that repopulates notebooks is (correctly) skipped for a stale kernel list, the published kernels lose their notebook associations on every partial failure and regain them on the next success, causing shared state to flap. Preserve the cached notebooks when the kernel list comes from this.kernelResources rather than from a fresh poll — for example by reusing the cached objects unchanged in that branch instead of re-mapping them.
Was this helpful? React with 👍 or 👎 to provide feedback.
| event.layerIds.forEach((layerId, index) => { | ||
| const key = layerKey(event.drawingId, layerId); | ||
| const layer = layers.get(key); | ||
| if (!layer || layer.order === index) return; | ||
| layers.set(key, { ...layer, order: index }); | ||
| }); |
There was a problem hiding this comment.
🔍 Reordering layers leaves unnamed layers with colliding order values
_reorderLayers writes order = index only for the layers named in the event and deliberately leaves the others untouched, so a layer that a client had not yet seen keeps whatever number it had — which will almost always collide with one of the freshly written indices. _layersOf then breaks the tie with a.id.localeCompare(b.id), so the unseen layer's position is decided alphabetically by id rather than by anything the user did, and it can silently jump in the stack after any reorder. The comment acknowledges the layer is not deleted, but not that its position becomes arbitrary. Appending unlisted layers after the rewritten block (or renumbering the whole stack) would make the outcome predictable.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export const withStackingIndex = ( | ||
| elements: readonly TJsonObject[] | ||
| ): TJsonObject[] => { | ||
| const complete = elements.every((e) => typeof e['index'] === 'string'); | ||
| if (complete) return [...elements]; | ||
|
|
||
| const width = String(Math.max(elements.length - 1, 0)).length; | ||
| return elements.map((element, i) => ({ | ||
| ...element, | ||
| index: `a${String(i).padStart(width, '0')}`, | ||
| })); | ||
| }; |
There was a problem hiding this comment.
📝 Info: Legacy migration rewrites every index when only some elements carry one
withStackingIndex regenerates a0…aN for the whole drawing as soon as a single element lacks index, which discards the fractional indices the elements that did have one were carrying. The reducer test only covers the all-present and all-absent cases, so the mixed case — a drawing edited across the version that introduced index — is untested and would be silently restacked. Synthesising only for the elements that lack one is unsafe for the reason the comment gives, so the safest reading is that a mixed drawing should be renumbered in the array's own order, which is what this does; worth a test stating that explicitly.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Write a clean error state without pending, keeping the attempt | ||
| // count: this clears a stuck flag, it is not a fresh failure, and | ||
| // dropping the count here would restart the backoff every time a tab | ||
| // died mid-fetch — which is the state most likely to repeat. | ||
| const previous = this.read<T>(key) as AnError | null; | ||
| this.write(key, { | ||
| error: true, | ||
| wait: new Date().getTime() + ERROR_WAIT, | ||
| attempt: previous?.attempt, | ||
| }); |
There was a problem hiding this comment.
🔍 Backoff is discarded when a stuck pending state is cleared
restartAfterError preserves attempt but writes a fixed ERROR_WAIT deadline, so the next retry happens 30s later regardless of how many consecutive failures preceded it. The comment says dropping the count would restart the backoff, which is true, but the wait itself is still restarted — a tab that repeatedly dies mid-fetch keeps retrying every 30s against a rate-limited endpoint, which is exactly the pattern errorWait was introduced to stop. Using errorWait(previous?.attempt ?? 0) here would make the two paths agree.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (!service) { | ||
| throw new Error(`Service ${serviceName} not found`); | ||
| } | ||
| throw new Error('Not implemented'); | ||
| return (config as TConfig)?.getAccessToken?.() ?? ''; |
There was a problem hiding this comment.
📝 Info: Comment and behaviour disagree about the token handed to a container
The new doc block says the caller "gets an empty string" and that returning a real token "would hand the notebook's whole API to whoever holds the page", but the implementation now returns config.getAccessToken?.() — and module-data-provider.tsx supplies exactly that for the user-containers config key. The user's own access token is the intended value (the guard validates it and swaps in the service token upstream), so the code is doing what the rest of the change wants; the comment describes the previous contract and will mislead the next reader into thinking the empty string is a security invariant. The accompanying spec asserts '' only because it loads the module with no config.
(Refers to lines 95-120)
Was this helpful? React with 👍 or 👎 to provide feedback.
| // allows reports whether this origin may read a response from the container. | ||
| func (c *CORS) allows(origin string) bool { | ||
| if origin == "" { | ||
| return false | ||
| } | ||
| o := strings.ToLower(origin) | ||
| if _, ok := c.origins[o]; ok { | ||
| return true | ||
| } | ||
| // A subdomain of the platform. Compared against the allowed origin with its | ||
| // scheme, so `https://evil.com/?x=https://apollo.test` and | ||
| // `https://apollo.test.evil.com` both fail: the first is not a suffix at | ||
| // all, the second does not end at a label boundary of an allowed origin. | ||
| for allowed := range c.origins { | ||
| host := strings.TrimPrefix(allowed, "https://") | ||
| if strings.HasPrefix(o, "https://") && strings.HasSuffix(o, "."+host) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
🟨 Credentialed CORS is granted to every subdomain of the platform domain, including tenant-served container pages
NewCORS/allows accept any origin ending in .{platform domain} and answer with Access-Control-Allow-Credentials: true and the caller's origin echoed back. Every user container's services are themselves published as subdomains of that same domain ({service}.uc-X.org-Y.{domain}), and those containers run tenant-chosen images that can serve arbitrary HTML/JS. A page served by one tenant's container can therefore issue credentialed cross-origin requests to another container's auth guard; the browser attaches the platform-scoped session cookie, and the guard's response becomes readable to the attacking page. The per-container session cookie naming added in this PR limits which session is sent, but a victim who has opened the target container in the same browser has exactly that cookie.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (!service) { | ||
| throw new Error(`Service ${serviceName} not found`); | ||
| } | ||
| throw new Error('Not implemented'); | ||
| return (config as TConfig)?.getAccessToken?.() ?? ''; |
There was a problem hiding this comment.
🟨 The signed-in user's platform access token is now handed to container services from the browser
getToken used to refuse (throw new Error('Not implemented')) and now returns the user's own platform access token, which callers attach as Authorization on requests to a container's host ({service}.uc-X.org-Y.{domain}) — see packages/modules/jupyter/src/lib/ds-backend.ts:29-46 and packages/modules/jupyter/src/lib/driver.ts:202-208. That token is the user's platform-wide credential, and it is sent to a host running a tenant-supplied image whose front proxy is the only thing between it and the workload.
(Refers to lines 113-120)
Was this helpful? React with 👍 or 👎 to provide feedback.
| function redactSecrets(uri: string): string { | ||
| return uri.replace( | ||
| /([?&](?:token|access_token|refresh_token|api_key|apikey)=)[^&#]*/gi, | ||
| '$1REDACTED' | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟨 Failed-request error messages now embed full URLs and only mask a fixed list of credential parameter names
API failures now build a message containing the full request URI, redacting only token, access_token, refresh_token, api_key and apikey. Any other credential-bearing query parameter — code, state, id_token, secret, password, signature, sig, key — survives verbatim into a message that, as the surrounding comments note, is logged and shipped to the collector.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if [ -n "${AUTH_GUARD_UPSTREAM_TOKEN:-}" ]; then | ||
| GUARD_FLAGS="$GUARD_FLAGS --upstream-token ${AUTH_GUARD_UPSTREAM_TOKEN}" | ||
| fi | ||
|
|
There was a problem hiding this comment.
🟨 The token that unlocks a container's notebook API is passed as a command-line argument
The upstream service token is appended to the auth guard's argv (--upstream-token ${AUTH_GUARD_UPSTREAM_TOKEN}). Anything able to read /proc/*/cmdline inside the container — which includes the tenant's own workload processes, since the guard and the service share the container — can read it, as can anything capturing process listings for diagnostics.
Was this helpful? React with 👍 or 👎 to provide feedback.
The rail and Excalidraw's top-left column are both placed from `--holistix-left-rail`, so they landed on the same x — the rule was written to clear the layers panel, and the rail became an island on the same surface afterwards. Measured: rail 255→311, panel 271→471, forty pixels of overlap over three hundred and sixty-eight, which is the column holding one control from every row. Both are moved out by the rail's own width. The panel keeps its place below the button rather than beside it: it is 200px, and Excalidraw centres the shapes toolbar in the middle third — at 1280px that starts at x=505, so a panel raised to the button's row would run into it. The button's own menu had the same forty pixels under the rail's first icons, unreported and found while measuring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… everything
An external drive carries its own name into the path, and those have
spaces. Unquoted, nginx counts the words in the `root` directive and
rejects the whole configuration:
nginx: [emerg] invalid number of arguments in "root" directive
The message names the directive and not the reason, and the platform
stays up on its old config until something reloads it — so the failure
surfaces later and somewhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local service is one image today, but an application is a pile — an
interface, an API, a database — and only the interface should be
reachable from the project.
The routing for that already exists: a container publishes one
`map-http-service` per door and the gateway writes one nginx block per
entry, at `{service}.uc-{cid}.org-{oid}.{domain}`. What was missing is a
way to say which doors exist at all.
`holistix.stack.yaml`, in the repository the stack is built from, so
what runs and what is reachable move with the code that changed them.
`ports` and `tunnel` are two separate lists on purpose: one list would
have made "runs" and "is public" the same word. Not exposed is the
default — publish-then-subtract makes a forgotten line the difference
between a private database and an open one.
Deliberately not a compose file. `image_id` is an allowlist key and the
broker "never accepts a command line"; a compose carries `privileged`,
`volumes`, `network_mode`. This declares, and the runner composes the
invocation. Every service names an id resolved through the image
catalogue, so a stack cannot reach an image its project could not have
started alone.
The refusals are each a failure diagnosed somewhere else: a tunnelled
port the service never opens would answer connection refused and read as
a broken tunnel; two doors of one name describe one FQDN and the loser
answers nothing; a dot in a name adds a label to the FQDN, which is built
by interpolation and cleans nothing; and `sync.from` may not leave the
repository, because this file is read by a script somebody pasted into a
terminal.
`ContainerImageRegistry.onProjectCleared` pairs the two catalogues: a
second one keyed by project is a second thing to forget, and the one
forgotten keeps serving the previous tenant. Neither has a caller yet —
tracked in TAC-366.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Local button dispatched `runner_id: 'local'` and nothing else. The event has carried an optional `machine_id` for weeks and the platform routes it end to end, but nothing emitted it — so every local placement named no machine, `assertPlacementIsForUs` refused it, and no enrolled runner ever received one. The whole local path was unreachable from the interface. "local" is not a destination. It is a set of machines, so the click now opens a picker and the other runners go straight through unchanged. The list comes from Ganymede's `GET /runners`, not from the project's machine catalogue: that one holds only machines already heartbeating into this project, and the first placement is what puts one there — a picker fed from it would offer nothing, forever. `describeMachines(runners, now)` takes the instant as an argument so the availability rule is testable without freezing a clock. Thirty seconds, the same number the container watchdog uses, because to somebody looking at a card a dead machine and a dead container are one event. A runner stamps `last_seen_at` on every authenticated request and polls every fifteen, so that is two missed passes rather than one. Unavailable machines are present and refused, never dropped: one that disappears reads as one that was never enrolled. The quiet state is named as an action — "run holistix-runner run on it" — because "offline" would suggest the machine is off when the usual cause is that the runner is not started. A dialog rather than a popover, for what has to be said in it: placing a service means other members of the project can place services on that machine too, which TAC-156 asks to be said in the UI and not only in a ticket. The connected half is mounted only while the dialog is open — a hook runs whenever its component does, and a project with several cards would poll once per card for a list nobody is looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`wait` unref'd its interval timer. The intent was that a pending wait should not delay shutdown, which is right — but unref does not apply only when stopping. Between two ordinary passes that timer is the only thing referencing the event loop: a pass opens no listener and holds no socket, and `process.once` on a signal does not reference it either. So node found nothing left to do and exited, code 0, no message. Measured against a real Ganymede: `holistix-runner run -i 10` announced itself once, stamped one `last_seen_at`, and was gone within twenty-five seconds. The machine then showed in the picker as "not answering", which is true and says nothing about why. Shutdown was already handled by clearing the timer when `stop` resolves, so the unref was both unnecessary and fatal. Asserted on the timer rather than on a count of passes: a test runner holds the event loop open by itself, which is why every pass-counting test in this file went on passing while the built binary could not survive its own interval. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…there Two faults on the same path, both found walking it against a real Ganymede for the first time. **Moving a service did not take it off where it was.** `_setRunner` wrote the new runner and never told the old one, so the container it had started went on running. The old runner is now asked to stop first — softly, because it may be an engine that is now unreachable, and refusing the move would pin the service to a machine nobody can reach. A container left behind can be removed by hand; a placement that cannot be moved cannot. **A built-in image was referenced by digest.** A digest makes the runtime resolve the manifest at the registry even when the tag is already on disk, and a runner holds no registry credentials — the broker has the project's, a laptop has nothing. Measured on Apple `container`: with the digest, `401 Unauthorized, no credentials found for host registry-1.docker.io`; with the tag alone, the same image started from disk in five seconds. That is the line `TRunnerPlacement.builtin` already draws rather than a new one: a tenant image is trusted *because* it is pinned, a built-in because it is in this deployment's own catalogue. Only the runner paths read this — the broker is handed `imageId` and re-resolves it host-side, so nothing there loosens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two writers, two spellings, one container: the broker labels
`holistix.user_container`, this runner `holistix.user_container_id`.
Both set `holistix.project`, so `listOwned` did return the broker's
container — with no id on it. `planReconcile` opens by dropping anything
it cannot name:
running.filter((c) => c.user_container_id)
so it concluded there was no container for the placement and asked to
create one whose name was already taken. Measured moving a notebook off
the platform onto a laptop: `container with id holistix_notebook_uc_msiod
already exists`, every ten seconds, with no convergence possible.
Both spellings are read now, and only the new one is written. Unifying
them would be tidier and is not this change: the broker's spelling is on
every container it has already started, and a runner that understood
only the new one would go on being unable to adopt them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**`pack` packaged whatever was in dist/.** It checked the directory existed and refused when it did not — but a directory from an earlier build satisfies that perfectly. So it packaged code from before the change, `reload-gateway.sh` installed it, and the gateway came back running exactly what it had been running. Every step reported success. Measured today: a whole diagnosis cycle spent looking for a fix inside a bundle that had never contained it, twice. It builds now. nx is incremental, so a no-op pack costs the graph read and nothing else, and there is no case where skipping is worth shipping the previous build. `PACK_NO_BUILD=1` covers the one thing this is not about: packing a tree you did not build, deliberately. **`fetch failed` was the whole message.** Node hides the reason on `error.cause`, and on a runner that matters more than usual: the only thing it does is talk to a platform it was pointed at, so a transport failure *is* the failure — and the person reading it is on their own laptop with no server log to check. `holistix-runner status` printed those two words. The cause was `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, a mkcert certificate whose authority node does not carry, and finding that took reading the source and reissuing the request by hand. The remedy is one environment variable. It now says so, and says which rootCA.pem to use — not the untracked copy lying in the repository, which on this machine is four months older than the authority in use. Unrecognised codes say only themselves: a message that explains the wrong thing sends somebody looking where the fault is not. The wrapper binds the real fetch at module load. Installed as `globalThis.fetch`, a bare `fetch` inside it resolves to itself — which it did, on the first build: `Exception in PromiseRejectCallback`, on every command, including the ones with nothing wrong. Unit tests could not see it, so the regression test reproduces the installation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four conflicts, two of which needed a decision rather than a side. **servers-reducer.ts** — this branch had pulled config construction out of its two call sites into `_runnerConfig()`; main had added `service_label_prefix` to the inline version. Taking either side would have swallowed the other. The extraction is kept and the field moved into it, which is what the extraction exists for: the file already records that these two sites drifted twice, on `auth_guard.client_id` and then on `gateway_dev`, each time a field added to one and forgotten at the other. **server-card.tsx** — main introduced a local `ledColor` for green = platform, blue = somebody's machine. This branch has an exported, tested `ledColor()` that already encodes that rule, plus yellow for the window between the ask and the container's first report, plus a stop outranking an earlier start. The auto-merge produced both in one scope, which is a temporal dead zone: `const color = ledColor(container)` runs before `const ledColor` is initialised. Helper kept, `StatusLed` pointed back at it. **server-card.spec.tsx** — both sides add tests, and they test different things: this branch `isAlive`, `ledColor` and the platform badge; main the command that overflowed the card, the LED as rendered, and presence. Merging them in place produced invalid syntax — git had interleaved the context, so the two sides were competing continuations of one scaffold rather than separate blocks. Rebuilt from this side with main's three describes appended whole, and the two imports they need. **doc/README.md** — a line each in the same two lists. Both kept. 263 tests pass in user-containers, up from 212: main's 51 are all here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux personnes qui dessinaient dans le même dessin s'écrasaient. Toute la scène tenait dans une seule entrée de shared map — le tableau d'éléments plus un SVG sérialisé — donc chaque trait réécrivait le tout, et la seule réconciliation était un test
fromUserqui remplaçait la scène entière quand il différait. Rien ne pouvait arbitrer : le module n'avait aucun reducer backend, c'était le seul à écrire dans Yjs directement depuis le navigateur.Ce qui change
<drawingId>::<elementId>, pour que la concurrence par clé de la shared map fasse le travail : deux personnes sur deux éléments ne touchent jamais la même clé.ExcalidrawReducerbackend et des événementsexcalidraw:*, comme tous les autres modules. Plus aucune écriture directe depuis le frontend.reconcileElementsd'Excalidraw, surversion/versionNonce, au lieu de remplacer la scène.project:initdes dessins écrits dans l'ancien format. Idempotente par construction plutôt que par marqueur de version : chaque dessin est supprimé au moment où il est déplacé, et les clés dérivent des données, donc deux exécutions concurrentes écrivent les mêmes clés avec les mêmes valeurs.Le piège de l'ordre
L'ordre d'empilement vivait dans le tableau. Une map keyée n'en a pas — il est porté par l'
indexfractionnaire d'Excalidraw. Un dessin écrit avant l'existence de ce champ serait revenu dans l'ordre arbitraire de la map, silencieusement restacké.withStackingIndexsynthétise les index pour un dessin entier d'un coup quand il en manque, jamais élément par élément : réels et synthétiques se comparent selon l'alphabet d'Excalidraw, donc les deux ne doivent pas s'entrelacer.Vérification
28 tests, dont un contre un vrai document Yjs : deux documents qui n'échangent que des updates, chacun modifiant un élément différent hors ligne, convergent avec les deux modifications. C'est exactement le bug corrigé — et ce test ne pouvait pas s'écrire contre l'ancien modèle.
Non fait : la recette sur un environnement réel. La VM de dev n'existe plus dans ce workspace, donc ça reste à faire avant de considérer TAC-215 comme clos.
Au passage
L'arbre de calques keye ses lignes sur l'id de l'élément et non sur sa position dans le tableau — c'était faux dès qu'on supprimait quelque chose. Et l'observer de shared data est enfin libéré au démontage : chaque montage en laissait un derrière, à écrire dans une scène disparue.
Refs TAC-214, TAC-215
🤖 Generated with Claude Code