close
Skip to content

feat(tailnet): kirocrew tailnet up — actually publish the dashboard - #2109

Merged
iamwhatever merged 1 commit into
mainfrom
feat/tailnet-serve-control
Aug 10, 2026
Merged

feat(tailnet): kirocrew tailnet up — actually publish the dashboard#2109
iamwhatever merged 1 commit into
mainfrom
feat/tailnet-serve-control

Conversation

@CrysisDeu

@CrysisDeu CrysisDeu commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

KiroCrew never ran tailscale serve anywhere. The config switch made the gateway
trust the tailnet origin; putting the dashboard on the tailnet was a command the
operator had to know and type. Two independent changes are required and either one
alone is a dead end — publish without trust and every request is refused by the
Origin check with a bare 403, trust without publish and there is nothing listening
— so the documented flow was three commands, one of them undocumented in the UI.

Adds kirocrew tailnet {up,down,status}.

up publishes first and records the config only once publishing succeeded. The
reverse order would leave a host claiming tailnet access is on with nothing serving
it, and the operator's next clue would be a 403 from another device. It prints the
URL to open, and says a restart is needed unconditionally — including when the
switch was already on, because the origin is resolved once at gateway startup.

status reports the three things that are independently required: whether the
setting is on, whether a MagicDNS name resolves right now, and whether serve is
actually pointing at this dashboard. Any one of them being wrong looks identical
from the user's chair, so a single on/off line would not be diagnostic. The live
name read is correct here and would be wrong in GET /api/tailnet/status: this
command reports what the machine can do next, the endpoint reports what the running
server already trusts.

down stops publishing and deliberately leaves the config alone — the trusted
origin is unreachable without serve, so clearing it would be an unrequested second
change that also demanded a restart to undo a withdrawal that took effect at once.

New module rather than an addition to dashboard/tailnet.py

tailnet.py's documented contract is the opposite of what a write path needs: it
swallows every failure so the gateway boots on a host that has never heard of
Tailscale. Here a failure is the point of the call — tailscale serve refuses for
reasons the operator can act on and cannot guess, most often because changing serve
config needs root or an --operator grant. tailnet_serve.py therefore returns a
ServeResult(ok, code, detail) and always passes the daemon's own stderr
through verbatim
: code is a best-effort classification for the UI to branch on,
detail is what Tailscale actually said. Upstream owns that wording, so a wrong
classification must still leave the operator with the real reason.

Published-state detection does not read the JSON schema. This host has no
Tailscale, so the shape of tailscale serve status --json is unverified here.
Rather than guess key paths, serve_state searches the parsed document for a proxy
target naming our own port, and an unrecognisable document reports unknown rather
than not published — reporting "not published" for a published node is the
checked-but-never-ran defect in a new costume.

Governance

publish is a fourth chokepoint on capabilities.tailnet_origin, checked before
the spawn: a pinned fleet forbids putting this host's dashboard on a tailnet, and
refusing after publishing would be theatre.

unpublish is deliberately NOT gated, and the asymmetry is load-bearing.
is_governance_pinned_off returns true both for a real policy deny and for a
ceiling it could not evaluate, so gating withdrawal would mean a transient
policy-read failure leaves a dashboard published on a tailnet with no supported way
to take it down — a fail-closed control failing open in effect. Same direction that
lets a config write of false through while true is refused.

Spawn hardening is shared with the read path by import, not copied: the binary
comes from _cli_path's vetted absolute allowlist and never from PATH, and the
child gets sandbox.scrub_env(). Registered in BENIGN_SPAWNS with the reason it
is not routed through sandboxed_spawn_argv — the call's whole purpose is to mutate
the local daemon's serve config through its unix socket, which is precisely the
ambient authority a sandbox removes.

Refactor

cli_config.set_base_config_key is extracted from config set so tailnet up
gets the same write semantics (unknown-key detection, overlay subtraction) rather
than a second, subtly different writer. Governance gating stays at the call sites,
where the wording and exit path differ per command.

Docs

The guide's Tailscale section leads with kirocrew tailnet up, keeps the manual
three-command form as the fallback, and states the root/--operator requirement.
The governance spec's chokepoint table goes from three rows to four and records the
withdrawal asymmetry.

End-to-end verification

test/test_tailnet_e2e.py exercises the real process boundary — a real
executable named tailscale on disk, found by the production _cli_path, spawned
by the production subprocess.run under the production scrub_env(), answering
over real stdout with real exit codes. It persists serve config, so
publish -> status -> withdraw runs as a real state machine, and it records every
argv it received. That covers what mocks structurally cannot: the argv a real daemon
would get, that scrub_env() leaves a usable environment (a scrubber that
stripped too much passes every mocked test and fails on every real host), stderr and
exit codes propagating through the CLI's own exit path, and the foreign-443 refusal
end to end.

The argv contract is verified against upstream source rather than assumed:
cmd/tailscale/cli/serve_v2.go confirms --bg (:239), --https as a UintVar so
--https=443 parses (:241), serve status --json (:257/:262), and off as a
trailing positional with the target optional (:361).

Still unverified: Tailscale's own runtime behaviour and the real shape of
status --json.
A real daemon is unobtainable on this host — pkgs.tailscale.com
and proxy.golang.org are both outside the sandbox's egress allowlist, and
Tailscale ships no binaries on GitHub releases. TestAgainstARealDaemon closes that
gap and skips unless a real tailscale is installed, so it is inert in CI and is
the whole verification on a host that has one.

Gates: 43 tailnet tests + 587 in the surrounding suites, isort, flake8, mypy (826
files), docs-lint, brand-lint. test_beacon.py::TestInstallId fails on this host
before and after this change (fails when run alone on the base commit too).


Stacked on #2102 (the status card). Review that one first; this PR's diff against it is the serve-control half.

@CrysisDeu
CrysisDeu requested a review from a team as a code owner August 7, 2026 22:05
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of 01244a554e66df641bfc044ffec953a14f15918b — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound feature, but the PR body and up's own docstring describe a config-writing design the shipped code deliberately abandoned.

Watch

  • Description ↔ diff drift. The PR body says up "publishes first and records the config only once publishing succeeded" and that "cli_config.set_base_config_key is extracted from config set" — no such symbol exists anywhere in the tree, and the shipped up never writes config: it refuses when dashboard.tailscale.enabled is false. Worse, _tailnet's docstring still claims "it publishes first and only records the config once publishing succeeded", directly contradicting the body below it. A maintainer reading either will re-implement the wrong contract. Update the PR body and the docstring to the check-don't-write design (the guide in docs/guides/remote-and-mobile.md already describes it correctly).
  • Loader destructiveness patched per-command, not at the source. _assert_config_sections_are_objects exists because "load() is itself destructive on a file it cannot parse — its migration write-back rewrites the file". That hazard afflicts every command calling KiroCrewConfig.load(), yet the guard ships only inside _tailnet, built on a fragile annotation-string heuristic (ann.endswith("Config") or ann.startswith("dict")) that duplicates loader knowledge and will drift as fields change. Every other CLI command can still silently rewrite the operator's config.

Suggestions

  • Move the shape guard into read_config_for_update/load() (or make the migration write-back refuse on wrongly-typed sections) as a follow-up, then delete _container_valued_sections from cli_commands.py.

[DESIGN-REVIEWED] 01244a5

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 01244a554e66df641bfc044ffec953a14f15918b and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- src/kiro_crew/cli_commands.py:1876 -- "if not enabled" makes the default configuration require a separate config command, contradicting the stated one-command flow -> Fix: persist the enabled flag after publishing succeeds.
[GPT-REVIEWED] 01244a5

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 01244a554e66df641bfc044ffec953a14f15918b: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 7, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from e4ea192 to 2e4caa3 Compare August 7, 2026 22:21
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA e4ea19275f6445755717907570b2aa52bfbd4a71 → new SHA 2e4caa34.

