Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/harden-trust-and-input-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"devctl": patch
---

A cloned repo's devservers.json can no longer start itself. devctl's rule is that it never acts on a project's committed config until you approve it by running a server there once, but boot restore skipped that check: after a reboot it would bring back a committed server for a project that was never approved. It now honors the same gate every other path does, so an unapproved project's config stays inert until you start it by hand. An explicit `start`, `ensure`, or `up` still records that approval, exactly as before.

`devctl register` now screens a server the same way the config file is screened. Registering a server directly was the one way into the daemon that skipped validation, so a spec `devctl config check` would reject (an out-of-range port, an empty command, a name containing the reserved `::`) could still be registered and then started. It is refused up front now.

`devctl switch` validates the branch's devservers.json before running that branch's lifecycle commands. A config the daemon would refuse to load no longer has its commands handed to the shell anyway, and an empty lifecycle command is now caught by `config check`.

A bad `--grep` pattern can no longer hang the daemon. A regular expression that nests one unbounded repeat inside another (the classic `(a+)+`) makes the engine run for minutes on a single log line; `devctl logs --grep` now rejects that shape before it runs, with a message that names the fix.

devctl no longer hangs waiting on a stuck daemon. A wedged daemon used to leave `devctl` and the menu bar app blocked with no output and no way out; requests now fail in bounded time and point you at `devctl daemon restart`, while a legitimately long `ensure`, `wait`, or group rollout is given the room it needs.

Editing a project's config through the menu bar can only write to a project devctl already tracks, closing a path where a crafted request could have dropped a devservers.json anywhere on disk.
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ Identity and stack
- Three products, one daemon: devctld owns all server processes; devctl (CLI) and devctl.app (SwiftUI MenuBarExtra) are thin clients over a unix socket (default ~/Library/Application Support/devctl/daemon.sock; DEVCTL_SOCKET overrides; /tmp fallback near the sun_path limit), NDJSON protocol. devctl daemon install/uninstall/start/stop/restart manage the LaunchAgent (dev.quantizor.devctl); tests and the smoke gate run devctld --foreground.

