mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
fix/opencode-session-header
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
90014210d2 |
fix(daemon): publish status files atomically (#949)
* fix(daemon): publish status files atomically * fix(daemon): bind status publication to directory handle * fix(daemon): validate status directory access * fix(daemon): accept current token directory owner * fix(daemon): migrate owned runtime directories safely * fix(daemon): open Windows security handle relatively * fix(daemon): use current NT directory object * fix(observability): harden existing crash directories * fix(security): bind crash report creation to private root * fix(observability): atomically publish crash reports * fix(observability): preserve committed crash reports * fix(observability): revalidate committed crash path * fix(daemon): secure runtime lifecycle boundaries * fix(daemon): bind status ownership through cleanup * fix(daemon): preserve Windows status security * fix(daemon): retain trusted runtime root for lifecycle * fix(daemon): roll back redirected socket binds * fix(daemon): address status publication review |
||
|
|
9c292092a2 |
fix(daemon): clean up child after startup timeout (#774)
* fix(daemon): clean up child after startup timeout * fix(daemon): terminate startup process tree * test(background): accept reaped-later zombie children * fix(background): retain configured process group target * fix(background): address termination review feedback * fix(daemon): use launch-time group identity, fix zombie/reap classification Addresses jatmn's three #774 review findings: - TerminateCommand rediscovered the process group via execution.TerminateProcessTree's Getpgid(pid) lookup. On Darwin, that lookup can return ESRCH once an unreaped group leader has already exited, even though live descendants remain in the group it configured at launch — silently falling back to signalling only the (already dead) leader PID and leaving descendants running (the required macOS smoke job's failure). Add execution.TerminateProcessGroup, which signals the negative PID directly using the launch-time Setpgid invariant instead of rediscovering it, and route TerminateCommand through it. - signalTargetRunning's zombie check matched the individual-PID fallback target (a process that is not its own group leader) against the process table by PGID. That target's actual PGID differs from its own PID by definition, so no row ever matched, unknown resulted every time, and a zombie individual-PID target was always conservatively reported as still running. Branch on the sign of the target: group members by PGID, individual PIDs by PID. - TerminateCommand returned the termination error even when the subsequent reap succeeded — a successful cmd.Wait is authoritative proof the leader is gone and collected, regardless of how the kill attempt itself went (e.g. taskkill /T racing an already-exited PID on Windows). Classify a successful reap as success. All three regression tests are verified to fail against the pre-fix code (confirmed by temporarily reverting each fix and rerunning). Co-Authored-By: Claude Sonnet 5 <[email protected]> * fix(daemon): harden command termination inputs Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-abb8-73de-9489-51e9e6514c6b Co-authored-by: Pierre Bruno <[email protected]> * fix(background): preserve process tree failures Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8 Co-authored-by: Pierre Bruno <[email protected]> * docs(daemon): clarify boundary-probe and Setsid-guard comments per PR #774 review Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Claude Sonnet 5 <[email protected]> Co-authored-by: Amp <[email protected]> Co-authored-by: Cursor <[email protected]> |
||
|
|
c38444157c |
Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune (#216)
* tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019)
The mouse classification helpers checked both the current Button/Action pair and
the deprecated msg.Type / tea.Mouse* constants. Bubble Tea's parser always
populates Button+Action and only DERIVES Type from them, so the Type fallbacks
were pure redundancy (a left-button drag is Action==Motion, already covered by
mouseMotion). Drop them — clears all 11 SA1019 findings in internal/tui with no
behavior change. The left-drag test now asserts the Action==Motion path directly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(SA1019 0)/
deadcode(no new) all pass.
* notify: wire the webhook sink behind an opt-in env var
The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.
Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
- reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
- no-op when the URL is unset/blank, so it is safe to call unconditionally
- sourcing the URL from the environment keeps the secret token out of any
on-disk config file
Attached at both notifier construction sites:
- headless exec: failed deliveries log to stderr (never stdout); the sink
redacts before logging
- TUI: logf=nil so a delivery failure can't corrupt the alt-screen
The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.
deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.
* swarm: add opt-in recurring-spawn scheduler (wakeup + daily)
Adds a dependency-free Scheduler to internal/swarm so a member can be spawned on
a recurring schedule, plus a swarm_schedule tool to drive it.
- Schedule is interval-based ("wakeup", every >= 1s) with an optional first
delay and a MaxRuns cap; a daily "HH:MM" ("cron") time is expressed as a 24h
interval whose first delay lands on the next occurrence.
- Each fire spawns a fresh member through the existing Swarm.Spawn path, so
members inherit the recorded policy and are bounded by the team slot cap +
queue. Non-overlapping by default: a fire is skipped while the job's previous
spawn is still running, preventing queue pile-up.
- Scheduler is lazily created (Swarm.Scheduler()), opt-in (nothing runs until a
job is added), and tied to the Swarm's base context — Swarm.Close() stops the
scheduler first so no spawn fires during shutdown.
- swarm_schedule tool: action add|list|cancel; prompt-gated like swarm_spawn;
add validates schedule + agent type up front (fail fast).
The timing loop takes an injectable ticker, so the tests drive fires
deterministically with no real time (fire/skip/cap/cancel/close, plus pure
nextDailyDelay/parseClock/swarmInt). All swarm tests pass 3x under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass.
* daemon/remote: SessionLink + git-bundle upload over the bridge
Adds an opt-in way to ship a local repo's git history to a remote daemon and
link a remote working tree to it — without a shared filesystem or a network git
remote. The daemon core (protocol/server) is untouched; all changes live in the
remote package plus a CLI hook.
Wire:
- The auth handshake gains an optional Mode field ("" / "session" => a daemon
session as before; "bundle" => a one-shot git-bundle upload). Unknown modes,
or bundle mode when uploads are disabled, are denied (fail closed).
- Bundle transfer reuses the daemon frame codec: a header frame (link id +
exact size), then the bundle bytes as KindData frames, then a result frame.
Server (Bridge):
- BridgeOptions.BundleDir enables uploads (empty => refused). A received bundle
is size-capped (default 256 MiB), staged to a temp file, `git bundle verify`-d,
then `git clone`-d into a staging dir and atomically renamed over
<BundleDir>/<link-id>. Link ids are sanitized to a single path component and
containment is re-checked, so an upload can never escape the bundle dir.
Client:
- UploadRepoBundle creates `git bundle create --all` of a work tree, hashes it
(sha256), streams it over an authenticated bundle-mode TLS connection, and
returns a SessionLink {address, link id, remote path, bundle sha}. SessionLink
persists as a 0600 atomic JSON file (token never stored) with Save/Load/Validate.
CLI:
- `daemon serve-remote --bundle-dir <d>` enables uploads.
- `daemon link --remote <addr> --repo <dir> --id <name> [--out <file>]` uploads
and prints the remote path (+ a ready run command); `daemon link --show <file>`
prints a saved link.
Tests: git bundle create/verify/clone roundtrip; link-id sanitization + path
containment; SessionLink save/load/validate (0600); and a full bridge upload E2E
over real TLS (extract + content check + re-upload-replaces + disabled + bad
token). All remote tests pass 2x under -race; CLI smoke verified via the binary.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* mcp: unify OAuth token storage onto the shared oauth store
MCP server tokens lived in their own mcp-oauth-tokens.json with a bespoke
codec/lock, separate from the provider-login store (internal/oauth). The oauth
store already reserved the "mcp:" key namespace for exactly this. Unify them.
- mcp.TokenStore now delegates to internal/oauth.Store, keying each server's
token as "mcp:<server>". Its public API (StoredToken/TokenStatus/Save/Load/
Delete/Status/FilePath) is unchanged, so callers (network client, CLI) are
untouched. The bespoke read/write/lock code is removed in favor of the shared,
cross-process-locked, atomic store.
- Transparent, non-destructive migration on first construction: a legacy
mcp-oauth-tokens.json is imported into the unified store (only entries not
already present — a newer unified token is never overwritten), then renamed to
a ".migrated" backup so it is imported at most once and stays recoverable.
A missing / unreadable / unknown-schema legacy file is left untouched and
never blocks startup. An explicit FilePath suppresses the default-path
migration so tests/overrides can't touch the real user config.
- Server names that can't form a valid namespaced key (oauth.ValidateKey) are
rejected on save and skipped during migration. Added oauth.Store.FilePath().
The token format was already identical (access/refresh/type/scopes/expires), so
this is a key rename, not a format change. Existing TokenStore tests pass
unchanged (they exercise the API); added migration + namespacing + newer-wins
tests. Local run check: `zero mcp oauth status` migrates a seeded legacy file
into oauth-tokens.json under mcp:demo, renames the legacy to .migrated, and leaks
no token material.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(clean)/
govulncheck(0)/deadcode(no new) all pass.
* oauth: add an opt-in OS keyring storage backend
Introduces a pluggable storage backend for the unified oauth token store and a
dependency-free OS keyring implementation, selected with
ZERO_OAUTH_STORAGE=keyring (default stays the 0600 file).
- internal/keyring: a small, third-party-dep-free secret store backed by the OS
tooling — `security` on macOS, `secret-tool` (libsecret) on Linux; other
platforms report unsupported. On Linux the secret is passed via stdin (never
the argv); on macOS `security` takes it as an argument (briefly visible in the
process list) — documented, and a reason to prefer the file backend there.
A runner seam makes the per-OS command logic fully testable without a real
keychain.
- internal/oauth: Store now persists its JSON blob through a blobStore backend.
fileBlob preserves the exact prior behavior (0600, atomic write, cross-process
lock file). keyringBlob stores the blob as one base64 entry (keeps multi-line
JSON a single control-char-free value) via a KeyringClient. NewStore resolves
the backend from StoreOptions.Storage or ZERO_OAUTH_STORAGE; unknown storage
and an unavailable keyring fail closed with a clear error.
Because both `zero auth` and the (now unified) MCP token store construct their
store through oauth.NewStore, the keyring backend is honored by both with no
call-site changes. Added oauth.Store.FilePath() returns a "keyring:" identifier
for that backend.
No new module dependency (go.mod unchanged). Tests: keyring round-trip/not-found/
unsupported/missing-binary/stdin-not-argv via an injected runner; store keyring
round-trip + base64-at-rest + storage selection; all existing file-backend tests
pass unchanged. Local run check (macOS): validated the exact `security`
add/find/delete shapes and the not-found exit code (44) against the real binary,
and `mcp oauth status` with ZERO_OAUTH_STORAGE=keyring reads the real keychain
cleanly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* all: prune unused unexported functions (dead-code sweep)
Removes 15 unexported functions/types/consts that are unreachable even from
tests (every one flagged by staticcheck U1000), clearing the U1000 noise the v16
audit surfaced. Conservative by design: only unexported, test-unused symbols are
removed — exported internal API (which may be an intentional surface or used only
by tests) is deliberately left in place.
Removed:
- agenteval: validateExpectedChangedFiles
- providers/gemini: const providerName
- tui: stripMarkdownInline, removeTrailingAtToken,
noopModelSwitchCompactionPolicy (+ BeforeModelSwitch), deleteComposerLineBefore,
deleteComposerLineAfter, deleteCompletedFileMentionBefore, mcpViewStatus,
mcpServerLines, fitCommandOutput, (*commandPicker).clearQuery,
tuiTheme.onPanel2, indentText
No behavior change (the symbols had no callers). staticcheck U1000 is now empty;
deadcode drops accordingly with no genuinely-new unreachable functions (verified
by a name-based diff vs origin/main). gofmt/vet/build(host+linux+windows)/full
test all pass.
* Address review feedback on the audit backlog
Resolve CodeRabbit review comments on this PR:
- keyring isNotFound: match the specific not-found exit codes (macOS `security`
44 = errSecItemNotFound; `secret-tool` 1) instead of treating any non-zero
exit as "no entry", so a real failure (locked keychain, permissions) surfaces
instead of being masked as absent.
- swarm scheduler daily_at: recompute each fire as the next local HH:MM rather
than adding a fixed 24h, so a wall-clock daily schedule does not drift across
DST. Adds Daily/Hour/Minute to Schedule and a clock to the Scheduler.
- swarm scheduler: reject Add when the scheduler context is already canceled
(not just when closed); re-check cancellation after a tick so a tick+cancel
race can't spawn one extra member after Cancel/Close.
- swarm schedule_tool swarmInt: reject non-integer / non-finite JSON numbers
(max_runs=1.9 errors instead of silently truncating to 1).
- mcp migrateLegacy: resolve a relative LegacyPath to absolute before the
same-file guard so it can't be bypassed by a relative spelling.
- oauth keyring store: serialize the keyring read-modify-write with a
cross-process lock file (best-effort, beside the config dir) so concurrent
processes don't clobber each other's tokens; previously withLock was a no-op.
Adds tests for the non-not-found keyring error, the daily delay recompute, the
Add-after-cancel guard, and non-integer swarmInt.
* swarm: make nextDailyDelay roll the day, not add 24h (DST)
CodeRabbit follow-up: the run loop already recomputes a daily job's delay each
cycle, but nextDailyDelay itself rolled "passed today" to the next occurrence
with Add(24*time.Hour). On a DST changeover day (23h/25h) that fires an hour
early/late. Use time.Date(..., day+1, hour, minute, ...) so Go normalizes the
date and applies the correct local offset, holding the wall-clock HH:MM across
DST. Adds an America/New_York spring-forward test (skips if tzdata is absent).
|
||
|
|
f857c4afb4 |
feat(daemon): TLS remote bridge — authenticated network access to the daemon (#211)
* feat(daemon): TLS remote bridge — authenticated network access to the daemon Add internal/daemon/remote: an OPT-IN, TLS-only network bridge on top of the local daemon. A remote client drives the SAME SessionManager/Pool over the network behind bearer-token auth, reusing the daemon's framing and dispatch unchanged. The local Unix-socket daemon stays the default and is unchanged. - Bridge: per accepted TLS connection, authenticate THEN hand to the daemon's control dispatch via the new Server.ServeConn seam — a remote session runs under the same sandbox/risk model as a local one (never bypasses it). - Authenticator + TokenAuthenticator: constant-time bearer-token check, token from $ZERO_DAEMON_REMOTE_TOKEN or a file; optional attestation hook (no-op default). Auth happens before any control frame; failure closes the connection after a small backoff; tokens are never logged. - RemoteClient (DialRemote): verified TLS dial (never InsecureSkipVerify) + auth handshake, then the existing daemon.Client via the new NewClientConn seam, so remote and local share one protocol. - Fail-closed: TLS mandatory (refuses without cert/key), a configurable min-protocol-version floor, the 1 MiB frame cap reused, and a bounded concurrent-connection cap that refuses overflow. - CLI: `zero daemon serve-remote --addr --tls-cert --tls-key`; `run`/`attach` gain `--remote <addr> [--token --ca-cert --server-name]`. Daemon seams added (additive, behavior unchanged): Server.ServeConn, NewClientConn. SessionLink (event POST) and git-bundle repo transfer are documented follow-ups. Tests cover auth accept/reject (env + file), TLS-required, cert verification, version floor, run/status/attach round-trip, re-attach resumption, connection cap, and ListenAndServeTLS/Close. Gates: gofmt / vet / build (host+linux+ windows) / test -race / staticcheck (no new) / govulncheck (0) / deadcode (no new) all pass. * swarm: de-flake TestMailboxConcurrentSends on Windows CI The 200-way concurrent-send stress test inherited from the swarm package timed out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms retry sleep starves waiters when Windows file ops are slow under heavy contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and raise the test's lock timeout to 60s so a legitimately-slow CI never times a send out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on #210's Windows smoke; functionally unrelated to the OAuth changes.) * remote: address CodeRabbit re-review (TLS dial + Close idempotency) - client: replace the deprecated, non-cancellation-aware tls.DialWithDialer with a context-aware tls.Dialer.DialContext (resolves the golangci-lint noctx CI blocker; the context bounds the dial + TLS handshake). - bridge: Close now clears b.listener under the lock, so a repeat Close is a no-op instead of returning a closed-listener error (matches its doc). Test asserts a double Close succeeds. Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/ govulncheck(0)/deadcode(no new) all pass. |
||
|
|
135e3c5657 |
feat(daemon): zero daemon — worker pool + session supervisor over a local control socket (#203)
* feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running local service that supervises a pool of headless `zero exec` workers and routes multiple agent sessions to them over an owner-only Unix-domain control socket. Additive — interactive and one-shot exec are unchanged. Reuses zero's building blocks: - internal/background: ConfigureChildProcessGroup (process-group setup) + TerminateProcess (cross-platform terminate) for worker kill/drain. The pool does its own os/exec and uses background only for those helpers. - internal/streamjson: the worker speaks stream-json on stdout; the daemon forwards those lines to clients. - internal/cli exec: a worker is `zero exec --output-format stream-json`. Pieces (reimplemented in Go from a reference daemon): - protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap, JSON control messages, version negotiation. - pool.go + backoff.go: bounded worker pool — at-least-once dispatch with exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/ EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is one-shot, so workers are on-demand (one session per worker = the lease), not warm long-lived slots (a persistent worker mode is a follow-up). - session.go: SessionManager — sessionID->worker routing, lease (one active request per worker), queue when the pool is full, per-session output buffer for attach + metrics. - server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop; cleans up lock/socket/status on exit. - launcher.go: production worker launcher. client.go: control-socket client. Security model: - Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the per-user profile ACL; a dedicated pipe ACL is a follow-up). - Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker re-establishes its OWN sandbox instead of inheriting a pass-through plan, while propagating the rest of the policy config/env. The daemon never bypasses the sandbox. - Control frames over the 1 MiB cap are rejected before allocation. Robustness: a crashed worker never takes down the daemon (backoff+jitter retry, MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then force-kills stragglers; a stale lock from an unclean exit is reclaimed. Out of scope (follow-up): remote session-linking; warm pre-spawned persistent workers; Windows named-pipe ACL. Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds; pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/ attach; lock single-instance + stale recovery; launcher env-scrub (incl. an end-to-end check that the spawned worker process receives scrubbed env); server e2e start->run->stream-json->status->attach->stop with a fake worker; CLI usage/validation/not-running paths. Live-verified start/status/stop (0600 socket, 0700 dir, file cleanup). Platform files behind explicit //go:build tags. * daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions - cli/app.go: list `daemon` in `zero --help` so the command is discoverable. - cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than one session id (fail fast on typos instead of silently ignoring tokens). - daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would leave a malformed lock file that another process reads as stale and wrongly reclaims, breaking the single-instance guarantee; on write failure remove the file and return the error. - daemon/session: bound the registry with MaxSessions (default 256) — evict the oldest FINISHED sessions once past the cap so a long-running daemon doesn't accumulate sessions without limit; running/queued sessions are never evicted, so attach-after-finish for recent sessions is preserved. Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted. Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0), deadcode (no new), GOOS=linux+windows cross-compile. |