All five items were legitimate. Two of them were mine getting a property wrong, not
style — dispositions below.

BLOCKING tailnet_serve.py:292down deletes an unrelated HTTPS mapping — FIXED.
Accepted in full, and this one was the sharpest: the docstring claimed the narrow
behaviour ("an unrelated serve mapping the operator set up by hand survives") that
the code did not have. --https 443 off removes whatever is on 443. unpublish
now takes the port and requires positive confirmation from serve_state that 443
is fronting this dashboard.

An undetermined state refuses too, deliberately. This code has never seen a
real tailscale serve status --json, so "could not tell" must not become "go
ahead": wrongly proceeding destroys configuration the operator rebuilds from
memory, wrongly refusing costs one copy-pasted command — which the refusal prints.
ServeState gained configured so the two negatives are distinguishable in
structure rather than by matching on detail: nothing served at all is an
idempotent success, something served that is not ours is a refusal.

BLOCKING cli_commands.py:1648 — non-string dashboard.url crashes every action — FIXED, together with FINDING (--port ignored), by one change: the command now resolves the port through resolve_client_port(None) instead of parse_dashboard_url(cfg.dashboard.url).

That helper already carries the non-str guard (_config_url_port), so this reuses
the existing guard rather than adding a second one that could drift from it. It
also fixes the --port finding properly: it consults the flag, KIROCREW_PORT,
the config URL, and finally the gateway's own run-marker. Worth stating that the
--port case is not hypothetical — this repo's dev hosts run the gateway that way,
so the config-derived port would have published 443 in front of a port nothing is
listening on: a publish that looks fine and 502s.

FINDING cli_commands.py:1707 — a config.local.json override leaves the write ineffective — FIXED.
up now verifies the EFFECTIVE value after writing, not the write. Printing
= true while an overlay still disables it is the same false promise this feature
exists to remove — and the operator's next clue would be a bare 403 from their
phone. The message names the overlay and gives the --local form.

FINDING cli_commands.py:1644 — function-local imports violate top-level-imports — FIXED.
Both moved to module scope; no import cycle (verified by importing kiro_crew.cli).

Tests: +10 (38 in test_tailnet_serve.py, 16 in test_tailnet_cli.py). New classes
pin each fix as a regression — TestWithdrawalSafety (confirmed-ours withdraws,
someone-else's is untouched, undetermined refuses and hands over the manual
command), TestEffectiveValueNotJustTheWrite, TestPortResolution (env port wins;
a non-string URL no longer crashes withdrawal).

Gates on 2e4caa34: 194 passed across the tailnet / governance / spawn-audit /
config-overlay suites, isort, flake8, mypy (826 files), docs-lint, brand-lint.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 7, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from 2e4caa3 to 5383df9 Compare August 7, 2026 22:37
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 01244a554e66df641bfc044ffec953a14f15918b — this comment is updated in place on each push.

Review details

This is a careful, well-reasoned PR. Let me verify the key correctness concern — the port-resolution flow in _tailnet — against the imported helpers, which I've now confirmed. The KIROCREW_PORT path correctly resolves through resolve_client_port(None) (which re-reads the env), the marker path is guarded, and up refuses when no port evidence exists. The corrupt-config guard runs before any load(), and both config files are checked. The governance chokepoint and mount/port ownership narrowing in tailnet_serve.py all fail-safe (refuse on unknown).

No reachable defect survives falsification on the changed lines.

No findings.

[OPUS-REVIEWED] 01244a5

Verdict parsed from the review's SHA-scoped output markers for commit 01244a554e66df641bfc044ffec953a14f15918b.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 01244a554e66df641bfc044ffec953a14f15918b: <one-sentence reason>

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 7, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from 5383df9 to b8d2f7a Compare August 7, 2026 22:40
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 2e4caa34 → new SHA b8d2f7a0. Both blockers accepted and fixed, and
an end-to-end suite added that exercises the real process boundary.

BLOCKING tailnet_serve.py:226 — endpoint-blind detection can delete an unrelated 443 mapping — FIXED.
Correct, and it means the previous round's fix was incomplete: requiring "the
dashboard is served" is not "the dashboard is served on 443", and the gap is
exactly the case named — dashboard on HTTPS 8443, something unrelated on 443.

serve_state now scopes the question with _port_scoped_subtrees, which collects
subtrees whose own dict key names 443 ("443", or any key ending ":443" such as
"desk.<tailnet>.ts.net:443") and searches only those. Kept key-shape-agnostic
rather than key-path-aware on purpose: this build has never seen a real status
document, so hardcoding container names would be a guess. The assumption it does
make — a mapping on 443 records 443 in some key — holds for anything publish
created, since it passes --https=443. When it does not hold the result is
unknown, so withdrawal refuses; the cost is a copy-pasted command, not a deleted
mapping.

BLOCKING cli_config.py:213 — malformed config replaced with defaults — FIXED.
Legitimate, and the pattern is inherited from config set rather than introduced
here — but extracting it into a shared helper is what made it a second caller's
problem, so fixing it is in scope. set_base_config_key now calls
read_config_for_update(config_path()) before load(), and both call sites abort
on ConfigReadError. This repo's own read_config_for_update docstring already
calls the swallow-and-write-back shape a data-loss bug, including the torn-read
case that makes it more than theoretical.

tailnet up reports both facts when it hits this, because both are true: serve IS
published, the setting is NOT recorded, and here is the command to run once the
file is fixed. Reporting only the failure would invite a re-run.

End-to-end coverage (new)

test/test_tailnet_e2e.py — 7 tests, no mocks on the boundary. A real
executable named tailscale is written to disk, discovered by the production
_cli_path, spawned by the production subprocess.run under the production
scrub_env(), and answers over real stdout with real exit codes; the fake persists
serve config, so publish → status → withdraw is a real state machine, and it
records every argv it received.

That covers a class the mocked suites structurally cannot:

  • the argv a real daemon would receive, asserted verbatim;
  • that scrub_env() leaves a usable environment — a scrubber that stripped too
    much passes every mocked test and fails on every real host;
  • exit codes and the daemon's stderr propagating through the CLI's own exit path;
  • the foreign-443 case from this round, end to end: down exits 1 and the foreign
    mapping is still present in the daemon's state afterwards.

TestAgainstARealDaemon closes the remaining gap and skips unless a real
tailscale is installed
, so it is inert in CI and is the whole verification on a
host that has one.

The argv contract is now verified against upstream source, not assumed:
cmd/tailscale/cli/serve_v2.go confirms --bg (:239), --https as a UintVar
so --https=443 parses (:241), serve status --json (:257/:262), and off
as a trailing positional with the target optional (:361).

Still not verified: Tailscale's own runtime behaviour and the real shape of
status --json. A real daemon is unobtainable on this host — pkgs.tailscale.com
and proxy.golang.org are both outside the sandbox's egress allowlist and
Tailscale ships no binaries on GitHub releases — which is why the real-daemon class
exists and skips rather than being quietly omitted.

Gates on b8d2f7a0: 456 passed across the tailnet / governance / config /
spawn-audit suites plus the 7 new E2E, isort, flake8, mypy (826 files), docs-lint.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 7, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from b8d2f7a to ddd1044 Compare August 7, 2026 23:06
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 7, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA b8d2f7a0 → new SHA ddd1044f. Both blockers accepted. One is fixed
by a narrower mechanism than the one suggested; the other by removing the shared
code path entirely rather than changing it.