Codebase map
- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus, whose displayPort is the one home for which of the three port fields a human is shown), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, SHA-256 over CryptoKit with a chunked file-digest entry point so hashing a file costs a chunk of memory rather than the file; agent.path holds login-shell PATH for the daemon), Setup/ (SetupPlanner: first-run / upgrade decisions, harness offers, stage-and-rename binary install, and CLIOwner: whether devctl or Homebrew owns the CLI, decided by realpath-matching the running bundle against the Caskroom backlink rather than any `/Caskroom/` substring, which drives skipping the binary install and the PATH warning under brew; AppInstancePolicy decides which of two copies of one bundle quits at launch, scoped to the bundle path so the DMG-to-Applications handoff, the one case where two copies are correct, is left alone), Agent/ (AgentContext: the pure session-context renderer the hook injects, bad-state servers first with a devctl why recommendation and devctl's own stderr count, never raw child output; DiscoveryStanza), Net/LoopbackProbe.swift (dual-stack loopback listen probe shared by the daemon port pre-check and CLI doctor), Net/PortClaim.swift + PortMaterializer.swift (effectivePort claim: portSpan and named ports; env injection and URL rewrite, including resolving a root-relative head or healthcheck url against the server's own base), Config/ (ProjectConfig loader and validator; ConfigProjection projects merged specs back down to devservers.json, dropping everything the machine derived, for `config init`; EffectiveHost is the one home for the host a spawn will use, read by both prepareSpawn and config check; LocalOverlay; LockResource reads locks declarations and resolves a resource's state path; WatchPolicy is the pure settle/quiet/burst decision behind auto-restart and WatchPaths resolves the entries config check warns about), Resource/ResourceIdentity.swift (bounded fingerprint of a lock resource's state so `lock` can report a change made under a live holder), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Update/ (UpdateCheck: GitHub releases/latest against DevCtlVersion, one on-disk cache with an ETag shared by the app poll and `devctl doctor`, every failure silent, and never fed into AgentContext.render; DevCtlDistribution: the one home for the tap token, releases URL, and brew upgrade/uninstall commands), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests).
- Sources/DevCtlDaemonCore: daemon logic as a library. Supervisor/ (ServerSupervisor actor per server: spawn, spool capture, health-gated phase machine, ensure/wait, group + descendant teardown with ProcessIdentity start-time revalidation; the ProcessLauncher seam; ProcessTree QA1123 sysctl sweep, plus narrowed/isAlive, the one home for turning a pid read off disk or the wire into one the kernel calls take, since a trapping conversion there is a crash loop under KeepAlive), Health/HealthProber.swift (EffectiveHealthcheck resolution, the HealthProber seam with ephemeral URLSession HTTP probes + BSD TCP, and PortGuard's lsof diagnostics, which live in that same file), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + persisted resource locks with daemon-owned pause/resume and dead-holder auto-release + the boot-restore gate that answers daemon.info and refuses everything else with daemon-starting + NWListener ControlServer whose startAccepting awaits the listener's ready state and throws rather than suspending forever).
- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus, whose displayPort is the one home for which of the three port fields a human is shown; ServerSpec.validationErrors is the per-spec check the `register` seam runs so a directly-registered spec is screened like a committed one), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app; a SO_RCVTIMEO response deadline, raised for a command carrying its own timeout, so a wedged daemon fails a request instead of hanging the client forever), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, SHA-256 over CryptoKit with a chunked file-digest entry point so hashing a file costs a chunk of memory rather than the file; agent.path holds login-shell PATH for the daemon), Setup/ (SetupPlanner: first-run / upgrade decisions, harness offers, stage-and-rename binary install, and CLIOwner: whether devctl or Homebrew owns the CLI, decided by realpath-matching the running bundle against the Caskroom backlink rather than any `/Caskroom/` substring, which drives skipping the binary install and the PATH warning under brew; AppInstancePolicy decides which of two copies of one bundle quits at launch, scoped to the bundle path so the DMG-to-Applications handoff, the one case where two copies are correct, is left alone), Agent/ (AgentContext: the pure session-context renderer the hook injects, bad-state servers first with a devctl why recommendation and devctl's own stderr count, never raw child output; DiscoveryStanza), Net/LoopbackProbe.swift (dual-stack loopback listen probe shared by the daemon port pre-check and CLI doctor), Net/PortClaim.swift + PortMaterializer.swift (effectivePort claim: portSpan and named ports; env injection and URL rewrite, including resolving a root-relative head or healthcheck url against the server's own base), Config/ (ProjectConfig loader and validator; ConfigProjection projects merged specs back down to devservers.json, dropping everything the machine derived, for `config init`; EffectiveHost is the one home for the host a spawn will use, read by both prepareSpawn and config check; LocalOverlay; LockResource reads locks declarations and resolves a resource's state path; WatchPolicy is the pure settle/quiet/burst decision behind auto-restart and WatchPaths resolves the entries config check warns about), Resource/ResourceIdentity.swift (bounded fingerprint of a lock resource's state so `lock` can report a change made under a live holder), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Update/ (UpdateCheck: GitHub releases/latest against DevCtlVersion, one on-disk cache with an ETag shared by the app poll and `devctl doctor`, every failure silent, and never fed into AgentContext.render; DevCtlDistribution: the one home for the tap token, releases URL, and brew upgrade/uninstall commands), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests).
- Sources/DevCtlDaemonCore: daemon logic as a library. Supervisor/ (ServerSupervisor actor per server: spawn, spool capture, health-gated phase machine, ensure/wait, group + descendant teardown with ProcessIdentity start-time revalidation; the ProcessLauncher seam; ProcessTree QA1123 sysctl sweep, plus narrowed/isAlive, the one home for turning a pid read off disk or the wire into one the kernel calls take, since a trapping conversion there is a crash loop under KeepAlive), Health/HealthProber.swift (EffectiveHealthcheck resolution, the HealthProber seam with ephemeral URLSession HTTP probes + BSD TCP, and PortGuard's lsof diagnostics, which live in that same file), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + persisted resource locks with daemon-owned pause/resume and dead-holder auto-release + the boot-restore gate that answers daemon.info and refuses everything else with daemon-starting + NWListener ControlServer whose startAccepting awaits the listener's ready state and throws rather than suspending forever). prepareSpawn is the one funnel every start-shaped path takes and the one home for the trust gate: its `userInitiated` flag records trust for an explicit command acting on a committed server and refuses an autonomous restore/sweep of an unapproved project. register validates the spec and writeConfig refuses a project the daemon does not track; every project-scoped arm canonicalizes the path at the decode seam.
- Sources/devctld: thin main; identical behavior under launchd and --foreground (tests and the smoke gate use foreground). Applies agent.path into process env before spawn, accepts on the socket before boot restore and marks the router restoring across it so a client can tell a busy daemon from a dead one, and runs the watch sweep on its own timer once restore has finished, so a boot spawn is never read as a config change.
- Tests: DevCtlKitTests is the unit-test target of record and holds the schema goldens; DevCtlDaemonCoreTests drives a real Router over temp paths; DevCtlCLITests covers CLI behavior with a contract and no other way to exercise it (argument parsing, the lock notices and identity verdict), importing the executable target with @testable. TestSupport.swift is the one home for the fixture-server lookup and reserves ports 45000 to 45500 for the unit suites; touching it reaps fixtures orphaned by an interrupted run, but only those whose parent is gone and whose port is in that block, so a concurrent test run and smoke.sh (which orphans a fixture on purpose, outside the block) are both left alone.
- Sources/devctl: CLI (swift-argument-parser). Two files only: HookSupport.swift (HookContext, the thin socket fetch over DevCtlKit's AgentContext renderer, + HarnessAdapter registry, each adapter with install/uninstall/hookState over a settings file devctl does not own and never edits without being asked; adding a harness: CONTRIBUTING.md) and CLI.swift, which holds every command as a struct, including Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Doctor (health report; owns the cross-project port-collision and squatter findings, plus report-only harness-hook and update findings), Uninstall (the one uninstall verb: agent, hooks, and CLI, with --agent-only for the cask and --purge for data; `daemon uninstall` is a deprecated alias warning on stderr), HookInstall/HookUninstall, and Link / x-url (deep links). CLI.swift is past the size where splitting is worth asking about.
Expand All @@ -35,7 +35,8 @@ Hard rules
- Wire methods are typed end to end: the daemon sniffs the {id, method} head, then re-decodes the full typed frame. A new method extends WireMethod plus Codable params/result types in Wire.swift; no untyped dictionaries on the wire.
- Every CLI command supports --json with a stable schema generated from the shared Codable types; failures emit {ok:false, error:{code,message,hint}} on stdout, hint being the literal remediation command. Error codes grow append-only. Golden tests in Tests/DevCtlKitTests assert exact schema strings; a changed field is an API change: update docs/cli-contract.md in the same commit, then the golden.
- Structured log files keep per-file monotonic timestamps (clamp on append); the since-query binary search depends on it.
- The daemon never acts on a project's committed config before trust is recorded; the SessionStart hook never emits raw log lines or command strings into agent context (child output is attacker-influenceable).
- The daemon never acts on a project's committed config before trust is recorded, enforced in prepareSpawn: an explicit command records trust, an autonomous restore or watch sweep refuses an unapproved project. The SessionStart hook never emits raw log lines or command strings into agent context (child output is attacker-influenceable). A spec reaching the daemon through `register` is validated like a committed one, and writeConfig only writes for a project the daemon already tracks.
- A user-supplied regex (`logs --grep`) is screened before it runs: a nested unbounded quantifier is refused, because Swift's backtracking engine turns `(a+)+` into minutes of CPU on a single line and the match runs on the log actor.
- State files load defensively: parse failure quarantines to .corrupt-<timestamp> and continues; never fatal (a startup parse crash under launchd KeepAlive loops forever). Corollary: new fields on persisted types (registry, state) stay optional so existing files keep parsing.
- Registry/state writes are temp + fsync + rename.
- Binary upgrades stage and rename(2); never overwrite a running signed Mach-O.
Expand Down
Loading
Loading