BLOCKING tailnet_serve.py:269 — mixed port-443 handlers treated as exclusively owned — FIXED, by naming the mount instead of proving exclusivity.

The finding is right, and reading upstream turned up a better fix than verifying
every handler under 443 — plus a second defect neither of us had named.
unsetServe (cmd/tailscale/cli/serve_v2.go:1557-1577) treats an absent
--set-path
as "every mount under this port": it enumerates all handlers and
removes them all, and when there is more than one it prompts interactively
(prompt.YesNo("Are you sure you want to delete N handlers…")) unless --yes is
passed — a prompt this command has no TTY to answer, so the hazard was a hang or an
unexpected stdin read, not only over-deletion.

Withdrawal now passes --set-path=/, which upstream resolves through
WebHandlerExists(svcName, hp, mount) and hands RemoveWebHandler exactly that one
mount. Sibling handlers are therefore never touched, and the multi-handler prompt
cannot trigger because mounts is length 1.

Ownership is narrowed to match what withdrawal actually removes: serve_state now
requires our proxy target at SERVE_MOUNT on SERVE_HTTPS_PORT, via
_mount_subtrees. "Ours is somewhere under 443" was true while a stranger's handler
sat at / — the mount being removed — which is the same shape of error as the
previous round one level deeper. Two tests pin it: ours at /api beside a
stranger's at / reports not ours, and a 443 subtree whose handler map is
unrecognisable reports unknown (so withdrawal refuses).

BLOCKING cli_config.py:242 — the corruption guard and the write snapshot use separate reads — FIXED by not sharing that path at all.

The race is real: read_config_for_update() validates, then KiroCrewConfig.load()
reads again to build the full serialisation, and a truncate between them yields
defaults that get written back.

Rather than change what every config set invocation writes to disk as a side effect
of a tailnet PR, the shared writer was revertedcli_config.py is now
byte-identical to this PR's base — and tailnet up records the setting through its
own _record_tailnet_enabled: one read_config_for_update, one mutation on that same
dict, one write_config_atomically. Nothing is re-read, so there is no window. It
also needs no overlay subtraction, because it never writes a value that came from the
overlay. A non-object dashboard / dashboard.tailscale section is refused rather
than coerced.

test_the_write_payload_never_comes_from_load asserts the property directly by
making KiroCrewConfig.load() raise if called during the write.

One correction, and a pre-existing issue found while testing it

A first attempt at these tests asserted that the file gains no default-filled
sections. That claim is false and the tests were rewritten rather than the code
contorted to satisfy them: KiroCrewConfig.load() performs its own migration
write-back
, so the command's first load() materialises defaults into config.json
independently of this writer — and, for a file that is valid JSON but wrongly typed,
normalises it before any guard here can refuse. That is pre-existing product
behaviour, out of scope for this PR, and worth its own issue: the corrupt-config
protection is weaker than it looks for parseable-but-wrong-typed files, because the
protection runs after a call that has already rewritten them. The ConfigReadError
guard is still correct and still holds for the unparseable case and wherever the
write-back does not happen, and is tested against the writer directly.

Gates on ddd1044f: 470 passed / 3 skipped across the tailnet, governance, config
and spawn-audit suites (including the 7 real-process E2E tests; the 3 skips are the
real-daemon class, which needs an installed tailscale), isort, flake8, mypy (826
files), docs-lint.

Base automatically changed from feat/tailnet-status-card to main August 7, 2026 23:22
@CrysisDeu
CrysisDeu requested a review from a team August 7, 2026 23:22
@github-actions github-actions Bot added merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 7, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from ddd1044 to cb816d8 Compare August 7, 2026 23:25
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 10, 2026
CrysisDeu pushed a commit that referenced this pull request Aug 10, 2026
The Opus lane had a 2.7% blocking rate against GPT's 18% across 73 PRs, and
63 of 73 posted comments contained literally "No findings." A controlled
experiment on this repo found the cause is not the model and not the
architecture -- it is three clauses in the prompt, each independently
sufficient to silence a defect the same model reports 3/3 times without them:
the closed-list reading of the residual defect classes, the certainty
threshold, and "drop the finding if the fix touches untouched code".

Splitting the call while keeping those clauses in the first half did NOT help:
the discovery pass produced zero candidates, so the filter had nothing to
keep. Precision enforcement has to sit downstream of discovery, which is also
what Anthropic's own code-review plugin does (parallel discovery agents, then
a per-candidate validation agent with a confidence floor).

What changes:

* Stage 1 (discovery) generates candidates with generous recall and no
  precision gates. Its output is never posted and gates nothing.
* Stage 2 (validation) is an independent call that re-derives input / call
  path / observable outcome for every candidate from code it opens itself,
  keeps only those it scores >= 80, and only then applies the closed blocking
  list. It keeps `id: review`, so the existing transcript capture, comment
  upsert and fail-closed gate are unchanged.
* The prompts move out of the YAML into .github/review-prompts/ and are shared
  by the same-repo and fork lanes, which previously carried near-duplicate
  copies. They are materialised from the BASE commit, so a PR can neither
  weaken the rules that govern it nor rewrite the prompt that reviews it; a
  missing prompt fails the job rather than degrading into an unspecified
  review that could look clean.
* Candidates cross the stage boundary as a workspace FILE, never string
  interpolation -- model output must not reach YAML or a shell argument, and a
  file has no arg-length ceiling.
* The fix-scope rule changes behaviour instead of being deleted: a finding
  whose only remedy lies outside the changed lines is reported as advisory
  rather than dropped, because the author cannot land the remedy here but the
  signal is still real. A regression the diff itself introduces still blocks,
  since reverting the hunk is an in-diff fix.

Job name, required check, tool surface and the marker contract are unchanged.
The fork lane keeps its no-shell posture (its diff is pre-fetched from
GitHub's compare endpoint).

Measured locally on the same corpus, running these exact prompt files:

* 8 negative-control runs (PRs where BOTH production lanes reviewed the same
  SHA and both emitted zero findings): 33 candidates in, 0 BLOCKING out, 7
  advisory out. The validator removed 79%. No run would have turned the
  required check red on a PR two reviewers passed.
* Positive controls (a GPT blocking the old Opus lane missed): recovered on
  #2109 (blocking, same guard GPT named, 2/2) and reported on #2169
  (advisory, 4/4, where the old lane said nothing). #2152's GPT finding was
  generated by discovery but dropped by the validator.

Three tests that asserted on prompt text follow it to its new home rather
than being deleted, and TestOpusTwoStageArchitecture locks the split in place
-- including a cross-check that the marker the gate greps equals the marker
the validation prompt is told to emit, because a typo either side fails every
PR closed and silently.

Known and deliberately not addressed here: the two stages are sequential, so
end-to-end latency roughly doubles under the same 90-minute runaway backstop.
That number can only be measured on a real CI run.

Stacked on the prompt-file bootstrap PR. Because the extraction reads the base
commit, the prompts have to exist on main before this can run -- and for the
same reason, a future PR that EDITS a prompt is still reviewed by the OLD one.
The workflow header documents that.
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (7304bb0e) and carrying the round-6 fixes that had not
been pushed. New head 276d11d7; the branch was CONFLICTING before this and is
MERGEABLE now.

What main changed under this PR, and what it required here. Phase 3
(identity-pinned tailnet sessions — trust_identity / allowed_logins /
pin_scope, resolved through tailscale whois) landed while this branch was
parked. Two consequences:

  • tailnet.py gained _run_json_detail as the function that actually spawns, and
    the BENIGN_SPAWNS entry moved with it. The rebase conflict was resolved as a
    union: main's _run_json_detail plus this PR's tailnet_serve.py::_run.
  • _cli_path gained _posix_candidate_trusted — a planted-binary defence that
    refuses any candidate the gateway user can write. That is the right control (with
    identity resolution, the CLI is executed on the auth path) and it broke the E2E
    suite
    , whose whole design is a real executable on disk. An unprivileged test
    cannot place a binary somewhere the test user cannot write.

The E2E fixture therefore patches _posix_candidate_trusted as a second deliberate
seam. Because that switches off a security control, TestTheSeamIsDeliberate
asserts the guard really does refuse this fake when unpatched — so the bypass is
demonstrably deliberate and cannot decay into "that guard does nothing" after a
future refactor. Both seams (candidate list, trust check) are documented in the
module docstring with the reason an env-var escape hatch would be wrong.

Round-6 fixes now in the tree (previously unpushed)

Undetermined serve state no longer permits destructive replacement. publish
proceeds only when the mount is explicitly free or already ours. The earlier
configured is True keying reasoned that a daemon which cannot answer a status read
would fail the write too — false for a timeout, since the status read has a 5s
ceiling and the write 15s, so a slow daemon times out the read, accepts the write,
and replaces an existing handler. no_cli is now determined before the occupancy
guard so a missing binary is not reported as "could not confirm the mount is free".

The malformed-config window in this command is closed. up validates the raw
file before anything calls KiroCrewConfig.load(), whose migration write-back would
otherwise normalise — and therefore silently rewrite — a config that is valid JSON
but wrongly typed. Applied to up only: refusing to report state or to withdraw
an exposure because the config file is malformed would be the worse failure, and
withdrawal in particular has to stay possible. Side effect worth naming: the failure
is now atomic — nothing published, nothing written — where it previously published
first and only then discovered it could not record the setting.

Two tests changed rather than being preserved, because the contract changed: the one
asserting an undetermined state proceeds was inverted, and the failure-mode
harness now answers serve status successfully while failing only the write (a
single mock answering every invocation identically is not how a daemon behaves, and
that unrealistic harness is what made the permissive guard look correct).

Gates on 276d11d7: 231 passed / 3 skipped across the tailnet, governance,
spawn-audit and config-overlay suites, isort, flake8, mypy (863 files), docs-lint.
The 3 skips are the real-daemon E2E class, which needs an installed tailscale.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from 276d11d to 314c65d Compare August 10, 2026 09:01
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 276d11d7 → new SHA 314c65d5. The finding is accepted, but not
by the suggested remedy — the race is closed properly instead.

BLOCKING cli_commands.py:1754 — a concurrent save can be overwritten — FIXED.

The finding is correct: the caller-side fingerprint re-check leaves a residual
window between the check and the rename. The suggested fix ("remove the automatic
write and require a separate config update") would remove the feature's whole point
kirocrew tailnet up exists because publishing and trusting the origin are two
steps that are useless apart, and doing only one leaves either a bare 403 or nothing
serving. So the window is closed rather than the convenience dropped.

The lock had to go inside write_config_atomically, not into this caller. An
advisory lock is mutual exclusion only when every writer takes it, and there are
29 config write sites across 14 modules (config set, the telemetry toggle, the
dashboard PATCH routes, memory, agents, mcp, security, apps, slack, setup…). A lock
adopted only here would have protected nothing against the very scenario named — a
dashboard save — while looking like a fix. Putting it in the single function they all
already call gives every one of them mutual exclusion with zero caller migration.

write_config_atomically now:

  • takes a cross-process advisory lock (fcntl.flock / msvcrt.locking) on a sidecar
    <name>.lock — sidecar because tmp+rename replaces the config inode and with it
    any lock held on that inode, so a lock taken there would be released by the very
    write it protects;
  • accepts an opt-in expect_fingerprint, which re-checks the content hash inside
    the same lock as the rename
    . That is what a caller-side check cannot do however
    carefully placed, which is exactly the defect reported here;
  • never makes an existing caller worse. No expect_fingerprint → identical
    behaviour, just serialised, and a lock that cannot be acquired proceeds anyway
    (a stuck lock file must not be able to wedge a settings write, and this runs from
    async handlers). Opting in fails closed on a lost lock, since the guarantee
    cannot be honoured without it.

ConfigChangedError is deliberately not a ConfigReadError subclass, so
_tailnet's handler was widened to catch it — otherwise a retry-exhausted collision
would have escaped as a traceback with the dashboard already published, which is
the same shape of defect flagged two rounds ago.

This closes #2147 for write_config_atomically's callers; that issue is updated.

Tests

test/test_config_write_lock.py (13 tests) covers the primitive, including the two
contracts that differ: opting out stays last-writer-wins and must not fail when
locking is unavailable, opting in fails closed. Also pinned: a same-length
change is still detected (the (mtime, size) fingerprint this replaced misses two
writes of equal length inside one timestamp tick), expect_fingerprint=None asserts
absence (distinct from not opting in — hence the sentinel default), the lock is on a
sidecar not the config inode, mode preservation survives, and a
multi-process contention test proves serialisation, which an in-process lock
could not deliver.

test_config_rmw_preserves_settings.py::test_leaves_no_temp_files_behind was
updated rather than worked around: the sidecar .lock is durable infrastructure, not
scratch (unlinking it after release would race a process holding the unlinked inode),
so the assertion now names it while still failing on anything tmp-shaped.

Regression surface

Because this changes a function 29 call sites share, the config-adjacent suites were
run wide: 9423 passed, isort, flake8, mypy (863 files). The only two failures are
test_terminal_commands.py::TestSanitizedPath::test_a_system_directory_is_trusted_without_any_config
and TestRunProbe::test_the_environment_is_an_allowlist_not_a_filtered_inherit,
both verified red on a pristine origin/main checkout on this host — pre-existing
and unrelated.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from 314c65d to d892b30 Compare August 10, 2026 09:19
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 314c65d5 → new SHA d892b30f. Four items, all accepted. GPT and
Opus independently landed on the same first blocking finding
, which is worth
recording: two lanes converging on it is why it gets treated as the serious one
rather than a style call.

BLOCKING — loader.py — the lock's time.sleep retry runs on the gateway event loop — FIXED.

Both reviewers are right, and this one was self-inflicted in the sharpest possible
way: the lock I added last round busy-waited with time.sleep(0.02) up to a 5s
timeout, inside a function that api_update_auto (updates.py:694) and
api_memory_settings (memory.py:149) call directly on the loop, unoffloaded. A
separate process holding the sidecar lock (kirocrew config set, tailnet up) would
have frozen every session and the liveness heartbeat — the exact
no-blocking-call-on-event-loop rule that this same function's docstring cites as
its reason for declining to shell out. I added the thing the docstring warns about.

The wait is now taken only off the loop: asyncio.get_running_loop() decides, and
on a loop the lock is attempted once, non-blocking, with a miss proceeding
immediately. Synchronous callers (CLI, background threads) keep the bounded retry.
Two tests hold it, both with the lock genuinely held from another process: an
on-loop plain write returns in well under a second instead of waiting out the
timeout, and an on-loop CAS write fails closed rather than being silently downgraded.

BLOCKING — cli_commands.py — read-only tailnet actions can still rewrite a malformed config — FIXED.

Correct, and it closes a hole in my own earlier reasoning. I had guarded only up,
arguing that refusing to report state or to withdraw over a malformed config
would be the worse failure. That argument had a flaw I missed: both status and
down need the dashboard port, which resolves through
resolve_client_portKiroCrewConfig.load(), whose migration write-back
normalises — and therefore rewrites — a parseable-but-wrongly-typed file. So there
was no version of those paths that read the file without rewriting it, and status
was effectively a write.

All three actions now validate the raw file first. Between rewriting the operator's
config and declining, declining wins — and withdrawal stays achievable because
the refusal prints the exact daemon command
(tailscale serve --https 443 --set-path=/ off). A test asserts that message
specifically, since refusing down is only defensible if the escape hatch is handed
over.

FINDING (Opus) — tailnet_serve.py:416 — unreachable duplicate not_ours return — FIXED.
A verbatim copy-paste artifact from a scripted edit two rounds ago, unreachable after
the return above it. No runtime effect, deleted.

Windows shard 1 — MY TEST was wrong, not the product — FIXED.
test_mode_is_still_preserved asserted 0o600 after os.chmod, which on Windows
only toggles the read-only bit (the mode reads back 0o666), and fchmod_safe is a
documented no-op there. Asserting it cross-platform tested the OS rather than this
change; it is now POSIX-skipped with that reason stated.

Gates on d892b30f: 9430 passed across the config, tailnet, spawn-audit and
handler suites (this touches a function 29 call sites share, so the sweep is wide),
isort, flake8, mypy (863 files), docs-lint. The only two failures are
test_terminal_commands.py::TestSanitizedPath::test_a_system_directory_is_trusted_without_any_config
and TestRunProbe::test_the_environment_is_an_allowlist_not_a_filtered_inherit, both
verified red on a pristine origin/main checkout on this host.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/tailnet-serve-control branch from d892b30 to 43edbb3 Compare August 10, 2026 09:40
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA d892b30f → new SHA 43edbb34. Both blocking findings accepted and
fixed. Opus passed this round.

BLOCKING — loader.py — plain writers bypass the CAS critical section — FIXED.

This one lands squarely on my own argument from two rounds ago. I made the lock
universal by putting it inside the shared writer — then made acquisition optional,
which reopens the hole for exactly the caller named: while a CAS writer held the lock
through its read-check-write, a dashboard save that could not acquire it walked past
and clobbered the result. Mutual exclusion that non-participants may skip is not
mutual exclusion, which is the same thing I said about per-caller adoption.

The lock now yields three states instead of a boolean, because "did not acquire"
was hiding two different situations with opposite correct responses:

State Meaning Response
acquired we hold it proceed
contended the lock works and someone else holds it every writer refuses
unavailable locking itself does not work here (no fcntl/msvcrt, sidecar uncreatable) proceed, exactly as before the lock existed

Refusing on contended is the fix; keeping unavailable permissive is what stops it
becoming "a platform without file locking can no longer save settings". Raising on a
rare collision is the honest failure — the alternative is destroying the other party's
save and reporting success. The trade is stated plainly: an async settings save can now
fail under genuine contention (a window of microseconds to low milliseconds, retryable)
where it previously succeeded by overwriting.

BLOCKING — cli_commands.py — tailnet can publish the wrong local service — FIXED.

Correct, and the consequence is worse than the 502 I had been reasoning about.
resolve_client_port ranks the configured dashboard.url above the run marker —
right for a client that wants the dashboard the operator configured, wrong for an
exposure decision. If the configured port was occupied and the gateway moved
(--port), that port now belongs to some other local service, and publishing in
front of it puts that service on the tailnet.

So this command resolves evidence before intent: _marker_port() first, which only
reports a port where a verified KiroCrew gateway process is actually listening (and
refuses when several are up), falling back to resolve_client_port otherwise. An
explicit --port / KIROCREW_PORT still outranks both — that is the operator naming
the target directly. resolve_client_port is left untouched, so token / status /
logout / stop keep their existing precedence; only the publish path reorders it,
because only the publish path is choosing what to expose.

Three tests: the marker outranks the configured URL, an explicit env port outranks the
marker, and a marker-discovery failure falls back instead of breaking the command.

Tests changed rather than preserved

Four assertions in test_config_write_lock.py encoded the old contract (plain writes
proceed on contention) and were rewritten, not patched around — the contract changed,
so they had to. The on-loop test now asserts both properties at once, since either
one alone is a defect: it returns in well under a second (no blocking sleep on the
loop) and it refuses rather than clobbering the holder's content.

Regression surface

10681 passed, isort, flake8, mypy (863 files), docs-lint. This widens the failure
mode of a function 29 call sites share, so the sweep covered config, telemetry,
allowlist, setup, agents, mcp, memory, security, apps, slack, interactions, chat and
updates — no caller regressed.

Windows shard 4 is not this branch.
test_turn_duration_subagent.py::test_provider_reported_duration_wins_over_local_clock
has zero references to write_config_atomically / config_path /
KiroCrewConfig (grep count 0), is not in this diff, and asserts a record count
(len(records) == 1, got 2) with no config involvement. Recent main CI runs
alternate success/failure, consistent with a flake on main. The two long-standing
test_terminal_commands.py failures remain verified red on a pristine origin/main
checkout on this host.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 43edbb34 → new SHA 5c7f53b4. Both blocking findings accepted, plus
the brand gate and two Windows failures — one of which was a regression I introduced
while editing tests.

BLOCKING — cli_commands.py — publish can still expose an unrelated loopback service — FIXED.

Correct, and it names the gap the marker-first change left: when discovery cannot
decide — an unreadable run marker, or several gateways up (where _marker_port
deliberately refuses) — the fallback is the configured port, which is exactly the port
that may now belong to a different local service if the gateway moved off it.

kirocrew tailnet {status,up,down} gains --port, and it outranks everything
including the marker and KIROCREW_PORT: an operator naming the target should not be
overridden by a heuristic. Precedence is now explicit flag → env → verified run
marker → configured URL, with a test per level.

BLOCKING — loader.py — lock contention escapes existing write-error handling — FIXED.

ConfigChangedError now derives from OSError. The reasoning is the part worth
keeping: callers around the repo already wrap config writes in except OSError
because a write can fail for ordinary filesystem reasons, so a fresh Exception
subclass sailed past every one of them and turned a rare, retryable collision into an
aborted Slack action or a 500. Modelling it as what it is — a failed write — routes it
into handling that already exists. A test asserts pytest.raises(OSError) catches it,
so the base class cannot be quietly narrowed later.

Brand Name Gate — FIXED. One prose KiroCrewKiro Crew in a comment I wrote
last round (cli_commands.py:1792). Verified against added lines only: the remaining
13 occurrences are all KiroCrewConfig, an identifier the gate exempts.

Windows shard 1 — one regression of mine, one bad test design

test_mode_is_still_preserved failed again despite being skipped last round: the
@pytest.mark.skipif was deleted by my own edit. A slice-based patch script
replaced the region ending at the def line, and the decorator sits above the def,
so it fell inside the replaced slice. Second time this session a patch-script slice
boundary has silently eaten code (the duplicate not_ours block was the first).
Restored, and the commit content was verified by grep rather than by intent.

The two event-loop tests failed on Windows for a better reason: they simulated
contention with a second process, which measured Windows byte-range msvcrt
locking semantics rather than this code's branch, non-deterministically. Rewritten to
simulate at the lock backend — a stub whose lock call always refuses — which is
exact and cross-platform. Real cross-process serialisation is still proven separately
by TestCrossProcessSerialisation, which is what a second process is actually good
for.

One test was added while rewriting them, because the split it verifies was otherwise
untested in one direction: test_the_sync_path_still_waits asserts a synchronous
caller does take the bounded retry. Without it, "only wait off the loop" would be
indistinguishable from "never wait at all", and a CLI writer bailing on the first tick
would look correct.

Gates on 5c7f53b4: 10684 passed, isort, flake8, mypy (863 files), docs-lint,
brand (added lines clean). The two test_terminal_commands.py failures remain verified
red on a pristine origin/main checkout on this host.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 5c7f53b4 → new SHA d14c2983. Both findings are accepted as correct,
and the response is to remove the code they are about from this PR
rather than fix
them here. config/loader.py is now byte-identical to main.

Why the scope narrowed instead

Both findings this round were about the config-locking primitive I added, not the
tailnet feature — and that has now happened five rounds running, each time on a defect
the previous round's fix introduced:

Round Finding on the primitive
1 caller-side fingerprint check leaves a check→rename window
2 time.sleep retry blocks the gateway event loop (GPT and Opus, independently)
3 non-CAS writers walk past the critical section
4 contention escapes existing except OSError handlers
5 CAS proceeds unlocked when locking is unavailable; optional imports inside a function

The deciding evidence arrived this round: the E2E (stub ACP backend, offline) job
passed on four consecutive SHAs of this PR — including three that already carried the
lock — and went red on 5c7f53b4,
where the only semantic change was
ConfigChangedError deriving from OSError. The failing spec is
ops-mission-control.spec.ts › primary-instance toggle persists, a persistence
spec, in a PR with zero website/ files changed. Making the refusal catchable by
generic except OSError handlers — which finding 4 asked for, correctly in isolation —
changes control flow in every one of those handlers, and finding 4 and that E2E result
now point in opposite directions.

That is two review findings in genuine tension, resolvable only by a judgement about
whether a config-write collision should surface to the user or be absorbed — and that
judgement belongs with the ~29 writers and their async-caller migration, not with a
tailnet PR.

So _record_tailnet_enabled now has exactly the same semantics as its 29 siblings:
a plain read-modify-write, last-writer-wins. The original lost-update finding therefore
returns, and I am not claiming otherwise — it applies equally to config set, the
telemetry toggle and every dashboard PATCH route, none of which does better. #2147 is
updated with the full trilemma (refuse breaks legitimate concurrent writers · proceed
loses updates · wait blocks the loop unless async callers are migrated to
asyncio.to_thread) and with the two non-obvious traps found while building it:
(mtime, size) is not a sufficient fingerprint, and a lock on the config inode is
released by the very tmp+rename it guards.

What this PR still guarantees

Everything tailnet-specific is unchanged and still covered:

  • publish and withdraw both refuse rather than clobber a foreign 443/ handler, with
    --set-path so upstream removes only our mount and never prompts;
  • port resolution is evidence-first (explicit --port → env → verified run marker →
    configured URL), so publishing cannot front an unrelated local service;
  • every action validates the raw config before anything calls KiroCrewConfig.load(),
    whose migration write-back would otherwise rewrite a wrongly-typed file just by
    reading it — and the failure is atomic: nothing published, nothing written;
  • a corrupt config still aborts rather than being replaced by defaults, and a
    non-object section is refused, not coerced;
  • the real-process E2E suite (fake tailscale on disk, production _cli_path /
    subprocess.run / scrub_env) still covers the publish → status → withdraw state
    machine, with TestTheSeamIsDeliberate proving the planted-binary guard it bypasses
    is live.

Also rebased onto adbec83b: main had moved again and the previous head's diff was
reverting 407 lines of rfc-perpetual-agent.md revision 2 — someone else's work.
Caught before pushing.

Gates on d14c2983: 9269 passed, isort, flake8, mypy (863 files), docs-lint. Diff
is 9 files, all tailnet or its docs/tests. The two test_terminal_commands.py failures
remain verified red on a pristine origin/main checkout on this host.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA d14c2983 → new SHA 1a3a9d9a. All three findings verified against the
code and fixed
. config/loader.py stays byte-identical to main.

1. Unverified port fallback could expose an unrelated service — FIXED

Correct, and the sharper of the three. resolve_client_port falls back to the
configured dashboard.url (or the built-in default) whether or not anything answers
there, and tailscale serve publishes whatever holds the port. So with the gateway
down — or moved after its configured port was taken — up would put an unrelated
loopback service on the tailnet, for every device on it.

up now requires evidence and refuses otherwise: explicit --port, KIROCREW_PORT,
or a run marker (which only reports a port a KiroCrew gateway is actually listening on).
status and down still accept the fallback — one only reports, the other checks mount
ownership before removing anything.

The test that previously asserted the fallback published now asserts it refuses:
its old premise was the hazard, not the safe default.

2. Status could overwrite malformed configuration — FIXED

Correct: the guard only covered dashboard and dashboard.tailscale, so your exact case
({"slack": 5}) walked past it into KiroCrewConfig.load(), whose migration write-back
rewrites the file — a read-only command destroying Slack settings. Guarding only the
sections this command writes was the wrong shape, because the destructive write comes
from load() and covers all of them.

The guarded set is now derived from dataclasses.fields(KiroCrewConfig) rather than
hardcoded, so a section added later is covered without anyone remembering to edit it — with
a test asserting the derived set covers every object-valued model field.

3. Concurrent config updates silently lost — FIXED, and this time in the right place

Your suggested fix was "reject an intervening config change", and that is now done —
confined to this command instead of in the shared helper. That distinction is the
whole reason the previous attempt failed:

previous attempt now
where inside write_config_atomically inside _record_tailnet_enabled
blast radius ~29 call sites, 14 modules 1 function
async callers dashboard handlers call it inline → a lock wait blocks the event loop none; kirocrew tailnet is a synchronous one-shot process
error class OSError subclass, so except OSError blocks absorbed it TailnetConfigBusy, deliberately not an OSError — with a test asserting that

The fingerprint is SHA-256 of the bytes ((mtime, size) cannot see two equal-length
writes in one timestamp tick), taken before the read and re-checked before the rename,
inside a sidecar lock — never the config inode, since the atomic write's rename
would drop a lock held there, releasing the very lock meant to guard it.

Against a non-participating writer this detects and refuses rather than clobbering.
Refusing is the right half of the trade here in a way it was not in the shared helper:
this is one interactive command that can be re-run, not a save path the dashboard needs
to keep working. On a refusal after publishing, the existing partial-success message
already says what took effect and what to run by hand.

Verification

Gates on 1a3a9d9a: isort, flake8, mypy (863 files), docs-lint, brand.

On the full suite this host is noisy, so I compared failure sets against a pristine
origin/main worktree run with the same command: 38 failures on this branch, 44 on
main, and comm -23 of the two sets is empty
— every failure here also fails on main
(test_pptx_maker_library, test_terminal_commands::TestRunProbe, test_artifact_source,
test_beacon, …). Nothing is unique to this branch. The counts drift run to run (41 then
38), which is itself the signal that this set is environmental rather than a real
regression.

New tests: up refuses an unverified port and publishes nothing; every object-valued
section survives all three actions; the derived guard set covers the model; an intervening
same-length write is detected; TailnetConfigBusy is not an OSError; the lock is a
sidecar.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 1a3a9d9a → new SHA 21f64846. Two findings fixed, one rebutted,
one open pending a product decision (stated plainly below rather than papered over).

FIXED — {"registries": 5} crashed every tailnet action

Correct, and a shape I had missed entirely. The guard derived its section list from
dataclasses.fields(KiroCrewConfig) but only collected dataclass and dict fields;
registries is list[ExternalRegistryConfig], so a scalar walked past the guard and the
loader iterated it — an uncaught TypeError, not a clean refusal.

The derivation now covers both container shapes and records which one each section
expects: a nested-config or mapping field must be an object, a list[...] field must be
an array. Tests: a scalar registries is refused with exit 1 (not a traceback) on all
three actions with the file unchanged, plus a test asserting the derived shape map agrees
with every model field, so a section added later cannot silently fall outside it.

FIXED — importlib.import_module inside _optional violated top-level-imports

Fixed with real module-level import statements, branched on the platform:

if sys.platform == "win32":
    import msvcrt
    fcntl = None
else:
    import fcntl
    msvcrt = None

For the record on why this took three attempts: a guarded try: import at module scope
makes every later import an E402 and gets the bare imports relocated by isort, which is
what pushed the resolution into a function in the first place; importlib in a function
then violated this rule. Putting a platform branch after the entire import section
satisfies both — verified by exit code (isort --check-only 0, flake8 -j 1 0,
mypy 0), not by reading output.

REBUTTED — "serve mutations race their ownership checks → remove publish and unpublish"

The race is real and I am not disputing it; the proposed remedy removes the feature.

tailscale serve exposes no conditional or compare-and-swap set — there is no
--if-unchanged, no generation token, no transactional mode. So no wrapper of it can
close this window; the gap is in the upstream CLI's contract, not in this code. What the
code does do is check ownership at the mount immediately before mutating, which bounds
the exposure to another tailscale serve landing inside that same instant on the same
machine, by another operator or script.

"Remove the automatic publish and unpublish mutations" would leave kirocrew tailnet
unable to publish or withdraw anything — that is the entire feature, and the PR's reason
to exist. Accepting an unclosable-by-construction microsecond window is the strictly
better trade, and it is the same trade every wrapper around a mutating CLI makes.
Recorded as a documented limitation rather than silently.

OPEN — the config lost-update window (4th round on this item)

This one I am not resolving unilaterally, because every remaining resolution changes
user-visible behaviour, and the round history shows why:

round what was tried outcome
1 caller-side fingerprint check flagged: check→rename window
2 lock + CAS inside write_config_atomically flagged: blocking sleep on the gateway event loop
3 tri-state lock flagged: CAS proceeding unlocked
4 reverted the primitive; plain write flagged: lost update returns
5 (now) lock + digest re-check local to this command flagged again: window between re-check and rename

The current code takes a sidecar lock, hashes the file's bytes before the read, and
re-checks that digest immediately before the write. Against another kirocrew tailnet
that is exclusion. Against the dashboard, which does not take this lock, a residual
window between the re-check and the rename remains, and no caller-side construct can
remove it — only a lock that every writer takes, which is the ~29-site repo-wide change
tracked in #2147 and which was already demonstrated (round 2/3) to break the gateway when
forced into this PR.

The three ways out are a product call, not a code call:

  1. Accept it — identical semantics to the other 29 config writers, including
    config set. Needs a writer's /ai-review override.
  2. Drop the automatic write (the requested fix) — up publishes, then prints the
    kirocrew config set dashboard.tailscale.enabled true command. Closes this finding
    permanently, and returns the flow to the two steps this PR set out to collapse into one.
  3. Route the write through the running gateway so one process is the only writer.
    up already requires a live gateway since the port fix, so the precondition holds —
    but it is a real redesign and only serialises if the gateway's own handler does.

Asked the requester to choose. Nothing is being smuggled through in the meantime: the
behaviour on this SHA is option 1's, and it is stated here rather than implied.

Gates on 21f64846, all by exit code: isort 0, flake8 0, mypy 0 (863 files), docs-lint 0,
tailnet suites 144 passed / 3 skipped. config/loader.py still byte-identical to main.
E2E (stub ACP backend, offline) passed on 1a3a9d9a, which closes out the earlier
E2E red — it was the ConfigChangedError(OSError) change in the shared loader, and
reverting that fixed it.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 21f64846 → new SHA c2ab6d66. The lost-update finding is fixed by
removing the write entirely
, which is neither of the two options I put to the requester
last round and is better than both.

What changed

up now checks dashboard.tailscale.enabled and refuses when it is off, instead of
setting it. _record_tailnet_enabled, TailnetConfigBusy, _tailnet_config_lock,
_config_bytes_digest and the fcntl/msvcrt imports are all gone; cli_commands.py
no longer imports write_config_atomically at all. This PR now performs zero config
writes
up, down and status all only read.

Three reasons this is the right shape rather than a capitulation:

  1. The refusal moved to before the mutation. The old ordering published first and
    wrote after, so a failed write left a published-but-untrusted dashboard — reachable
    on the tailnet and answering 403, which is exactly the confusing state this command
    exists to remove. Now a host that would 403 never gets published.
  2. The cost is paid once per machine, not per run. The flag is one-time setup. After
    the operator enables it, kirocrew tailnet up is a single command forever — so the
    feature's value (publishing was an undocumented command you had to know and type) is
    intact.
  3. No caller-side construction could have closed the window. A fingerprint check, a
    lock plus CAS inside the shared writer, and a lock plus digest local to this command
    were each tried and each left a gap or broke something else (the shared-writer version
    blocked the gateway event loop and regressed an unrelated dashboard E2E).

One correction to the record, found by my own test

I wrote a test asserting the config file is byte-identical after each action. It
failed
, and it was right to: KiroCrewConfig.load() performs a one-time migration
write-back that materialises every default key — measured directly, a 98-byte
hand-written config becomes 9187 bytes on first load. That happens for kirocrew status,
config get and every other command in this repo; it is not something this feature does
or can prevent, and a second load() does not rewrite.

So the honest claim is narrower than "nothing is written", and that is what the test now
asserts: no action changes any value the operator chose, including the trust flag.
I would rather state that precisely than let a stronger sentence stand.

It also means removing my write did not take the command's total write count to zero —
it took it from two writers to one, and removed the one that wrote a semantic change.
The residual load() write-back carries the same read-modify-write race for every command
in the repo, which is #2147's territory, not this feature's.

Tests

  • TestUpNeverWritesConfig — no action changes an operator value; the removed helpers
    are asserted absent, so a future edit cannot quietly reintroduce the write path; a
    disabled flag refuses with zero publish calls.
  • TestTheEffectiveValueIsWhatGates — an overlay in config.local.json that disables the
    flag refuses before publishing and names the file, instead of the old behaviour of
    discovering it after a successful publish.
  • TestUpOrdering — a failed publish and a governance-pinned host both leave the flag
    exactly as the operator set it.
  • Fixtures for both the CLI and the real-process E2E suites now start from an enabled
    host, because that is the precondition of up.

Docs updated: the guide shows the one-time config set alongside tailnet up, and states
why the flag is checked rather than written and why the port is never guessed.

Gates on c2ab6d66, all by exit code: isort 0, flake8 0, mypy 0 (863 files), docs-lint 0,
brand clean, tailnet + spawn-audit suites 149 passed / 3 skipped. config/loader.py
remains byte-identical to main. Wide suite: 41 failures, all in the host-environmental
set also failing on a pristine origin/main run; the two that looked branch-unique
(test_dev_fleet_app, another TestRunProbe member) pass in isolation twice and this
branch changes no file either touches.

KiroCrew never ran `tailscale serve` anywhere. The config switch made the gateway
*trust* the tailnet origin; putting the dashboard on the tailnet was a command the
operator had to know and type. Two independent changes are required and either one
alone is a dead end — publish without trust and every request is refused by the
Origin check with a bare 403, trust without publish and there is nothing listening
— so the documented flow was three commands, one of them undocumented in the UI.

Adds `kirocrew tailnet {up,down,status}`.

`up` publishes first and records the config only once publishing succeeded. The
reverse order would leave a host claiming tailnet access is on with nothing serving
it, and the operator's next clue would be a 403 from another device. It prints the
URL to open, and says a restart is needed *unconditionally* — including when the
switch was already on, because the origin is resolved once at gateway startup.

`status` reports the three things that are independently required: whether the
setting is on, whether a MagicDNS name resolves right now, and whether serve is
actually pointing at this dashboard. Any one of them being wrong looks identical
from the user's chair, so a single on/off line would not be diagnostic. The live
name read is correct here and would be wrong in `GET /api/tailnet/status`: this
command reports what the machine can do next, the endpoint reports what the running
server already trusts.

`down` stops publishing and deliberately leaves the config alone — the trusted
origin is unreachable without serve, so clearing it would be an unrequested second
change that also demanded a restart to undo a withdrawal that took effect at once.

New module rather than an addition to `dashboard/tailnet.py`

`tailnet.py`'s documented contract is the opposite of what a write path needs: it
swallows every failure so the gateway boots on a host that has never heard of
Tailscale. Here a failure is the point of the call — `tailscale serve` refuses for
reasons the operator can act on and cannot guess, most often because changing serve
config needs root or an `--operator` grant. `tailnet_serve.py` therefore returns a
`ServeResult(ok, code, detail)` and **always passes the daemon's own stderr
through verbatim**: `code` is a best-effort classification for the UI to branch on,
`detail` is what Tailscale actually said. Upstream owns that wording, so a wrong
classification must still leave the operator with the real reason.

Published-state detection does not read the JSON schema. This host has no
Tailscale, so the shape of `tailscale serve status --json` is unverified here.
Rather than guess key paths, `serve_state` searches the parsed document for a proxy
target naming our own port, and an unrecognisable document reports `unknown` rather
than `not published` — reporting "not published" for a published node is the
checked-but-never-ran defect in a new costume.

Governance

`publish` is a fourth chokepoint on `capabilities.tailnet_origin`, checked before
the spawn: a pinned fleet forbids putting this host's dashboard on a tailnet, and
refusing after publishing would be theatre.

`unpublish` is deliberately NOT gated, and the asymmetry is load-bearing.
`is_governance_pinned_off` returns true both for a real policy deny and for a
ceiling it could not evaluate, so gating withdrawal would mean a transient
policy-read failure leaves a dashboard published on a tailnet with no supported way
to take it down — a fail-closed control failing open in effect. Same direction that
lets a config write of `false` through while `true` is refused.

Spawn hardening is shared with the read path by import, not copied: the binary
comes from `_cli_path`'s vetted absolute allowlist and never from `PATH`, and the
child gets `sandbox.scrub_env()`. Registered in `BENIGN_SPAWNS` with the reason it
is not routed through `sandboxed_spawn_argv` — the call's whole purpose is to mutate
the local daemon's serve config through its unix socket, which is precisely the
ambient authority a sandbox removes.

Config write

`tailnet up` records the setting through its own small writer
(`_record_tailnet_enabled`) rather than sharing `config set`'s path, and
`config set` is left byte-identical. An earlier revision did extract a shared
writer; it was reverted because `config set` reads the file TWICE on the way to a
write -- validate, then `KiroCrewConfig.load()` to build the full serialisation it
persists -- and another writer truncating between those reads makes the second one
observe defaults, which are then written over everything the user has. Sharing that
path meant inheriting the race or changing what every `config set` invocation writes
to disk.

The private writer is one read, one mutation, one atomic write:
`read_config_for_update` (`{}` for an absent file, raises `ConfigReadError` for a
present-but-unreadable one), the value set on that same dict, `write_config_atomically`.
A non-object `dashboard` / `dashboard.tailscale` section is refused rather than
coerced -- replacing it with `{}` would discard the operator's data and still report
success.

`up` then verifies the EFFECTIVE value rather than the write, because
`config.local.json` takes precedence: a host whose overlay disables this has just
had a successful write that changes nothing on restart, and printing "= true" there
is the false promise this feature exists to remove.

Docs

The guide's Tailscale section leads with `kirocrew tailnet up`, keeps the manual
three-command form as the fallback, and states the root/`--operator` requirement.
The governance spec's chokepoint table goes from three rows to four and records the
withdrawal asymmetry.

Not verified against a real Tailscale daemon — no Tailscale on this host. What that
leaves unverified is narrow and stated in the code: the exact argv is asserted, the
failure classification is best-effort with the real stderr always passed through,
and published-state detection is schema-agnostic by design.

Gates: 43 tailnet tests + 587 in the surrounding suites, isort, flake8, mypy (826
files), docs-lint, brand-lint. `test_beacon.py::TestInstallId` fails on this host
before and after this change (fails when run alone on the base commit too).
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA c2ab6d66 → new SHA 01244a55. Blocking finding fixed; the
non-blocking FINDING rebutted with the round history.

FIXED — the overlay bypassed the shape guard

Correct, and a real hole in my own fix from two rounds ago. I validated
config_path() and stopped there, but load() merges config.local.json over the
base file, so a wrongly-typed section in the overlay reaches the loader just as surely —
config set --local registries 5 is enough — and a scalar where a list is expected gets
iterated into a traceback. Guarding one of two inputs left the overlay as an open door to
exactly the failure the guard exists to prevent.

Both files are now validated before anything calls load(), and the error names the
file that is actually broken
rather than always the base one, because sending the
operator to edit a healthy config.json is its own bug. A missing overlay stays fine —
absence is not malformation.

Tests: every action refuses on a malformed overlay for both registries (list-typed,
the iteration crash) and slack (object-typed) with the overlay left untouched; the
message names config.local.json; and an absent overlay is not treated as an error.

REBUTTED — "persist the enabled setting after publishing succeeds"

This is the write path you blocked on three consecutive SHAs:

SHA your verdict on that write
d14c2983 BLOCKING — "Concurrent config updates are silently lost … Revert this write path"
1a3a9d9a BLOCKING — "Digest check leaves a lost-update window … Revert this private write path"
21f64846 BLOCKING — same, after a lock + content-digest attempt

Reinstating it would reopen a blocker that took five rounds to close, so I am not making
that change on the strength of a non-blocking FINDING. The three constructions available
to a second process — a caller-side fingerprint, a lock plus compare-and-swap inside the
shared writer, and a lock plus digest local to this command — were each tried; the
shared-writer one also blocked the gateway event loop and regressed an unrelated
dashboard E2E. Closing the window needs one primitive that all ~29 config writers
take, which is #2147.

On the premise itself — "contrary to the stated one-command flow" — that flow is no
longer stated. docs/guides/remote-and-mobile.md was updated in this same change to show
the one-time config set alongside tailnet up, and to say why the flag is checked
rather than written. So the documented contract and the code agree.

Two things worth keeping in view, since the trade is deliberate rather than free:

  • The one-time step is once per machine, not per invocation. After it, tailnet up
    is a single command permanently, and the thing this PR set out to remove — publishing
    being an undocumented command you had to know and type — is gone either way.
  • Exiting early is the safer ordering, not just the cheaper one. The version that
    wrote after publishing left a published-but-untrusted dashboard whenever the write
    failed: reachable on the tailnet and answering 403. Refusing before publishing cannot
    produce that state.

Gates on 01244a55, all by exit code: isort 0, flake8 0, mypy 0 (863 files), docs-lint 0,
tailnet suites 148 passed / 3 skipped. config/loader.py remains byte-identical to
main. Wide suite: 42 failures, all present in a pristine origin/main run on this host;
the one that looked branch-unique is another member of the TestRunProbe family, which
fails a different member on each run — this branch touches no file it exercises.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants