390 Commits

Author SHA1 Message Date
46fba794f3 Merge pull request 'bugfix-and-rewrite' (#59) from bugfix-and-rewrite into dev
Reviewed-on: #59
2026-07-13 08:00:00 +00:00
0fd561726e Finish sandbox-to-capsule ID rename and clean up leftover stub files
Move client-IP parsing into a shared pkg/netutil helper used by both
auth handlers and session middleware, update remaining sb-/wrenn-sb-
references to the cl-/wrenn-cl- naming, and remove a few empty
placeholder files left over from earlier package splits. Also updates
docs and the default listen port.
2026-07-12 21:31:10 +06:00
01fd1ddc81 Harden user-facing inputs and channel webhooks against abuse
- Validate emails and display names (users, API keys, OAuth signups) against a strict allowlist so stored values can't carry HTML/script payloads
- Block notification channel webhooks from targeting private/internal/metadata addresses (SSRF), with an opt-out env var for self-hosted setups that need it
- Bound concurrent channel delivery retries so a sustained outage can't spawn unbounded goroutines
- Fail closed when the admin check can't reach the database, instead of quietly denying a legitimate admin
- Remove envd's dead auth middleware (unused, envd relies on VM isolation not per-request auth) and drop unused username plumbing from the filesystem service
2026-07-12 18:31:53 +06:00
bb7725c323 Drop legacy raw-JWT credentials file format
Host credentials file has been JSON for a while; old hosts on the
raw-JWT format will just re-register.
2026-07-12 17:24:31 +06:00
b255042496 Fix audit log misattributing failed capsule startups to system
Failed create/resume now logs as a separate "error" entry instead of
overwriting who actually created the capsule.
2026-07-12 16:58:47 +06:00
7d782daed3 Switch API error responses to structured error codes
Backend handlers now return errors through a shared apperr catalog
instead of ad-hoc strings, so every error has a stable code, a
human message, and a request ID. The frontend API client understands
this new error shape and surfaces the code/request ID to callers so
error messages (and support requests) are more useful.
2026-07-12 16:38:59 +06:00
31e77f8f6a Fix capsule showing running instead of starting on launch
The dashboard jumped a launching capsule straight to running, skipping
the starting state. The event stream was treating the request-accepted
event the same as the boot-complete event and forcing a running status.
Now only true completion events set the final status, so transient
states show correctly.
2026-07-12 08:53:34 +06:00
e58a17bf03 Make file operations run as sandbox default user, not root
File reads and writes forced username=root when talking to envd, while
process execution ran as the sandbox default user. Drop the hardcoded
user so file ops resolve the same default user as exec.
2026-07-12 07:05:41 +06:00
a53b28e35e Updated image creation scripts 2026-07-12 06:51:19 +06:00
0c698d8518 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-07-11 19:23:51 +06:00
a6c804e679 Revoke API keys and cached sessions when a team member is removed
Removing someone from a team used to leave two credentials live: any
team-scoped API keys they'd created, and their cached session still
holding the old team ID. Now RemoveMember deletes those keys and
invalidates the session cache, and session rehydration clears a stale
team binding if the membership is gone.
2026-07-11 19:21:02 +06:00
3372e2f30a Harden envd process spawning and guest-to-host access
- Reject non-IP addresses before writing to /etc/hosts, closing a
  newline-injection path into the hosts file.
- Check setgroups/setgid/setuid return values when dropping privileges
  for spawned processes, so a failed privilege drop aborts the spawn
  instead of silently running the command as root.
- Block capsules from reaching services bound on the host itself
  (control plane, host agent, SSH) while still allowing replies to
  host-initiated connections like envd health checks.
2026-07-11 19:20:56 +06:00
5b955167e8 Block capsule traffic from leaking to private IP ranges or other capsules
Adds host-level iptables rules on host agent startup that stop sandbox
traffic from escaping onto the private network or reaching another
sandbox directly, plus gitignores local tokensave tool state.
2026-07-11 17:40:28 +06:00
f7c9350b18 Fix unbounded blocking in process stdin/PTY writes
Input to a sandbox process could hang a thread forever when the process
stopped reading its stdin — a stuck or malicious guest could pin the
whole blocking pool and degrade the agent.

Now stdin and PTY writes use non-blocking fds: normal input completes
immediately, and a process that isn't draining its input causes the
write to fail with an "input buffer full" error after a short timeout
instead of pinning a thread.
2026-07-07 21:29:12 +06:00
e34c9e1abe Harden envd concurrency and fix CoW loop device leaks
envd (0.6.0):
- Run blocking syscalls (sync/drop_caches, directory walks and
  removals, stdin/pty writes) on the blocking pool so health checks
  and exec streams don't stall during snapshot quiesce or large-tree
  operations
- Stop writing sandbox metadata into envd's own environment at init;
  it was undefined behavior with the async runtime running
- Retry a memory preload that previously failed or was cancelled
  instead of blocking every future pause until the next init
- Match RAM ranges against kernel core segments on page boundaries so
  preload no longer rejects kernels with unaligned low-memory ranges
- Tolerate a concurrent request creating a directory first

host agent:
- Detach the copy-on-write loop device when snapshot removal fails so
  deleting its backing file can no longer orphan it permanently
- Clean the sandbox directory on pause rollback, and skip paused
  restore for directories still owned by a live VM that failed to
  re-attach
2026-07-07 20:48:39 +06:00
7e1ce86413 fix(sandbox): prevent memory corruption in lazy-restore snapshots
Snapshotting or pausing a sandbox restored with
memory_restore_mode=ondemand silently zeroed all never-faulted guest
memory: envd's preload walked the vmalloc region instead of physical
RAM (/proc/kcore exposes one PT_LOAD per RAM range, never one segment
covering all of RAM), so no pages were materialised and ch.snapshot
wrote holes that read back as zeros on the next restore.

- match kcore segments to /proc/iomem ranges via a derived direct-map
  base; hard-error instead of walking the wrong segment
- guard preload state with a lifecycle generation so a loader thread
  frozen across pause/resume cannot publish a stale "done"
- verify preload against envd before pause/snapshot: self-heal from
  idle, hard-fail on failure; persist the lazy-restore flag so the
  guard survives host-agent restarts and re-attach
- fix loop refcount double-release on pause-then-destroy that detached
  the shared base image under sibling sandboxes
- release runtime resources when a VM dies mid-pause
- detach CoW loops before deleting their backing files; never release
  a live CH process's slot or loop on a failed re-attach

Guest images must be rebuilt (scripts/update-minimal-rootfs.sh);
snapshots taken before this fix carry the old envd and stay unsafe.
2026-07-07 17:27:36 +06:00
da9cdb4ba6 Reuse existing loop device for same backing file
Prevents loop device leaks when a later process re-attaches a sandbox
created by an earlier one.
2026-07-07 05:47:07 +06:00
f838d84a51 Raise default capsule specs to 2 vCPU / 2048 MB
New capsules and template builds now default to 2 vCPUs and 2 GB RAM
instead of 1 vCPU and 512 MB when specs aren't given.
2026-07-06 18:37:15 +06:00
067a6b1c9d Move host-agent runtime packages to pkg/ for external reuse
Relocate the sandbox runtime (sandbox, vm, network, devicemapper,
envdclient, layout, models) from internal/ to pkg/ so external
consumers like the wr CLI can embed the orchestration layer without
a control plane. Add cross-process re-attach support for running
sandboxes (state files, restore_running) and bump agent/cp versions.
2026-07-03 03:54:46 +06:00
5a1db550f0 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-06-26 03:49:24 +06:00
bfac42aabd fix: show 'starting' state in dashboard when capsule is being created
Publish a transient state_changed SSE event right after inserting the
sandbox, so all dashboard subscribers see "starting" before the
background goroutine finishes and flips it to "running". The frontend
now also adds unknown capsules to the list on capsule.state.changed
events, not only capsule.create.
2026-06-26 03:47:44 +06:00
aa902fdd90 Add healthcheck phase to build console, remove unused Jupyter test script 2026-06-26 02:11:51 +06:00
d721b3fb94 v0.3.0 — update versions, Rust dependencies, and formatting 2026-06-26 02:11:46 +06:00
4bf19f11a9 Merge branch 'dev' of git.omukk.dev:wrenn/wrenn into dev 2026-06-26 02:10:10 +06:00
946fbecf4a feat: run commands in each sandbox's own default shell
Commands and template builds now run in the user's login shell taken
from the sandbox image, instead of always forcing sh. On Ubuntu that
means bash features work in build steps; other images use their own
default shell. Exec, streaming exec, the terminal, and admin template
builds all follow the same rule.

The terminal can also open as a specific user now, and a terminal opened
with no command starts that user's login shell.
2026-06-23 23:11:25 +06:00
6aa1aec4e0 Updated openapi yaml to reflect PTY updates 2026-06-23 04:22:35 +06:00
763f897d91 perf(pty): make terminal sessions faster and lighter over the network
Cut the amount of data sent when using the in-browser terminal,
especially for full-screen TUIs that repaint a lot.

- Group rapid terminal output into one message instead of many small
  ones, so a screen redraw doesn't flood the connection.
- Turn on compression for the terminal WebSocket only, leaving other
  connections as they were.
- Send terminal output and keystrokes as raw binary instead of
  text-encoded JSON, which avoids the extra size that encoding adds.

Result: fewer, smaller messages and a snappier terminal.
2026-06-23 04:00:54 +06:00
38167a2ca3 feat(envd): stream file transfers, replace multipart upload with raw PUT
The /files endpoints buffered whole files in memory and used blocking
std::fs on the async runtime, so concurrent transfers could starve Tokio
workers and risk OOM in the guest VM. Multipart fields were also parsed
sequentially within a single request, and racing writes to the same path
could tear.

- GET /files now streams off disk via tokio::fs + ReaderStream (no full
  read into RAM).
- Replace multipart POST with raw-body PUT /files; the body streams to a
  temp staging file in the destination directory, then renames atomically
  into place — concurrent writers never observe a torn file, last write
  wins, no mutex needed.
- Drop gzip (both directions), the http/encoding module, and the flate2
  and mime_guess deps. Host<->envd runs over TAP (in-kernel copy), so
  compression only burned guest+host CPU; no caller set the headers.
- Update host-agent WriteFile / WriteFileStream to PUT raw bodies. GET
  side (ReadFile / ReadFileStream) is unchanged — server-side streaming
  is transparent to it.
2026-06-23 02:57:48 +06:00
87c808ffa9 feat(api): add user metadata to capsules at create-time 2026-06-22 05:09:23 +06:00
61637694cb docs: rebrand copy to agent-native positioning
Update CLAUDE.md, README, and login tagline to 'runtime where AI
engineers live' positioning.
2026-06-22 04:54:59 +06:00
766bf7fccf docs(openapi): bump to 0.2.2 and align spec with handlers
- Version 0.2.0 -> 0.2.2 (matches VERSION_CP)
- Add missing response fields: MeResponse (user_id/team_id/role/is_admin),
  Team.is_byoc, TeamMember.name, APIKeyResponse.created_by/creator_email,
  Register/RefreshHostTokenResponse cert/key/ca PEM
- Fix audit-logs response shape (items + next_before/next_before_id)
- Fix wrong success codes: admin capsule create/destroy -> 202, build create -> 201
- Add reusable ServiceUnavailable (503); document 503/400/403/404/409 emitted
  across capsule, files, hosts, channels, teams, snapshots routes
- Fix HostHasCapsulesError example code, exec envs/cwd description
2026-06-22 04:54:59 +06:00
2d658f1500 Speed up capsule pausing
Pausing a capsule now returns faster: the snapshot cleanup that reclaims
empty memory is moved into the background instead of making the pause wait
for it. The saved snapshot ends up the same size as before, and templates
made from a paused capsule still wait for that cleanup so their reported
size is unchanged.

Also bumped the agent and control-plane versions to 0.2.2.
2026-06-21 06:16:01 +06:00
4f6ad92cde envd: step CLOCK_REALTIME directly on resume for instant clock sync
On snapshot resume the guest wall clock is stale by the pause duration.
The only correction was chronyc makestep, but chrony needs several PHC
refclock poll cycles (poll 2 = ~4s) before it has a valid offset to step
to, so the clock stayed wrong for seconds after every resume.

Set CLOCK_REALTIME directly from the host timestamp already carried in
the /init body, inside the lifecycle-changed hook, for an immediate
jump. chronyd keeps running and disciplines drift against /dev/ptp0
afterward; the existing makestep is kept to resync chrony's internal
offset. Bump envd 0.4.0 -> 0.4.1.
2026-06-21 05:40:44 +06:00
5cb4266c3a Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-06-21 04:45:33 +06:00
ef7370d455 fix: record auto-pause in audit log and update capsule status live
Auto-paused capsules now show up in the audit log and flip to paused
on the dashboard right away, instead of staying as running.
2026-06-21 04:39:37 +06:00
1bb2166537 agent/envd: drive inactivity-pause by real VM work, not just requests
The TTL reaper only saw "last user request" time, so a long-running but
non-interactive job (build, download) was auto-paused mid-run, while a
parked `sleep infinity` could not be distinguished from real work.

Add a guest liveness signal and fold it into the existing LastActiveAt
clock the reaper already uses (reaper logic unchanged):

- envd: extend the 1s sampler to also diff /proc/net/dev and
  /proc/diskstats into net_bps/disk_bps (deltas, core-normalized CPU
  already present); expose GET /activity (atomic reads, no syscalls),
  auth-excluded like /health.
- host agent: StartActivitySampler polls each running sandbox every 5s
  (bounded concurrency) and refreshes its TTL when busy — guest CPU >=
  threshold, or net/disk throughput >= floor. Debounced over 2
  consecutive samples so an isolated idle-noise spike cannot keep a
  sandbox alive.
- proxy: inbound proxy traffic now counts as activity (AcquireProxyConn
  bumps LastActiveAt), so an idle-but-served web server stays up.

Thresholds (CPU 5%, net 16KB/s, disk 32KB/s, interval 5s) are
env-overridable: WRENN_CPU_BUSY_THRESHOLD, WRENN_NET_FLOOR_BPS,
WRENN_DISK_FLOOR_BPS, WRENN_ACTIVITY_SAMPLE_INTERVAL.

Net effect: sleep-infinity and idle terminals still pause; builds,
downloads, and served servers stay alive.
2026-06-21 04:13:03 +06:00
4a34574716 fix: restore page title after capsule destroy navigation 2026-06-21 02:50:37 +06:00
eeecaab2e9 envd: add envd ports to list reachable ports + URLs; bump 0.4.0
Adds an `envd ports` subcommand, run inside a sandbox, that lists the
open TCP ports reachable from outside and the public URL each is served
at (https://{port}-{sandbox_id}.{domain}).

envd:
- new `cmd::ports` subcommand: scans /proc/net/tcp[6] in-process via the
  existing read_tcp_connections(), reads sandbox ID + proxy domain from
  env / /run/wrenn, prints a table (or --json). Refuses to run outside a
  sandbox via the WRENN_SANDBOX marker.
- port::conn: reachable_listening_ports() filters LISTEN sockets bound to
  wildcard (direct via TAP) or loopback (socat-forwarded), excludes the
  49983 control port, dedups. Covered by unit tests.
- /init accepts proxy_domain, stored as WRENN_PROXY_DOMAIN env + run-file.
- write_run_file now uses the WRENN_RUN_DIR constant.

host:
- WRENN_PROXY_DOMAIN host-agent env (default wrenn.dev) threaded through
  sandbox.Config -> PostInitWithDefaults on create and resume.
- create-time /init is now always called so WRENN_SANDBOX_ID is set even
  for sandboxes without template defaults (first-call lifecycle bump is a
  no-op, so no resume side effects).
2026-06-21 02:35:58 +06:00
74575118c5 Minor changes 2026-06-21 02:29:28 +06:00
0e31483ec1 admin/hosts: scope aggregated stats to platform hosts only
The host stat strip reduced over allHosts (platform + BYOC), so the
admin overview showed combined host count, vCPU, and memory totals.
BYOC capacity belongs to individual teams; the admin heads-up should
reflect platform capacity only. Scope all aggregates to platformHosts.
2026-06-20 02:22:43 +06:00
459aa31c7e agent: dedup exec/connect event mapping; map timeout/internal errors
Share one event<->proto mapper across Exec, ExecStream and
ConnectProcess on both client and server. Map agent
DeadlineExceeded->504 and Internal->500 in the API. Bump 0.2.1.
2026-06-20 01:36:06 +06:00
e6b620d93d envd: output replay buffer, login-shell exec, SHELL export; bump 0.3.1
Add bounded per-process OutputLog so a late Connect replays missed
output then continues live with no gap or dup. Wrap argless commands in
the user's login shell and export SHELL. Plus cargo fmt sweep.
2026-06-20 01:36:00 +06:00
9de090239a Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-05-25 04:05:06 +06:00
d27d9fd735 Update pipeline to invoke manual runs 2026-05-25 04:01:50 +06:00
1f21665362 expand ~ in cmd and args for ExecRequest 2026-05-25 03:57:30 +06:00
f9ba2e1604 envd: spawn bash instead of sh, source /etc/profile and .bashrc before exec
Changes the default shell from /bin/sh to /bin/bash for both PTY and
pipe command execution paths. The wrapper script now sources /etc/profile
and ~/.bashrc before exec'ing the user's command, so PATH additions and
other shell environment customizations (e.g. cargo) are available.

Missing profile files are silently skipped via test -f guards.
2026-05-25 03:56:28 +06:00
32a766d4b8 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-05-25 03:13:13 +06:00
baece1f866 Merge pull request 'Created CI pipeline for building, tagging and publishing to GitHub' (#46) from ci/build-tag-and-release into dev
Reviewed-on: #46
2026-05-24 21:10:15 +00:00
6d7ae69a43 Minor fixes 2026-05-25 03:04:10 +06:00
c3be944c5b Changed repo directory to /home/wrenn-user/wrenn 2026-05-24 19:40:04 +06:00
4a772457f1 Merge branch 'dev' of git.omukk.dev:wrenn/wrenn into ci/build-tag-and-release 2026-05-24 14:01:30 +06:00
c5b66c7e1a Decouple disk size from user input; agent returns actual size 2026-05-24 13:41:49 +06:00
153b55b509 Merge branch 'dev' of git.omukk.dev:wrenn/wrenn into ci/build-tag-and-release 2026-05-24 12:31:17 +06:00
c4d24d92a0 Updated CoW size default 2026-05-24 12:30:33 +06:00
b562873f06 Update build_go.py 2026-05-23 19:23:21 +06:00
2277de9b47 Changed repo directories, installation boilerplate and models 2026-05-23 19:23:00 +06:00
bbc3ccb49f api: remove host_ip/guest_ip from capsule response; wire disk_size_mb in request
- Remove host_ip and guest_ip fields from sandboxResponse (unused on
  frontend, not needed in API surface)
- Wire disk_size_mb through createSandboxRequest for both regular and
  admin capsule creation (service already supported it)
- Update openapi.yaml: remove host_ip/guest_ip from Capsule schema,
  remove stale sampled_at from CapsuleStats.current, fix disk_used_mb
  format int64 + description
- Remove guest_ip/host_ip from frontend Capsule type
2026-05-23 17:10:02 +06:00
e52169daad Updated openapi schema 2026-05-23 16:40:10 +06:00
9dca93fcd9 Merge branch 'dev' into ci/build-tag-and-release 2026-05-23 07:29:17 +00:00
dc2d949e5e Merge pull request 'feat(admin): host metrics, team details, and admin panel improvements' (#53) from feat/admin-panel into dev
Reviewed-on: #53
2026-05-22 22:28:02 +00:00
429de4001f refactor(hosts): remove duplicated adminHostResponse, reuse hostResponse for both endpoints 2026-05-23 04:13:17 +06:00
76f8cc1675 feat(hosts): add resource transparency to BYOC dashboard host page 2026-05-23 04:10:13 +06:00
e061095110 feat(templates): resolve actual disk usage via host agent RPC
- Add GetTemplateSize RPC to host agent proto with team_id + template_id
- Implement TemplateRootfsSize using block-level stat (Blocks * 512)
  for accurate sparse file accounting
- Add resolveTemplateSizes helper that queries a host agent and
  persists the result to the DB for subsequent requests
- Wire resolveTemplateSizes into GET /v1/admin/templates and
  GET /v1/snapshots so system base templates show correct sizes
- Add UpdateTemplateSize sqlc query
- Remove unused cert_fingerprint / cert_expires_at from host queries
- Add GET /v1/admin/hosts route
2026-05-23 03:57:30 +06:00
f4eb6645bc feat(admin): host metrics, team details, and admin panel improvements 2026-05-23 03:42:40 +06:00
6014fa72cf Merge pull request 'Added a multi-distro system and asynchronized snapshot action' (#52) from fix/image-creation-and-maintenance into dev
Reviewed-on: #52
2026-05-22 20:07:18 +00:00
c164aadfc9 feat(templates): multi-distro system base images + paused-state snapshotting
- Replace single 'minimal' system template with four well-known distro
  base images: minimal-ubuntu (id 0), minimal-alpine (id 1), minimal-arch
  (id 2), minimal-fedora (id 3) — all platform-owned, reserved IDs 0–1024
- Add id.IsReservedTemplateID() guard and well-known ID constants
- Seed the four system base templates via a goose migration
- On-disk layout: images/teams/{b36(team)}/{b36(tid)}/rootfs.ext4
- Rebuild scripts (update-minimal-rootfs.sh, build-*.sh) handle all four
- rootfs-from-container.sh: fix usr-merged distro tini install, flatten
  IMAGE_NAME slashes for tar path

Snapshotting:
- Allow snapshots from 'paused' state (not just 'running')
- Implement flattenPausedCow for paused sandboxes: re-attach dm-snapshot,
  flatten CoW, tear down; distinct dm name to avoid colliding with Resume
- copyMemorySnapshotFiles + linkOrCopyFile: hardlink CH memory artefacts
  with sparse-preserving cp --sparse=always fallback across filesystems
- Promote staging dir atomically with rename(2)
- Track origStatus through snapshotInBackground so badge returns to
  paused (not running) after paused snapshot
- Expand CleanupOrphanPauseDirs to clean up .stage- prefixes too

Build service:
- Look up all base templates (including system ones) via DB query instead
  of hardcoding the minimal template path
- Insert a sandbox DB record for builder sandboxes
- Destroy builder sandbox + mark DB record stopped on finalize
- Default base template to minimal-ubuntu instead of minimal

Chores:
- Remove stale recipe files (code-runner-beta, jupyter test)
- Remove prepare-wrenn-user.sh (replaced by setup-host.sh)
- Remove old build-rootfs.sh / docker-to-rootfs.sh / per-template build.sh
- Update CLAUDE.md, README.md, envd-rs README with new template info
- Frontend: update admin templates page, admin capsule view, snapshot
  dialog to support paused-state snapshots
2026-05-23 01:58:51 +06:00
a01796a4c3 feat(sandbox): async snapshots with snapshotting state + lifecycle toasts
Snapshots now mirror the async pause flow. POST /v1/snapshots returns 202
with the capsule in a new "snapshotting" state and registers the template
in a background goroutine, instead of blocking the request while Cloud
Hypervisor pauses the VM. Users get clear feedback that the capsule is
busy rather than seeing it appear wedged.

- New "snapshotting" sandbox state: CAS running -> snapshotting -> running
- CreateSnapshot async via snapshotInBackground; the back-to-running CAS
  is gated so a racing destroy wins (no false "running" signal)
- serviceEventToCanonical maps snapshot completion + state-change events;
  state changes are published transiently (SSE only)
- HostMonitor reconciles stuck snapshotting: 15m grace while the VM is
  alive, error if the VM is gone
- Global lifecycle toasts (create/pause/resume/snapshot/destroy) driven by
  terminal system-actor SSE events, exactly one per operation
- openapi: status enum, 202 + Capsule responses, admin snapshot name optional
- Remove dead LogSnapshotCreate; host-callback failure during a snapshot
  marks the sandbox errored instead of removing the row
2026-05-22 21:36:46 +06:00
5e819a44f8 Improved the prompt for generating release notes 2026-05-22 01:38:57 +06:00
c2f2be9d2f Merge branch 'dev' into ci/build-tag-and-release 2026-05-21 19:37:59 +00:00
d98acc2764 fix(sandbox): harden pause/snapshot against wedged CH and IO starvation
quiesceAndPauseCH now bounds PrepareSnapshot (5s), probes ch.vm.info
for "Running" before pause, and bounds ch.pause (30s). Second snapshot
attempts against a dead CH socket no longer hang on a 2m envd timeout.

Punch zero-page scan reads 1 MiB chunks instead of 4 KiB, cutting
read(2) syscalls by 256x during 20 GiB snapshot writes on single-disk
hosts. Zero-run batching preserved across chunk boundaries.

Agent startup flushes orphan conntrack rows in 10.11.0.0/16 and
10.12.0.0/16, and logs pre-existing wrenn loop attachments for
post-crash visibility.

wrenn-agent.service gains IOSchedulingClass=best-effort,
IOSchedulingPriority=5, IOWeight=50 so snapshot IO cannot starve
sshd/journal on contended disks.
2026-05-21 14:20:12 +06:00
35654ccb9a Fixed frontend build error 2026-05-20 06:19:49 +06:00
d856f7e18d fix(gitignore): anchor builds/ to repo root; add admin template builds page 2026-05-20 06:15:41 +06:00
eccfb76550 Merge pull request 'Updated template creation process from admin panel and improved agent graceful shutdown' (#51) from fix/template-creation into dev
Reviewed-on: #51
2026-05-19 23:33:28 +00:00
77392ce997 chore(recipes): add data libs and kernel smoke test to code-runner-beta 2026-05-20 05:30:21 +06:00
a8576121cf feat(auth): hash session IDs at rest
Store sha256(SID) in Postgres + Redis; raw SID lives only in cookie.
Prevents DB/cache dump from yielding usable session tokens.
2026-05-20 05:30:17 +06:00
76587e17a2 fix(sandbox): stop leaking paused sandboxes as stopped
Paused sandboxes were silently turning into 'stopped' rows in the CP
DB whenever the auto_paused callback failed delivery and the agent
later restarted. Snapshot dirs survived on disk but the agent had no
restore path, so HostMonitor's missing->stopped reconcile orphaned
them.

Agent
- Manager.RestorePausedSandboxes: scans WRENN_DIR/sandboxes/ on
  startup, picks newest-per-slot, reserves the slot, registers a
  Paused entry in m.boxes. Losers and corrupt metas are trashed.
- Manager.Destroy: re-insert + bail if a racing Pause completed
  between m.boxes delete and lifecycleMu acquisition, so an
  in-flight user Pause's snapshot is not wiped by a concurrent
  Destroy or by Shutdown's destroy loop. Distinguishes from
  legitimate destroy-of-paused via statusAtEntry.
- Manager.Shutdown: emit sandbox.stopped (SendAsync) after each
  non-paused Destroy so the CP flips DB out of running/error.
- ErrDraining + atomic.Bool guard: Create/Resume bounce with
  CodeUnavailable once Shutdown begins, preventing new lifecycle
  work from racing the destroy loop.
- resumeFromMeta: set sb.baseImagePath so cleanup's loops.Release
  pairs with the Acquire just performed (latent bug for any
  Resume->Destroy after the restore path lands).
- ListSandboxes: nil-guard HostIP so paused entries don't emit
  '<nil>' into proto.

Control plane
- HostMonitor: new reconcile block. When the agent reports a
  sandbox as paused but the DB row is stopped (legacy zombie),
  issue DestroySandbox to release the on-disk snapshot and slot.
  Gated on len(paused) > 0 to avoid unbounded stopped-row query.

Host agent main
- Call RestorePausedSandboxes after SetEventSender, before
  StartTTLReaper and before HTTP serve.
- Keep mgr.Shutdown BEFORE httpServer.Shutdown so main does not
  exit while pause work is still draining.
2026-05-20 05:26:31 +06:00
0e8ba30c7a fix(snapshot): propagate SandboxDir across template chains
A snapshot-of-a-snapshot (t2 from t1 from t0) failed to launch with
"GET /api/v1/vm.info: status 500: VM is not created". Cloud Hypervisor's
saved config.json hardcodes the original sandbox's tmpfs path, but
createFromSnapshotTemplate rebuilt SandboxDir from meta.SandboxID — which
on a chained template was the immediate parent sandbox, not the root
ancestor whose path CH baked in. Mismatch → restore silently failed.

wrenn-snapshot.json now carries the effective SandboxDir verbatim, set
at snapshot time from sandboxDirOverride (inherited across chains) or
SandboxTmpDir(sb.ID). The launcher uses meta.SandboxDir directly.

Other changes to the snapshot metadata file:
- Drop sandbox_id (no longer load-bearing).
- Add template_name (plumbed via the existing, previously-deprecated
  CreateSnapshotRequest.name field).
- Make slot_index omitempty and stop writing it for templates — a
  template allocates a fresh slot per launch. Pause still records it.

Template deletion residuals:
- Manager.DeleteSnapshot now prunes the parent team directory when it
  becomes empty.
- New strict deleteSnapshotEverywhere helper used by both the
  user-facing DELETE /v1/snapshots/{name} and admin
  DELETE /v1/admin/templates/{name}. Aborts the DB-row deletion if any
  active host is offline or returns a non-NotFound error, so a failure
  leaves the template visible and retryable instead of orphaning files.

Hard cutover: snapshot templates created before this change have no
sandbox_dir field and will fail to launch with a clear error. Existing
pause snapshots are unaffected (Resume reuses the original sandbox ID,
so the empty SandboxDir falls back to the same default).
2026-05-20 03:14:41 +06:00
2e71ebe16f feat(builds): real-time WebSocket+PTY template build console
The admin template build experience was poll-based: the frontend fetched
GET /v1/admin/builds/{id} every 3s and per-step logs only appeared after a
step finished. This replaces it with a live console.

Backend:
- recipe.Execute gains streaming callbacks (StepStartFunc, OutputChunkFunc)
  and a StreamExecFunc. RUN steps now stream via a PTY so build tools emit
  unbuffered, colorized output. execRunStreaming treats a stream that ends
  without a terminal chunk as a failure.
- The build worker runs each RUN step through the host agent PtyAttach RPC
  and publishes step-start/output/step-end/build-status events to a per-build
  Redis pub/sub channel (wrenn:build:{id}).
- BuildBroker fans those events from Redis out to in-process WebSocket
  subscribers, with lazy per-build subscriptions.
- New WS endpoint GET /v1/admin/builds/{id}/stream replays the completed-step
  history from the DB log, then live-tails broker events.

run_as_root:
- The non-root build user is now injected as USER/WORKDIR steps prepended to
  the persisted recipe by the create handler, instead of being hardcoded in
  the pre-build phase. "Run as root" simply omits them, so wrenn-user is never
  created in a root template. No build-level column is needed.

Frontend:
- New /admin/templates/builds/[id] route with a hybrid console: an xterm.js
  terminal streaming live PTY output plus a structured step list (status,
  exit code, timing).
- Build rows on the templates page navigate to the console; the inline
  log-expand is removed. A "Run recipe as root" checkbox is added to the
  create modal.
2026-05-20 02:09:03 +06:00
f06d03996a fix: niceness EPERM for non-root processes, flatten sync
envd: spawn_process wrapped every command in `nice -n {delta}`. current_nice()
used `20 - getpriority()`, but getpriority already returns the nice value, so
delta was -20 for a default-nice envd. Non-root users cannot raise priority, so
the wrapper failed with "cannot set niceness: permission denied" for any process
run as a non-root user. current_nice() now returns the raw value; the wrapper
invokes `nice` only when delta > 0. The oom_score_adj write is kept (always
permitted for raising one's own score).

sandbox: FlattenRootfs used a plain ch.pause, which freezes vCPUs but does not
flush the guest VFS page cache, so freshly written files (e.g. pip installs)
had not reached the block device and the flattened rootfs captured empty files.
Switch to quiesceAndPauseCH (envd /snapshot/prepare: sync + drop_caches), as
CreateSnapshot and Pause already do, and reset the connection tracker after
resume on both the success and quiesce-failure paths.
2026-05-20 01:34:15 +06:00
21c837aa02 fix: surface sandbox create failures and stop destroy leak
The createInBackground "sandbox.failed" event had no case in
serviceEventToCanonical, so it was dropped: no SSE push, no webhook,
no audit row. The dashboard stayed on "starting" until a manual
refresh. Add the case and route create/resume/pause failures through
with a non-empty reason so channels.isRedundantSystemFollowup does
not filter them out of webhook delivery.

A DestroySandbox RPC arriving while Create was still booting found
nothing in m.boxes (the sandbox is registered only after the envd
readiness wait) and no-oped, while Create raced on to register a VM
that no DB record owned — a permanent VM/dm/network/loop leak. Create
now registers a cancellable handle in m.creates before acquiring
resources; Destroy aborts the in-flight create and waits for its
rollback. Shutdown drains in-flight creates for the same reason.

The envd readiness wait was a fixed 30s, too tight for large VMs
which cold-boot slower. Scale it at 8s per GiB of guest RAM with a
120s floor.

handleFailed skips its audit write for reconciler-emitted events
(reason=transient_timeout); LogSandboxCreateSystem already wrote that
row before publishing, so auditing again would double-count.
2026-05-20 01:09:26 +06:00
0012088191 fix(envd): avoid lost prune on already-exited process
Check cached_end() after subscribing so the cleanup task does not block
on a broadcast receiver that already missed the end event from a
short-lived process.
2026-05-19 23:41:29 +06:00
d180bf355c Merge branch 'dev' into ci/build-tag-and-release 2026-05-19 14:19:23 +00:00
28e35bf23e refactor(cpextension): remove LimitsProvider/UsageProvider hooks
Quota enforcement moves to the cloud repo as a MiddlewareProvider
wrapping POST /v1/capsules. Drops the Limits/Usage structs, both
provider interfaces, the enforceLimits gate, and the DB-backed
defaultUsageProvider. OSS deployment is unmetered.
2026-05-19 18:56:47 +06:00
a94a72afa2 Updated cleanup script 2026-05-19 17:28:36 +06:00
75607b4464 fix(envd): signal process groups, drain output before exit event
SendSignal now targets the negative pid so the whole process group is
killed, not just the /bin/sh wrapper — children are no longer orphaned.
The pipe spawn path calls setpgid to become a group leader (pty path
gets this from setsid).

The waiter thread joins the stdout/stderr/pty reader threads before
publishing the end event, and the RPC streams drain the data channel
fully before emitting the end event (and emit a still-buffered end
event when the data channel closes first). Trailing output is no
longer lost to a process-exit/read race.
2026-05-19 17:16:16 +06:00
8563229bf4 fix(processes,fs): prune killed processes, preserve envd error codes
ProcessServiceImpl.processes was a bare DashMap; the cleanup task
cloned it (DashMap::clone deep-copies), so dead PIDs were removed
from a detached copy and ListProcesses showed them forever. Share
the map via Arc.

Host agent flattened every envd filesystem error to CodeInternal,
discarding the Connect code. mkdir on an existing directory thus
surfaced as 500/agent_error instead of 409. Add envdErr() helper
that preserves the envd Connect code (NotFound, AlreadyExists, ...)
and apply it to WriteFile/ReadFile/ListDir/MakeDir/RemovePath.

Split agentErrToHTTP so AlreadyExists maps to 409 already_exists
(distinct from FailedPrecondition -> 409 conflict). Document the
already_exists case on the mkdir endpoint in openapi.yaml.
2026-05-19 14:53:49 +06:00
62e7e9c4e1 Merge branch 'dev' into ci/build-tag-and-release 2026-05-19 06:42:02 +00:00
e87506ce6b Merge pull request 'Migrate to Cloud Hypervisor' (#49) from feat/migrate-to-ch into dev
Reviewed-on: #49
2026-05-18 22:59:57 +00:00
e843be21a4 extensions: auth/sandbox hooks, limits/usage providers, exported session middleware
Cloud-repo extensions could not build on the cookie-session migration —
they still called the removed JWT helpers and duplicated middleware. Move
the session/CSRF middleware and cookie helpers into pkg/auth/session/middleware
as the single source of truth, with thin re-exports on cpextension.

Add hook interfaces so cloud can plug billing without forking OSS:
- AuthHook (OnSignup fail-fast; OnLogin / OnAccount*Delete log+ignore)
- SandboxEventHook (un-acks on error so messages redeliver; idempotent)
- LimitsProvider / UsageProvider (402 on overage; DB-backed usage default)

ServerContext gains OAuthRegistry, Channels, ChannelPub so extensions stop
reimplementing them.
2026-05-19 04:49:19 +06:00
9b34d6a82f events/capsules: dedupe channel notifications, harden create dialog
- channels dispatcher: drop capsule.{create,pause,resume,destroy} events
  with system actor and no reason metadata. Suppresses the goroutine /
  host-callback follow-up that duplicated every user-initiated action in
  notification channels (Telegram, webhooks). Genuinely system-only
  emitters (TTL auto-pause, host monitor reconciler, host failures) all
  set reason, so they continue to notify.
- CreateCapsuleDialog: wrap submit in try/finally so the creating flag
  always clears, and close the dialog before invoking oncreated to avoid
  the parent receiving the new capsule while the dialog is still open.
- capsules page: guard against double-insertion of the same capsule when
  the SSE event arrives before the dialog's oncreated callback resolves.
2026-05-19 04:27:11 +06:00
42af7c4357 auth: replace user JWTs with cookie sessions
User authentication moves from short-lived JWT bearer tokens to opaque
session cookies (wrenn_sid) backed by a Postgres sessions table and a
Redis hot cache. Browsers get a paired wrenn_csrf cookie; all mutating
requests must echo it via X-CSRF-Token (double-submit).

- New pkg/auth/session service: issue/revoke, idle (6h) + absolute
  (24h) lifetimes, switch-team rotation, RevokeAllForUser on password
  events, per-user listing for self-service.
- Middleware: requireSession + requireCSRF replace requireJWT and the
  WS first-message JWT exchange. SSE/WS endpoints rely on the cookie
  flowing on the upgrade — SSE ticket store deleted.
- API keys (wrn_<32hex>) remain for SDK/server use; capsule routes
  accept either via requireSessionOrAPIKey.
- Host-agent JWTs (signed by JWT_SECRET) are unchanged — that channel
  is wrenn-cp ↔ wrenn-agent and unrelated to user identity.
- Frontend client drops bearer-token plumbing, sends credentials and
  the CSRF header on every mutating call.
- OpenAPI + dashboard host-registration docs updated.
2026-05-19 04:01:24 +06:00
4b58dc32ab events: outcome + metadata + transient channel
Rename CapsuleCreated→CapsuleCreate (and pair siblings) into action
verbs, add Outcome (success/error), Metadata, and Error fields to the
canonical Event. Introduce PublishTransient for ephemeral SSE-only
signals (capsule.state.changed) so dashboard transitions don't reach
webhook/telegram subscribers.

Audit logger now publishes the canonical event itself with the derived
outcome, collapsing the old "audit then separately publish" split.
Sandbox event consumer rebuilt around the unified stream: host-agent
callbacks are translated once into canonical events, then fan out via
DB reconciler, channel dispatcher, and SSE relay independently.

Documents the channels subscription model in the README.
2026-05-19 04:01:06 +06:00
09cb78f1b8 feat(frontend): typed capsule/SSE state machine, resilient event stream
Introduce CapsuleStatus union + RESUMABLE_STATUSES / TRANSIENT_STATUSES
sets that mirror the backend state machine; the routes and SnapshotDialog
now derive button enablement from the sets instead of ad-hoc string
checks. Add disk_size_mb + metadata to the Capsule shape.

SSEEventKind union + isSSEEvent guard so malformed wire payloads can't
reach handlers via blind casts. Event stream reconnect now:
  - retries with backoff when the ticket fetch itself fails (previously
    gave up on a single 401/network blip),
  - reconnects immediately on window 'online' and document visibilitychange
    (back to visible) when the EventSource is not OPEN,
  - subscribes to capsule.error.

openapi.yaml: align OAuth paths (/v1/auth/oauth → /auth/oauth to match
the actual mount point), document bearerAuth on capsule routes, fix
'capsulees' typos, and expand schemas for the new state machine surfaces
the frontend now consumes.
2026-05-19 01:29:38 +06:00
f002839c48 feat(api): plumb root ctx through SSE store, surface pause/resume failures, clamp TTL
NewSSETicketStore now takes a context so its cleanup goroutine exits on
server shutdown instead of leaking for the process lifetime. Threaded
through api.New and pkg/cpserver/run.go.

SandboxEventConsumer learns sandbox.pause_failed / sandbox.resume_failed
event types and forwards TeamID from the publisher; server.go propagates
TeamID into the SSE broker so per-team subscribers receive failure
events. resumeInBackground now rolls resuming → paused on failure (was
resuming → error) so the user can retry without manual intervention.

pkg/service/sandbox: mirror internal/sandbox.MinTimeoutSec + clampTimeout
on the control plane so the DB row's timeout_sec agrees with what the
agent runs after its own silent clamp.
2026-05-19 01:29:28 +06:00
cab50db1c1 refactor(sandbox): per-sandbox dir layout, atomic envd client, sentinel errors
Move CoW from sandboxes/{id}.cow to sandboxes/{id}/rootfs.cow so every
per-sandbox artifact lives under one parent. PauseSnapshotDir now aliases
SandboxDir; Pause stages the CoW into the staging dir before swapDir so
the swap carries it through.

Publish sb.client via atomic.Pointer so Exec/Pty/Process callers can load
without holding lifecycleMu; Pause's releaseRuntime stores nil, Resume
stores a fresh client. Funnel every caller through new activeClient()
that nil-checks after Load to close the pause-vs-exec race.

Replace string-sniffing for "not found" / "not running" with sentinel
errors (ErrNotFound, ErrNotRunning, ErrNotPaused, ErrInvalidRange) and a
single mapSandboxError switch in the hostagent server. Add
parseSandboxIDs helper for the repeated team+template UUID parse.

Rewrite ConnTracker off sync.WaitGroup onto an explicit counter + zeroCh
so Drain/ForceClose can select on cancellation and timeout without
leaking the waiter goroutine on repeated pause failures.

Add internal/sandbox/punch.go: post-snapshot SEEK_DATA scan that
fallocate-punches any 4 KiB block of zeros in CH memory-* files (guest
dirty-then-free pages CH writes verbatim). Run after both pause snapshot
and CreateSnapshot. Bump envd quiesce sleep 500ms → 1s so the kernel
fully flushes before CH dumps memory.

Add sandboxDirOverride threaded through snapshotMeta + restoreVMConfig:
sandboxes launched from snapshot templates carry the original source
sandbox's tmpfs path in CH's saved config.json, so every subsequent
restore must reuse it.
2026-05-19 01:29:20 +06:00
802af222ee feat(sandbox): launch sandboxes from snapshot templates
New createFromSnapshotTemplate path branches off Manager.Create when the
template directory contains a CH memory snapshot (state.json + config.json
+ rootfs.ext4). Mirrors the pause/resume restore mechanics — same UFFD
lazy memory + post-restore memory loader — but produces a fresh sandbox
per call (new ID, new slot, new CoW on the shared flattened rootfs).

Shared restore primitives extracted to restore.go (buildRestoreVMConfig,
launchRestoredVM, initAndStartMemoryLoader) and reused by resumeFromMeta.

Chain correctness: descendants of snapshot templates start the memory
loader so subsequent CreateSnapshot from them is self-contained.

Defensive guards:
- CreateSnapshot refuses to overwrite an existing template dir.
- DeleteSnapshot refuses when running sandboxes still reference it.
- TimeoutSec clamped to MinTimeoutSec=60 to keep TTL reaper well clear of
  the post-create startup window.
- Snapshot routing skips minimal template even if a stray state.json lands.

vm.SandboxTmpDir / vm.SandboxSocketPath extracted so launchers don't
re-derive CH disk paths independently.
2026-05-18 15:20:45 +06:00
8262a4999e feat(vm): CH live snapshot, pause/resume with UFFD memory restore
Pause and live-snapshot share one CH primitive (ch.pause + ch.snapshot +
ch.destroy/resume). Pause writes artefacts to a staging dir and
atomically swaps to avoid CH re-reading a memory-ranges file mid-rewrite
across pause-resume-pause chains. Resume uses
memory_restore_mode=ondemand backed by userfaultfd; CH lazily faults
pages from the source file. A new envd /memory/preload endpoint
materialises every physical page (one byte per page via /dev/mem,
fallback /proc/kcore) so a subsequent snapshot writes a self-contained
file instead of holes.

Sandbox manager refactor: lifecycle / pause / resume code extracted to
internal/sandbox/pause.go, leaving manager.go focused on the in-memory
state map and orchestration entry points (-871 / +72). Stale CH process
and dm-snapshot cleanup runs at agent startup (internal/vm/cleanup.go)
and via scripts/cleanup-stale.sh for operator use.

Host monitor honors the agent's reported per-sandbox status when
reconciling missing rows (so an agent-side pause during a CP
disconnect isn't silently promoted back to running). New
BulkRestoreMissingToStatus query replaces the running-only path.
Transient statuses (pausing/resuming/starting/stopping) defer
reconciliation to the next tick.
2026-05-18 14:05:27 +06:00
b9cb3998f8 feat(api): real-time SSE event stream for sandbox lifecycle
In-process broker fans out sandbox state events (created/paused/running/
destroyed) to connected SSE clients, filtered by team. Backend publishes
through the channels Publisher; an SSE relay subscribes to Redis Pub/Sub
and dispatches to subscribers. Browser auth uses short-lived tickets
issued via /v1/events/token; SDKs use header auth. Admin routes get a
parallel stream that sees all teams. Frontend dashboard and admin
capsule pages subscribe to push state changes instead of polling.

Sandbox event publishing moved out of AuditLogger into the service layer
so callbacks from the host agent and direct state changes share one
path.
2026-05-18 14:05:06 +06:00
62bede5dae fix: resolve bugs and DRY violations in sandbox manager and API handlers
- Fix createFromSnapshot discarding memoryMB param (balloon optimization was dead)
- Fix double dm-snapshot removal in Pause() cleanupPauseFailure path
- Fix DestroySandbox RPC mapping all errors to CodeNotFound
- Fix handleFailed event consumer missing pausing/resuming → error transitions
- Fix stream resource leak in StreamUpload on early-return paths
- Add envs/cwd fields to ExecRequest proto for foreground exec parity
- Extract createResources rollback helper to eliminate 4x duplicated teardown
- Remove unused chClient.ping method
- Add .mcp.json to gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 02:30:32 +06:00
74f85ce4e9 refactor: polish control plane and host agent code
- Decompose executeBuild (318 lines) into provisionBuildSandbox and
  finalizeBuild helpers for readability
- Extract cleanupPauseFailure in sandbox manager to unify 3 inconsistent
  inline teardown paths (also fixes CoW file leak on rename failure)
- Remove unused ctx parameter from startProcess/startProcessForRestore
- Add missing MASQUERADE rollback entry in CreateNetwork for symmetry
- Consolidate duplicate writeJSON for UTF-8/base64 exec response
2026-05-17 02:11:48 +06:00
124e097e23 refactor: eliminate DRY violations across control plane and host agent
Extract shared helpers to consolidate repeated patterns:
- requireRunningSandbox: sandbox lookup + running check (10 call sites)
- upgradeAndAuthenticate: WS upgrade + JWT/API-key auth (3 handlers)
- updateLastActive: last_active_at update with background context (5 sites)
- attachCowAndCreate: cow loop attach + dmsetup create (devicemapper)
- issueRegistrationToken: token gen + Redis + audit (host service)
- ErrNotFound sentinel: replaces string matching in hostagent server

Also merges duplicate wsProcessOut/wsOutMsg types into one.

Net: -208 lines, zero behavior change.
2026-05-17 02:03:06 +06:00
a5425969ed fix: assorted bug fixes for CH migration
Fix resource leaks, race conditions, and error handling across host
agent and control plane: proper sparse file cleanup on close error,
connect error wrapping for MakeDir, CoW file cleanup on pause failure,
per-sandbox VM directories, deferred map deletion to avoid race in VM
destroy, and goroutine launch for extension background workers.
2026-05-17 01:47:56 +06:00
fb16bc9ed1 chore: update proto, scripts, and docs for CH migration
- Update hostagent proto: firecracker_version → vmm_version in metadata
- Regenerate hostagent.pb.go
- Update .env.example: WRENN_FIRECRACKER_BIN → WRENN_CH_BIN
- Update Makefile: remove --isnotfc from dev-envd target
- Update prepare-wrenn-user.sh: firecracker → cloud-hypervisor paths
  and capability assignments
- Update wrenn-init.sh: disable write_zeroes on rootfs for dm-snapshot
  compatibility with CH
- Update README.md and CLAUDE.md: Firecracker → Cloud Hypervisor
  throughout
2026-05-17 01:33:35 +06:00
dd8a940431 feat(envd): update guest agent for Cloud Hypervisor
Remove Firecracker-specific MMDS metadata fetching and metrics host
module. CH communicates with the guest purely over TAP networking,
so MMDS (Firecracker's metadata service via MMDS address) is no longer
needed.

- Remove src/host/ module (mmds.rs, metrics.rs)
- Remove reqwest dependency (was only used for MMDS HTTP calls)
- Remove --isnotfc CLI flag (no longer dual-mode)
- Simplify health endpoint and init handler
- Update state management for CH snapshot lifecycle
- Bump version to 0.3.0
2026-05-17 01:33:25 +06:00
eaa6b8576d feat(vm): replace Firecracker with Cloud Hypervisor
Migrate the entire VM layer from Firecracker to Cloud Hypervisor (CH).
CH provides native snapshot/restore via its HTTP API, eliminating the
need for custom UFFD handling, memfile processing, and snapshot header
management that Firecracker required.

Key changes:
- Remove fc.go, jailer.go (FC process management)
- Remove internal/uffd/ package (userfaultfd lazy page loading)
- Remove snapshot/header.go, mapping.go, memfile.go (FC snapshot format)
- Add ch.go (CH HTTP API client over Unix socket)
- Add process.go (CH process lifecycle with unshare+netns)
- Add chversion.go (CH version detection)
- Refactor sandbox manager: remove UFFD socket tracking, snapshot
  parent/diff chaining, FC-specific balloon logic; add crash watcher
- Simplify snapshot/local.go to CH's native snapshot format
- Update VM config: FirecrackerBin → VMMBin, new CH-specific fields
- Update envdclient, devicemapper, network for CH compatibility
2026-05-17 01:33:12 +06:00
c2dc382787 Updated openapi schema 2026-05-16 18:32:37 +06:00
3671af2498 feat: immediate sandbox reconciliation on host reconnect
When a host transitions from unreachable → online via heartbeat, trigger
ReconcileHost in a background goroutine so "missing" sandboxes are
resolved instantly instead of waiting up to 60s for the next monitor tick.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-16 16:15:49 +06:00
e34bcedc31 Merge pull request 'fix/remove-sync-updates' (#47) from fix/remove-sync-updates into dev
Reviewed-on: #47
2026-05-15 08:08:07 +00:00
ff91ef3edf Bump versions 2026-05-15 13:56:04 +06:00
ba3a3db98c Updated openapi specs 2026-05-15 12:39:06 +06:00
6faad45a28 feat: async sandbox lifecycle with Redis Stream events
Replace synchronous RPC-based CP-host communication for sandbox
lifecycle operations (Create, Pause, Resume, Destroy) with an async
pattern. CP handlers now return 202 Accepted immediately, fire agent
RPCs in background goroutines, and publish state events to a Redis
Stream. A background consumer processes events as a fallback writer.

Agent-side auto-pause events are pushed to the CP via HTTP callback
(POST /v1/hosts/sandbox-events), keeping Redis internal to the CP.

All DB status transitions use conditional updates
(UpdateSandboxStatusIf, UpdateSandboxRunningIf) to prevent race
conditions between concurrent operations and background goroutines.

The HostMonitor reconciler is kept at 60s as a safety net, extended
to handle transient statuses (starting, pausing, resuming, stopping).

Frontend updated to handle 202 responses with empty bodies and render
transient statuses with blue indicators.
2026-05-15 12:25:16 +06:00
cdb178d8d1 Created CI pipeline for building, tagging and publishing to GitHub 2026-05-13 23:27:33 +06:00
c08884fa2c Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-05-13 11:05:49 +06:00
6164d7cae3 version bump 2026-05-13 10:58:54 +06:00
dc6776cc8f fix(agent): register with CP before inflating rootfs images 2026-05-13 10:52:22 +06:00
0bfda08f47 Merge pull request 'test (envd): add 136 unit tests across 12 modules' (#44) from testing/envd into dev
Reviewed-on: #44
2026-05-13 04:42:06 +00:00
485be22a16 test(envd): add 136 unit tests across 12 modules
Cover all pure-function modules with inline #[cfg(test)] blocks:
crypto (NIST/RFC 4231 known-answer vectors), auth (SecureToken ops,
signature generation/validation), conntracker (snapshot lifecycle),
execcontext, util (AtomicMax concurrent correctness), http/encoding
(RFC 7231 negotiation), port/conn (/proc/net/tcp parsing),
rpc/entry (format_permissions), and permissions/path (tilde expansion,
ensure_dirs). Add tempfile dev-dep for filesystem tests. Update
Makefile test target to include cargo test.
2026-05-13 10:39:54 +06:00
ead406bdac Merge pull request 'fix: resolve large operation reliability — stream hangs, pause races, and memory bloat' (#43) from fix/large-operations into dev
Reviewed-on: #43
2026-05-13 03:44:41 +00:00
1472d77b52 Merge branch 'dev' into fix/large-operations 2026-05-13 03:44:19 +00:00
6a0fea30a6 Rootfs script updated 2026-05-13 09:35:06 +06:00
8c34388fc2 Changed commands to check if envd is statically linked or not 2026-05-12 23:19:30 +06:00
aca43d51eb fix: resolve process stream hangs, pause race, and PTY signal loss
- Cache terminal EndEvent on ProcessHandle so connect() can detect
  already-exited processes instead of hanging forever on broadcast
  receivers that missed the event. Subscribe before checking cache
  to close the TOCTOU window.

- Protect sb.Status writes in Pause with m.mu to prevent data race
  with concurrent readers (AcquireProxyConn, Exec, etc.).

- Restart metrics sampler in restoreRunning so a failed pause attempt
  doesn't permanently kill sandbox metrics collection.

- Return dequeued non-input messages from coalescePtyInput instead of
  dropping them, preventing silent loss of kill/resize signals during
  typing bursts.
2026-05-09 18:11:15 +06:00
522e1c5e90 fix: subscribe to process channels before spawning threads to prevent event loss
Fast-exiting processes (e.g. echo) sent data/end events before
start() subscribed to the broadcast channels, causing the stream
to hang indefinitely and the exec RPC to time out with 502.

Move channel subscription into spawn_process, before reader/waiter
threads start, and return pre-subscribed receivers via SpawnedProcess.
2026-05-09 17:28:37 +06:00
d1d316f35c fix: resolve exec 502 by terminating process streams on exit
The start() and connect() streaming RPCs blocked forever in the data
event loop because ProcessHandle retains a broadcast sender (needed for
reconnection via connect()), preventing the channel from closing.

Race data_rx against end_rx with tokio::select! so the stream terminates
when the process exits. Remaining buffered data is drained before
yielding the end event.
2026-05-09 16:36:33 +06:00
2af8412cdc fix: use RwLock for envd Defaults to fix silent mutation loss
The /init handler's default_user mutation cloned the Defaults struct,
mutated the clone, then dropped it — the actual state was never updated.
This caused processes to always run as "root" regardless of the user
set via POST /init. Additionally, default_workdir was accepted in the
init request but never applied.

Wrap user and workdir fields in RwLock with accessor methods so mutations
propagate correctly through the shared AppState.
2026-05-09 15:28:09 +06:00
c93ad5e2db fix: harden pause flow with connection isolation and UFFD event handling
Restructure pause to: block new operations (StatusPausing), drain proxy
connections with 5s grace, force-close remaining via context cancellation,
drop page cache, inflate balloon, then freeze vCPUs. Previously connections
could arrive during the pause window and API operations weren't blocked.

Handle UFFD_EVENT_REMOVE/UNMAP/REMAP/FORK gracefully instead of crashing
the UFFD server. These events fire during balloon deflation on snapshot
restore, killing the page fault handler and preventing VM boot.

Also adds ConnTracker.ForceClose() with cancellable context propagated
through the proxy handler, so lingering proxy connections are actively
terminated rather than left dangling.
2026-05-09 14:51:19 +06:00
38799770db fix: inflate balloon before snapshot to reduce memfile size
Firecracker dumps the entire VM memory region regardless of guest
usage. A 20GB VM using 500MB still produces a ~20GB memfile because
freed pages retain stale data (non-zero blocks).

Inflate the balloon device before snapshot to reclaim free guest
memory. Balloon pages become zero from FC's perspective, allowing
ProcessMemfile to skip them. This reduces memfile size from ~20GB
to ~1-2GB for lightly-used VMs.

- Pause: read guest memory usage, inflate balloon to reclaim free
  pages, wait 2s for guest kernel to process, then proceed
- Resume: deflate balloon to 0 after PostInit so guest gets full
  memory back
- createFromSnapshot: same deflation since template snapshots
  inherit inflated balloon state
- All balloon ops are best-effort with debug logging on failure
2026-05-05 15:38:04 +06:00
51b5d7b3ba fix: resolve pause/snapshot failures and CoW exhaustion on large VMs
Remove hard 10s timeout from Firecracker HTTP client — callers already
pass context.Context with appropriate deadlines, and 20GB+ memfile
writes easily exceed 10s.

Ensure CoW file is at least as large as the origin rootfs. Previously,
WRENN_DEFAULT_ROOTFS_SIZE=30Gi expanded the base image to 30GB but the
default 5GB CoW could not hold all writes, causing dm-snapshot
invalidation and EIO on all guest I/O.

Destroy frozen VMs in resumeOnError instead of leaving zombies that
report "running" but can't execute. Use fresh context for the resume
attempt so a cancelled caller context doesn't falsely trigger destroy.

Increase CP→Agent ResponseHeaderTimeout from 45s to 5min and
PrepareSnapshot timeout from 3s to 30s for large-memory VMs.

After failed pause, ping agent to detect destroyed sandboxes and mark
DB status as "error" instead of reverting to "running".
2026-05-04 01:46:57 +06:00
fd5fa28205 Merge pull request 'Enhanced frontend ux' (#42) from enhance/frontend into dev
Reviewed-on: #42
2026-05-03 11:08:48 +00:00
1244c08e42 fix: fetch sandbox metrics immediately on page load
Metrics data was only fetched after Chart.js dynamic import completed,
leaving graphs empty until the first poll interval fired. Now
loadMetrics() runs in parallel with the Chart.js import, and
initCharts() resets the dedup key so pre-fetched data populates
newly created chart instances.
2026-05-03 16:43:26 +06:00
021d709de2 feat: show template owner and restrict delete in admin panel
Add Owner column to admin templates table, resolving team IDs to names
via admin teams API. Disable delete for non-platform templates and the
minimal template, with contextual tooltips explaining why.
2026-05-03 15:51:20 +06:00
cac6fcd626 feat: admin grant/revoke from admin panel
Add PUT /v1/admin/users/{id}/admin endpoint and frontend UI for
granting and revoking platform admin status. Uses atomic conditional
SQL (RevokeUserAdmin) to prevent race conditions that could remove
the last admin. Includes idempotency check, audit logging, and
confirmation dialog with self-demotion warning.
2026-05-03 15:24:34 +06:00
4954b19d7c fix: merge capsule data in-place to prevent visual refresh on poll
Replaces full array assignment with granular merge that reuses existing
Svelte proxy objects, so only rows with actual data changes re-render.
2026-05-03 15:09:21 +06:00
01819642cc fix: drop page cache before snapshot to reduce memory dump size
Linux keeps freed memory as page cache, which Firecracker snapshots
as non-zero blocks. A 16GB VM with 12GB stale cache would write all
12GB to disk. Dropping pagecache (not dentries/inodes) in
/snapshot/prepare before blocking the reclaimer shrinks snapshots
to actual working set size with minimal resume latency impact.
2026-05-03 14:27:49 +06:00
cb28f7759d Merge pull request 'fix: accurate sandbox metrics and memory management' (#41) from bugfix/sandbox-metrics-calculations into dev
Reviewed-on: #41
2026-05-03 06:41:41 +00:00
1178ab8b21 fix: accurate sandbox metrics and memory management
Three issues fixed:

1. Memory metrics read host-side VmRSS of the Firecracker process,
   which includes guest page cache and never decreases. Replaced
   readMemRSS(fcPID) with readEnvdMemUsed(client) that queries
   envd's /metrics endpoint for guest-side total - MemAvailable.
   This matches neofetch and reflects actual process memory.

2. Added Firecracker balloon device (deflate_on_oom, 5s stats) and
   envd-side periodic page cache reclaimer (drop_caches when >80%
   used). Reclaimer is gated by snapshot_in_progress flag with
   sync() before freeze to prevent memory corruption during pause.

3. Sampling interval 500ms → 1s, ring buffer capacities adjusted
   to maintain same time windows. Reduces per-host HTTP load from
   240 calls/sec to 120 calls/sec at 120 capsules.

Also: maxDiffGenerations 8 → 1 (merge every re-pause since UFFD
lazy-loads anyway), envd mem_used formula uses total - available.
2026-05-03 12:19:01 +06:00
233e747d5d Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-05-03 04:56:14 +06:00
20a228eb8d Merge pull request 'Rewritten envd with rust to improve reliability during pause and resume operations' (#39) from feat/envd-rewrite into dev
Reviewed-on: #39
2026-05-02 22:49:36 +00:00
ef5f223863 fix: improve error feedback for terminal disconnects and host unavailability
Show "[session disconnected]" in terminal when PTY websocket closes cleanly.
Map scheduler and agent unavailability errors to 503 with user-friendly
message instead of leaking internal details.
2026-05-03 04:47:10 +06:00
31456fd169 fix: resolve PTY failure, MMDS file writes, and metrics instability in envd-rs
Three bugs fixed:

1. PTY connections failed because home directory was hardcoded as
   /home/{username} instead of reading from /etc/passwd. For root,
   this produced /home/root/ which doesn't exist — CWD validation
   rejected every PTY Start request without explicit cwd. Fixed all
   6 locations to use user.dir from nix::unistd::User.

2. MMDS polling silently failed to parse metadata because the
   logs_collector_address field lacked #[serde(default)]. The host
   agent only sends instanceID + envID — missing "address" field
   caused every deserialize attempt to fail, so .WRENN_SANDBOX_ID
   and .WRENN_TEMPLATE_ID were never written. Also added error
   logging and create_dir_all before file writes.

3. Metrics CPU values were non-deterministic because a fresh
   sysinfo::System was created per request with a 100ms sleep
   between reads. Replaced with a background thread that samples
   CPU at fixed 1-second intervals via a persistent System instance,
   matching gopsutil's internal caching behavior. Metrics endpoint
   now reads cached atomic values — no blocking, consistent window.

Also: close master PTY fd in child pre_exec, add process.Start
request logging, bump version to 0.2.0.
2026-05-03 04:28:10 +06:00
bbcde17d49 Updated static link check for envd 2026-05-03 03:32:41 +06:00
f328113a2a rename guest hostname from "sandbox" to "capsule"
Terminal prompt inside VMs now shows root@capsule instead of
root@sandbox, aligning with user-facing "capsule" terminology.
2026-05-03 03:32:03 +06:00
1143acd37a refactor: remove Go envd module, update host agent for Rust envd
The Go envd guest agent (`envd/`) is fully replaced by the Rust
implementation (`envd-rs/`). This commit removes the Go module and
updates all references across the codebase.

Makefile: remove ENVD_DIR, VERSION_ENVD, build-envd-go, dev-envd-go,
and Go envd from proto/fmt/vet/tidy/clean targets. Add static-link
verification to build-envd.

Host agent: rewrite snapshot quiesce comments that referenced Go GC
and page allocator corruption — no longer applicable with Rust envd.
Tighten envdclient to expect HTTP 200 (not 204) from health and file
upload endpoints, and require JSON version response from FetchVersion.

Remove NOTICE (no e2b-derived code remains). Update CLAUDE.md and
README.md to reflect Rust envd architecture.
2026-05-03 03:12:25 +06:00
0b53d34417 feat: rewrite envd guest agent in Rust (envd-rs)
Complete Rust rewrite of the Go envd guest daemon that runs as PID 1
inside Firecracker microVMs. Feature-complete across all 8 phases:

- Health, metrics, and env var endpoints
- Crypto (SHA-256/512, HMAC), auth (secure token, signing), init/snapshot
- Connect RPC via connectrpc + buffa (process + filesystem services)
- File transfer (GET/POST /files) with gzip, multipart, chown, ENOSPC
- Port subsystem (/proc/net/tcp scanner, socat forwarder)
- Cgroup2 manager with noop fallback
- Snapshot/restore lifecycle (conntracker, port subsystem stop/restart)
- SIGTERM graceful shutdown, --cmd initial process spawn
- MMDS metadata polling for Firecracker mode

42 source files, ~4200 LOC, 4.1MB stripped release binary.
Makefile updated: build-envd now targets Rust (musl static),
build-envd-go preserved for Go builds.
2026-05-03 02:47:15 +06:00
3deecbff89 fix: prevent Go runtime memory corruption and sandbox halt after snapshot restore
Three root causes addressed:

1. Go page allocator corruption: allocations between the pre-snapshot GC
   and VM freeze leave the summary tree inconsistent. After restore, GC
   reads corrupted metadata — either panicking (killing PID 1 → kernel
   panic) or silently failing to collect, causing unbounded heap growth
   until OOM. Fix: move GC to after all HTTP allocations in
   PostSnapshotPrepare, then set GOMAXPROCS(1) so any remaining
   allocations run sequentially with no concurrent page allocator access.
   GOMAXPROCS is restored on first health check after restore.

2. PostInit timeout starvation: WaitUntilReady and PostInit shared a
   single 30s context. If WaitUntilReady consumed most of it, PostInit
   failed — RestoreAfterSnapshot never ran, leaving envd with keep-alives
   disabled and zombie connections. Fix: separate timeout contexts.

3. CP HTTP server missing timeouts: no ReadHeaderTimeout or IdleTimeout
   caused goroutine leaks from hung proxy connections. Fix: add both,
   matching host agent values.

Also adds UFFD prefetch to proactively load all guest pages after restore,
eliminating on-demand page fault latency for subsequent RPC calls.
2026-05-02 17:22:51 +06:00
bb582deefa fix: prevent sandbox halt after resume by fixing HTTP/2 HOL blocking and adding timeouts
Disable HTTP/2 on both host agent server and CP→agent transport — multiplexing
caused head-of-line blocking when a slow sandbox RPC stalled the shared connection.
Add ResponseHeaderTimeout to envd HTTP clients. Merge SetDefaults into Resume's
PostInit call to eliminate an extra round-trip that could hang on a stale connection.
2026-05-02 13:48:51 +06:00
7ef9a64613 fix: close stale TCP connections across snapshot/restore to prevent envd hangs
After Firecracker snapshot restore, zombie TCP sockets from the previous
session cause Go runtime corruption inside the guest VM, making envd
unresponsive. This manifests as infinite loading in the file browser and
terminal timeouts (524) in production (HTTP/2 + Cloudflare) but not locally.

Four-part fix:
- Add ServerConnTracker to envd that tracks connections via ConnState callback,
  closes idle connections and disables keep-alives before snapshot, then closes
  all pre-snapshot zombie connections on restore (while preserving post-restore
  connections like the /init request)
- Split envdclient into timeout (2min) and streaming (no timeout) HTTP clients;
  use streaming client for file transfers and process RPCs
- Close host-side idle envdclient connections before PrepareSnapshot so FIN
  packets propagate during the 3s quiesce window
- Add StreamingHTTPClient() accessor; streaming file transfer handlers in
  hostagent use it instead of the timeout client
2026-05-02 05:19:37 +06:00
f3572f7356 Fix empty WRENN_TEMPLATE_ID after resuming paused sandbox
Resume() was building VMConfig without TemplateID, so Firecracker MMDS
received an empty string. envd's PostInit then wrote that empty value to
/run/wrenn/.WRENN_TEMPLATE_ID. Fix by persisting the template ID in
snapshot metadata during Pause and reading it back during Resume.
2026-05-02 04:57:08 +06:00
2e998a26a2 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-05-01 15:01:32 +06:00
f3ec626d58 Envd version bump 2026-05-01 14:59:37 +06:00
f4733e2f7a Version bump 2026-04-25 04:49:17 +06:00
cdacc12a48 Merge pull request 'Fixed network throttle when an application is running' (#37) from fix/network-throttle-on-load into dev
Reviewed-on: #37
2026-04-24 22:43:31 +00:00
bd98610153 fix: sandbox network responsiveness under port-binding apps
Running port-binding applications (Jupyter, http.server, NextJS) inside
sandboxes caused severe PTY sluggishness and proxy navigation errors.

Root cause: the CP sandbox proxy and Connect RPC pool shared a single
HTTP transport. Heavy proxy traffic (Jupyter WebSocket, REST polling)
interfered with PTY RPC streams via HTTP/2 flow control contention.

Transport isolation (main fix):
- Add dedicated proxy transport on CP (NewProxyTransport) with HTTP/2
  disabled, separate from the RPC pool transport
- Add dedicated proxy transport on host agent, replacing
  http.DefaultTransport
- Add dedicated envdclient transport with tuned connection pooling
- Replace http.DefaultClient in file streaming RPCs with per-sandbox
  envd client

Proxy path rewriting (navigation fix):
- Add ModifyResponse to rewrite Location headers with /proxy/{id}/{port}
  prefix, handling both root-relative and absolute-URL redirects
- Strip prefix back out in CP subdomain proxy for correct browser
  behavior
- Replace path.Join with string concat in CP Director to preserve
  trailing slashes (prevents redirect loops on directory listings)

Proxy resilience:
- Add dial retry with linear backoff (3 attempts) to handle socat
  startup delay when ports are first detected
- Cache ReverseProxy instances per sandbox+port+host in sync.Map
- Add EvictProxy callback wired into sandbox Manager.Destroy

Buffer and server hardening:
- Increase PTY and exec stream channel buffers from 16 to 256
- Add ReadHeaderTimeout (10s) and IdleTimeout (620s) to host agent
  HTTP server

Network tuning:
- Set TAP device TxQueueLen to 5000 (up from default 1000)
- Add Firecracker tx_rate_limiter (200 MB/s sustained, 100 MB burst)
  to prevent guest traffic from saturating the TAP
2026-04-25 04:21:55 +06:00
5e13879954 fix: OAuth ConnectProvider state HMAC format mismatch
ConnectProvider computed HMAC over bare state, but Callback always
verifies HMAC(state+":"+intent). This caused the account-linking
flow to always fail with invalid_state.
2026-04-25 02:00:39 +06:00
339cd7bee1 fix: security and stability fixes from code review
- Scope WebSocket auth bypass to only WS endpoints by restructuring
  routes into separate chi Groups. Non-WS routes no longer passthrough
  unauthenticated requests with spoofed Upgrade headers. Added
  optionalAPIKeyOrJWT middleware for WS routes (injects auth context
  from API key/JWT if present, passes through otherwise) and
  markAdminWS middleware for admin WS routes.

- Fix nil pointer dereference in envd Handler.Wait() — p.tty.Close()
  was called unconditionally but p.tty is nil for non-PTY processes,
  crashing every non-PTY process exit.

- Fix goroutine leak in sandbox Pause — stopSampler was never called,
  leaking one sampler goroutine per successful pause operation.

- Decouple PTY WebSocket reads from RPC dispatch using a buffered
  channel to prevent backpressure-induced connection drops under fast
  typing. Includes input coalescing to reduce RPC call volume.
2026-04-24 15:48:38 +06:00
153a54fdcd Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-04-21 16:11:59 +06:00
c3afd0c8a0 Merge pull request 'Audit logging, Data anonymization, and OAuth flow improvements' (#35) from feat/compliance into dev
Reviewed-on: #35
2026-04-21 10:09:37 +00:00
11928a172a feat: send email notification on account hard-delete
Notify users via email when their account is permanently deleted after
the 15-day soft-delete grace period. Query now returns email alongside
user ID so the notification can be sent after deletion.

Email failure is logged as a warning but does not block cleanup.
2026-04-21 16:01:56 +06:00
bb2146d838 refactor: deduplicate audit logger with shared entry builders
Replace repetitive actorFields + write boilerplate across all 25+ typed
Log methods with shared helpers: newEntry (general), newAdminEntry
(platform-level), resolveHostTeamID, and logSystemHostEvent.

Reduces logger.go from 665 to 374 lines with no behavior change.
2026-04-21 15:54:39 +06:00
d270ab7752 Version bump 2026-04-21 15:54:04 +06:00
7fd801c1eb feat: add audit logging for all admin actions and admin audit page
Log every admin-panel action (user activate/deactivate, team BYOC toggle,
team delete, template delete, build create/cancel) to the audit_logs table
under PlatformTeamID with scope "admin".

Add GET /v1/admin/audit-logs endpoint and /admin/audit frontend page with
infinite scroll and hierarchical filters. Expose audit.Entry + Log() for
cloud repo extensibility.

Fix seed_platform_team down-migration FK violation by deleting dependent
rows before the team row.
2026-04-21 15:41:45 +06:00
edec170652 fix: remove accent gradient bars from admin host dialogs
Normalize admin host page dialogs to match design system pattern:
border + shadow only, no colored gradient strips. Align animation
timing and shadow to reference components (DestroyDialog, etc).
2026-04-21 15:02:09 +06:00
684c98b0fa fix: admin capsule create audit log uses PlatformTeamID
POST /v1/admin/capsules was outside the injectPlatformTeam middleware
subrouter, so audit entries landed under the admin's personal team.
2026-04-21 14:54:52 +06:00
ebbbde9cd1 feat: anonymize audit logs on user hard-delete and fix host audit log team assignment
Anonymize audit logs when soft-deleted users are purged after 15 days:
actor_name set to 'deleted-user', actor_id and resource_id nulled,
email stripped from member metadata. Per-user delete ensures no user
is removed without successful anonymization.

Frontend renders deleted-user as a styled red badge in audit log view.

Fix shared host create/delete audit logs landing in admin's personal
team — now correctly assigned to PlatformTeamID.
2026-04-21 14:42:09 +06:00
6a6b489471 feat: separate GitHub OAuth login/signup flows with name confirmation
Block auto-account creation when signing in via GitHub from login mode.
Signup via GitHub now shows a name confirmation dialog before redirecting
to dashboard, letting users verify/edit their display name pulled from
GitHub.

- Add intent query param to OAuth redirect, persisted in HMAC-signed state cookie
- Block registration in callback when intent=login, return no_account error
- Set wrenn_oauth_new_signup cookie on new account creation
- Frontend callback shows name confirmation dialog for new signups
- Add no_account error message to login page
2026-04-21 11:03:12 +06:00
dbc6030c17 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-04-21 10:09:36 +06:00
9ee6e3e1a8 Merge pull request 'Feat: Added daily usage page' (#34) from feat/usage into dev
Reviewed-on: #34
2026-04-18 08:54:04 +00:00
aa96557d1c Clean up dashboard page headers for consistency
Remove unnecessary wrapper divs around h1/subtitle pairs in audit,
channels, settings, and templates pages. Drop inline count from
channels header.
2026-04-18 14:47:33 +06:00
47be1143fb Add MiddlewareProvider interface for extension middleware
Allows cloud extensions to inject middleware that wraps OSS routes
(e.g. billing enforcement) before they are registered.
2026-04-18 14:47:29 +06:00
8f8638e6db Bump version to 0.1.2 2026-04-18 14:47:25 +06:00
003453fa3c Normalize usage page layout and clarify copy
Separate summary cards with proper surface hierarchy, add staggered
entrance animations, tighten padding, and rewrite labels/descriptions
to be specific and actionable rather than generic.
2026-04-18 14:46:01 +06:00
92aab09104 Add daily usage metrics (CPU-minutes, RAM GB-minutes)
Introduce pre-computed daily usage rollups from sandbox_metrics_snapshots.
An hourly background worker aggregates completed days, while today's
usage is computed live from snapshots at query time for freshness.

Backend: new daily_usage table, rollup worker, UsageService, and
GET /v1/capsules/usage endpoint with date range filtering (up to 92 days).

Frontend: replace Usage page placeholder with bar charts (Chart.js),
summary total cards, and preset/custom date range controls.
2026-04-18 14:29:09 +06:00
e7670e4449 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-04-17 16:41:08 +06:00
955aa09780 Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-04-17 01:24:52 +06:00
ce452c3d11 Merge pull request 'Improved codebase to prepare for production' (#32) from chore/hardening into dev
Reviewed-on: #32
2026-04-16 13:00:06 +00:00
ab034062d3 Merge branch 'dev' into chore/hardening 2026-04-16 12:58:48 +00:00
24f904fa74 Add +page.js to disable prerendering for admin capsule detail page 2026-04-16 18:38:03 +06:00
cc63ed2197 Minor patch 2026-04-16 18:14:50 +06:00
9c4fea93bc Added host preparation script and updated claude md 2026-04-16 16:56:04 +06:00
977c3a466a Shrink minimal rootfs on graceful host agent shutdown
On startup EnsureImageSizes expands the minimal rootfs to the configured
disk size. This adds the inverse: ShrinkMinimalImage runs e2fsck + resize2fs -M
during graceful shutdown so the image is stored compactly on disk.
2026-04-16 16:26:50 +06:00
e6e3975426 Add unauthenticated /health endpoint to control plane
Returns JSON with status and build version for monitoring and
load balancer health checks.
2026-04-16 16:13:42 +06:00
bba5f80294 Add production file logging with logrotate support
Both control plane and host agent now write structured slog output to
$WRENN_DIR/logs/ in addition to stderr. Log level is configurable via
LOG_LEVEL env var (default: info). SIGHUP reopens the log file so
logrotate can rotate without copytruncate.
2026-04-16 15:09:26 +06:00
44c32587e3 Cap network slot allocator at 32767 to match veth IP space
The veth addressing uses 10.12.0.0/16 with 2 IPs per slot. At slot
index 32768, vethOffset=65536 overflows byte arithmetic and wraps back
to 10.12.0.0, causing silent IP collisions with existing sandboxes.
Cap the allocator at 32767, which is the actual addressable limit.
2026-04-16 14:57:44 +06:00
b9aa444472 Merge pull request 'Bug fixes and optimizations' (#31) from fix/optimizations into dev
Reviewed-on: #31
2026-04-16 00:39:47 +00:00
fb4b67adb3 Destroy owned sandboxes on user disable and fix OAuth login resilience
When an admin disables a user, all active sandboxes (running, paused,
hibernated) for teams they own are now destroyed and their API keys
are deleted. User queries now filter by status column instead of
deleted_at, so re-enabling a user always works. OAuth login paths
use ensureDefaultTeam to auto-create a team if the user has none,
matching the email/password login behavior.
2026-04-16 06:37:51 +06:00
9ea847923c Fix concurrency, security, and correctness issues across backend and frontend
- C1: Add sync.RWMutex to vm.Manager to protect concurrent vms map access
- H1: Fix IP arithmetic overflow in network slot addressing (byte truncation)
- H5: Fix MultiplexedChannel.Fork() TOCTOU race (move exited check inside lock)
- H8: Remove snapshot overwrite — return template_name_taken conflict instead
- H9: Wrap DeleteAccount DB ops in a transaction, make team deletion fatal
- H10: Sanitize serviceErrToHTTP to stop leaking internal error messages
- H11: Add deleted_at IS NULL to GetUserByEmail/GetUserByID queries
- H12: Add id DESC to audit log composite index for cursor pagination
- H15: Delete dead AuthModal.svelte component
- H17: Move JWT from WebSocket URL query param to first WS message
- H18: Fix $derived to $derived.by in FilesTab breadcrumbs
2026-04-16 06:11:42 +06:00
ed2222c80c Move sidebar into layout files and fix timer cleanup across frontend
Sidebar and AdminSidebar were re-instantiated on every page navigation
(17 pages total), causing unnecessary DOM teardown/rebuild and redundant
localStorage reads. Now each lives in its respective +layout.svelte as a
single persistent instance.

Also adds onDestroy cleanup for leaked timers (settings, team, login RAF
loop) and CSS containment on <main> to isolate layout recalculations.
2026-04-16 05:34:47 +06:00
e91109d69c Fix API key cleanup on user deactivation and build archive race condition
Delete all API keys created by a user when their account is disabled,
deleted, or soft-deleted. Store build archives before enqueuing to Redis
so workers never dequeue a build with missing files.
2026-04-16 05:29:02 +06:00
451d0819cc Merge pull request 'Added settings for users and proper email flow for authentication' (#30) from feat/user-onboarding into dev
Reviewed-on: #30
2026-04-15 22:45:30 +00:00
084c6caa7d Redirect authenticated users away from login page 2026-04-16 04:30:25 +06:00
43e838c55c Fix cascading deletion gaps for user and team cleanup
- Add ON DELETE CASCADE to users_teams, oauth_providers, admin_permissions
  and ON DELETE SET NULL (with nullable columns) to team_api_keys.created_by,
  hosts.created_by, host_tokens.created_by so HardDeleteExpiredUsers no longer
  fails with FK violations
- User account deletion now cascades to sole-owned teams via DeleteTeamInternal,
  preventing orphaned teams with live sandboxes after account removal
- ListActiveSandboxesByTeam now includes hibernated sandboxes so their disk
  snapshots are cleaned up during team deletion
- Team soft-delete now hard-deletes sandbox metric points, metric snapshots,
  API keys, and channels to prevent data accumulation on deleted teams
- Extract deleteTeamCore() to deduplicate shared logic across DeleteTeam,
  AdminDeleteTeam, and DeleteTeamInternal
- Fix ListAPIKeysByTeamWithCreator to use LEFT JOIN after created_by became
  nullable, and update handler to read pgtype.Text.String for creator_email
2026-04-16 04:26:48 +06:00
e1b23f3d79 Updated claude md with better design 2026-04-16 04:22:30 +06:00
a3f75300a9 Add email activation flow and replace is_active with status column
Email signup now creates inactive users who must activate via a 30-minute
email token before signing in. Team creation is deferred to first login
after activation, while OAuth users continue to get teams immediately.

- Replace boolean is_active with status column (inactive/active/disabled/deleted)
- Add POST /v1/auth/activate endpoint with Redis-backed token consumption
- Signup returns message instead of JWT, sends activation email
- Login differentiates error messages by user status
- Add confirm password field to signup form
- Add /activate frontend page that auto-logs in on success
- Handle inactive user cleanup on re-signup (30-min cooldown) and OAuth collision
2026-04-16 04:05:41 +06:00
e8a2217247 Add settings page, forgot/reset password flows, and me API client
Adds /dashboard/settings route with profile/password/OAuth/account-deletion
management. Adds /forgot-password and /reset-password routes. Enables sidebar
settings link. Adds typed me.ts API client.
2026-04-16 03:25:03 +06:00
93e6fe8160 Add Wrenn wordmark to email template and improve spacing 2026-04-16 03:24:59 +06:00
f69fa8cded Add /v1/me account management endpoints
Adds self-service endpoints: GET/PATCH/DELETE /v1/me, POST /v1/me/password,
POST /v1/me/password/reset{/confirm}, GET/DELETE /v1/me/providers/{provider}.
Includes OAuth account-linking flow via cookie, hard-delete cleanup goroutine
(24h ticker, 15-day grace period), and OpenAPI spec for all new routes.
2026-04-16 03:24:55 +06:00
bc8348b199 Add DB queries for account self-service
New queries: UpdateUserPassword, SoftDeleteUser, HardDeleteExpiredUsers,
CountUserOwnedTeamsWithOtherMembers, GetOAuthProvidersByUserID, DeleteOAuthProvider.
2026-04-16 03:24:42 +06:00
81715947bb Updated claude md 2026-04-16 02:08:03 +06:00
d705f83b68 Removed unnecessary files and renamed minimal update script 2026-04-16 02:06:39 +06:00
2f0e7fcdc2 Merge pull request 'Added transactional email sending' (#29) from feat/email-transaction into dev
Reviewed-on: #29
2026-04-15 18:56:57 +00:00
970ae2b6b2 Updated email template for optional name 2026-04-16 00:54:38 +06:00
ded9c15f06 minor changes 2026-04-16 00:54:20 +06:00
9d68eb5f00 Add transactional email system via SMTP
Introduce internal/email package with SMTP sending, embedded HTML/text
templates, and multipart MIME assembly. Emails use a generic EmailData
struct (recipient name, message, optional button, optional closing) so
new email types can be added without code changes.

Wired into signup (welcome email), team creation, and team member
addition. No-op mailer when SMTP_HOST is not configured.
2026-04-16 00:46:08 +06:00
700512b627 Updated letter-spacing 2026-04-15 22:38:19 +06:00
d1975089f1 Merge pull request 'Added metadata tracking for binaries and refactored to maintain a separate cloud version' (#28) from feat/meta-versioning into dev
Reviewed-on: #28
2026-04-15 15:44:20 +00:00
a5ad3731f2 Refactored to maintain a separate cloud version
Moves 12 packages from internal/ to pkg/ (config, id, validate, events, db,
auth, lifecycle, scheduler, channels, audit, service) so they can be imported
by the enterprise repo as a Go module dependency.

Introduces pkg/cpextension (shared Extension interface + ServerContext) and
pkg/cpserver (Run() entrypoint with functional options) so the enterprise
main.go can call cpserver.Run(cpserver.WithExtensions(...)) without duplicating
the 20-step server bootstrap. Adds db/migrations/embed.go for go:embed access
to OSS SQL migrations from the enterprise module.

cmd/control-plane/main.go is reduced to a 10-line wrapper around cpserver.Run.
2026-04-15 21:41:48 +06:00
11d746dcfc Merge pull request 'Fixed issues with code interpreter' (#27) from fix/code-interpreter into dev
Reviewed-on: #27
2026-04-15 12:56:18 +00:00
5f877afb9e Remove PTY inactivity timeout to keep terminal sessions alive indefinitely
Sessions now only end on process exit or explicit kill, not idle time.
The keepalive ping every 30s remains to prevent network-level disconnects.
2026-04-15 18:31:48 +06:00
5b4fde055c Fix build recipe execution and flatten reliability
- Set HOME in bctx.EnvVars when USER switches so ~ expands correctly in
  subsequent RUN/WORKDIR steps instead of resolving to /root
- Run /bin/sync inside the guest before FlattenRootfs destroys the VM,
  preventing pip-installed files from being captured as 0-byte due to
  unflushed page cache
- Wrap healthcheck command with su <user> so it runs with the template's
  default user context (correct HOME, correct UID)
- Export Shellescape from the recipe package for use in build service
- Add code-runner-beta recipe (Jupyter server with ipykernel --sys-prefix)
  and replace old python-interpreter-v0-beta
2026-04-15 18:24:54 +06:00
59507d7553 Merge pull request 'Added teams and users pages to admin panel' (#26) from feat/admin-panel into dev
Reviewed-on: #26
2026-04-14 22:00:40 +00:00
a265c15c4d Add admin user management with is_active enforcement
Admin users page at /admin/users with paginated user list showing name,
email, team counts, role, join date, and active status toggle. Inactive
users are blocked from all authenticated endpoints immediately via DB
check in JWT middleware. OAuth login errors now show human-readable
messages on the login page.
2026-04-15 03:58:44 +06:00
d332630267 Add admin teams management page
Admin panel now includes a Teams page with paginated listing of all teams
(including soft-deleted), BYOC enable with confirmation dialog, and team
deletion with active capsule warnings. Shows member count, owner info,
active capsules, and channel count per team.
2026-04-15 03:36:37 +06:00
587f6ed8ad Merge pull request 'Implemented least-loaded host scheduler with bottleneck-first strategy' (#25) from feat/host-scheduler into dev
Reviewed-on: #25
2026-04-14 21:03:25 +00:00
82d281b5b5 Implement least-loaded host scheduler with bottleneck-first strategy
Replace round-robin scheduling with resource-aware host selection that
picks the host with the most headroom at its tightest resource. Extends
the HostScheduler interface with memory/disk params for admission control.
2026-04-15 03:02:29 +06:00
17d5d07b3a Removed unused env vars from env example 2026-04-15 02:19:28 +06:00
71b87020c9 Remove redundant comments from login page glow animation 2026-04-14 04:32:17 +06:00
516890c49a Add background process execution API
Start long-running processes (web servers, daemons) without blocking the
HTTP request. Leverages envd's existing background process support
(context.Background(), List, Connect, SendSignal RPCs) and wires it
through the host agent and control plane layers.

New API surface:
- POST /v1/capsules/{id}/exec with background:true → 202 {pid, tag}
- GET /v1/capsules/{id}/processes → list running processes
- DELETE /v1/capsules/{id}/processes/{selector} → kill by PID or tag
- WS /v1/capsules/{id}/processes/{selector}/stream → reconnect to output

The {selector} param auto-detects: numeric = PID, string = tag.
Tags are auto-generated as "proc-" + 8 hex chars if not provided.
2026-04-14 03:57:01 +06:00
962860ba74 Pre-pause snapshot signal to prevent Go runtime crash on restore
envd crashes with "fatal error: bad summary data" after Firecracker
snapshot/restore because the page allocator radix tree is inconsistent
when vCPUs are frozen mid-allocation. The port scanner goroutine
allocates heavily every second, making it the primary trigger.

Add POST /snapshot/prepare to envd — the host agent calls it before
vm.Pause to quiesce continuous goroutines and force GC. On restore,
PostInit restarts the port subsystem via the existing /init endpoint.

- New PortSubsystem abstraction with Start/Stop/Restart lifecycle
- Context-based goroutine cancellation (replaces irreversible channel close)
- Context-aware Signal to prevent scanner/forwarder deadlock
- Fix forwarder goroutine leak (was spinning forever on closed channel)
- Kill socat children on stop to prevent orphans across snapshots
- Fix double cmd.Wait panic (exec.Command instead of CommandContext)
2026-04-13 05:21:10 +06:00
117c46a386 Fix: Auto-admin didn't work for oauth users 2026-04-13 05:00:37 +06:00
d828a6be08 Normalize dashboard page headers: add divider line and align button layout
Add consistent mt-6 border-b divider to Capsules, Metrics, and Templates
headers. Align Channels header to match Keys page pattern (items-center,
description inside the title group).
2026-04-13 04:59:40 +06:00
bbdb44afee Merge pull request 'Added manual template building' (#24) from feat/admin-panel into dev
Reviewed-on: #24
2026-04-12 22:44:39 +00:00
784fe5c7a8 Polish admin capsule pages and improve shared components
- Admin list: remove redundant Open button, normalize with dashboard
  patterns (sorting, search highlight, auto-refresh, animations)
- Admin detail: breadcrumb header, status bar, visibility polling
- FilesTab: add treeOnly prop, compact mode uses 2/7 tree + 5/7 preview
  split, expand tree to full width when no file selected, improve copy
- MetricsPanel: hide Live badge in compact layout (redundant with status)
- DestroyDialog: accept destroyFn prop for admin capsule deletion
2026-04-13 04:41:51 +06:00
60c0de670c Extract MetricsPanel component and use it in admin capsule detail page
Moves all Chart.js metrics logic (polling, smoothing, chart init/update)
into a reusable MetricsPanel component with 'full' and 'compact' layout
modes. The admin capsule detail page now reuses MetricsPanel, TerminalTab,
and FilesTab — no duplicated code.
2026-04-13 04:16:53 +06:00
90bea52ccd Add admin capsule management, fix file browser for special files, normalize dialog styles
- Admin capsule CRUD: list, create (platform templates), get detail with
  terminal/files/metrics, snapshot, destroy
- First signup auto-promotes to platform admin
- JWT auth via query param for WebSocket connections
- File browser: handle non-regular files (devices, pipes, sockets) gracefully
  instead of showing raw backend errors
- Normalize admin template dialogs to match established dialog patterns:
  remove accent bars, unify animation/shadow/button styles
2026-04-13 04:12:36 +06:00
f920023ecf Block download for non-regular files in file browser
Disable the download button for symlinks and show a dedicated
preview pane explaining the symlink target and suggesting to
navigate to the target file instead. Guard handleDownload against
non-file types as a safety net.
2026-04-13 02:57:38 +06:00
19ddb1ab8b Normalize dialog styles across capsules and templates pages
Aligned all dialog boxes to a consistent pattern: same shadow
(--shadow-dialog), animation (fadeUp 0.2s ease), button sizing
(py-2, duration-150), and hover effects. Added template type
indicator dot to CreateCapsuleDialog combobox. Removed accent
gradient bars from templates page inline dialogs.
2026-04-13 02:48:58 +06:00
5633957b51 Explicit write when mounting rootfs for updates 2026-04-13 02:38:09 +06:00
eb47e22496 Merge pull request 'Fixed crash on non-regular files and connection leaks' (#23) from hotfix/file-browsing-error-for-dev into dev
Reviewed-on: #23
2026-04-12 20:12:46 +00:00
b1595baa19 Updated env.example 2026-04-13 02:10:43 +06:00
da06ecb97b Fix file browser crash on non-regular files and connection leaks
- envd: reject non-regular files (devices, pipes, sockets) in GetFiles
  to prevent infinite reads from /dev/zero, /dev/urandom etc.
- host agent: add context cancellation check in ReadFileStream loop
  with proper Connect error codes
- frontend: abort in-flight file reads on file switch, directory
  navigation, and component teardown via AbortController
- frontend: guard against abort errors surfacing in UI, use try/finally
  for fileLoading state
2026-04-13 02:09:50 +06:00
0d5007089e Merge pull request 'Updated dependencies and fixed breaking changes' (#22) from fix/dependency-updates into dev
Reviewed-on: #22
2026-04-12 18:26:57 +00:00
0e7b198768 Bump netlink v1.3.1 and netns v0.0.5
Fixes resource leaks in named namespace handlers, adds IFF_RUNNING
flag deserialization and RouteGetWithOptions.
2026-04-13 00:13:40 +06:00
9ad704c12b Update CP listen port to 9725 and public URL to app.wrenn.dev 2026-04-13 00:01:59 +06:00
0189d030bb Bump frontend and Go x/ dependencies
- vite 7→8, @sveltejs/vite-plugin-svelte 6→7, typescript 5→6
- golang.org/x/crypto v0.49→v0.50, golang.org/x/sys v0.42→v0.43 (both modules)
2026-04-13 00:01:53 +06:00
7b853a05ba Update pgx/v5 from v5.8.0 to v5.9.1
Picks up timestamp scan optimizations, ContextWatcher goroutine leak
fix, and stdlib ResetSession connection pool fix.
2026-04-12 22:50:28 +06:00
108b68c3fa Updated gitignore 2026-04-12 22:24:54 +06:00
565817273d Rename API routes /v1/sandboxes → /v1/capsules 2026-04-12 21:51:04 +06:00
ea65fb584c Merge pull request 'Completed template build for admins' (#21) from feat/admin-template-build into dev
Reviewed-on: #21
2026-04-11 21:41:18 +00:00
25b5258841 COPY multi-source support, configurable rootfs size, build fixes
- COPY now supports multiple sources: COPY a.txt b.txt /dest/
  Last argument is always destination (matches Dockerfile semantics).
- COPY resolves relative destinations against current WORKDIR.
- WRENN_DEFAULT_ROOTFS_SIZE env var (e.g. 5G, 2Gi, 1000M, 512Mi)
  controls template rootfs expansion. Used both at agent startup
  (EnsureImageSizes) and after FlattenRootfs (shrink then re-expand).
- Pre-build now sets WORKDIR /home/wrenn-user after USER switch.
- Extracted archive files get chmod a+rX for readability.
- Path traversal validation on COPY sources.
2026-04-12 03:39:17 +06:00
46c43b95c2 Visual polish 2026-04-12 02:44:40 +06:00
000318f77e Fix runtime env leaking into templates, add hostname to /etc/hosts
- Filter out user-specific env vars (HOME, USER, LOGNAME, SHELL, etc.)
  from template default_env so they don't override envd's per-user
  resolution. Fixes bash sourcing /root/.bashrc as wrenn-user.
- Keep WRENN_SANDBOX (legitimate runtime flag), only filter per-sandbox
  IDs (WRENN_SANDBOX_ID, WRENN_TEMPLATE_ID).
- Add "127.0.0.1 sandbox" to /etc/hosts in wrenn-init.sh so sudo can
  resolve the hostname. Fixes "unable to resolve host sandbox" error.
- Move capsule lifecycle buttons (Pause/Resume/Snapshot/Destroy) to the
  same row as Stats/Files/Terminal tabs.
- Show vCPU/Memory for all template types with Required/Recommended
  tooltips on the user templates page.
2026-04-12 02:43:09 +06:00
f5eeb0ffcc Rename /dashboard/snapshots to /dashboard/templates, show specs for all template types
- Rename snapshots route to templates for consistency with sidebar label
- Show vCPU and Memory values for base templates (not just snapshots),
  with tooltip distinguishing "Required" vs "Recommended"
- Show recipe copy button in admin build logs
- Admin panel defaults to /admin/templates on entry
- WORKDIR creates directory if not present (mkdir -p)
- Use USER command in pre-build instead of raw adduser
- Fix Svelte whitespace stripping in step keyword display
2026-04-12 02:22:43 +06:00
75af2a4f66 Add USER, COPY, ENV persistence to template build system
Implement three new recipe commands for the admin template builder:

- USER <name>: creates the user (adduser + passwordless sudo), switches
  execution context so subsequent RUN/START commands run as that user
  via su wrapping. Last USER becomes the template's default_user.

- COPY <src> <dst>: copies files from an uploaded build archive
  (tar/tar.gz/zip) into the sandbox. Source paths validated against
  traversal. Ownership set to the current USER.

- ENV persistence: accumulated env vars stored in templates.default_env
  (JSONB) and injected via PostInit when sandboxes are created from the
  template, mirroring Docker's image metadata approach.

Supporting changes:
- Pre-build creates wrenn-user as default (via USER command)
- WORKDIR now creates the directory if it doesn't exist (mkdir -p)
- Per-step progress updates (ProgressFunc callback) for live UI
- Multipart form support on POST /v1/admin/builds for archive upload
- Proto: default_user/default_env fields on Create/ResumeSandboxRequest
- Host agent: SetDefaults calls PostInitWithDefaults on envd
- Control plane: reads template defaults, passes on sandbox create/resume
- Frontend: file upload widget, recipe copy button, keyword colors for
  USER/COPY, fixed Svelte whitespace stripping in step display
- Admin panel defaults to /admin/templates instead of /admin/hosts
- Migration adds default_user and default_env to templates and
  template_builds tables
2026-04-12 02:10:01 +06:00
f6c3dc0801 Merge pull request 'bugfix: preserve agent gRPC status codes and map AlreadyExists to 409 Conflict' (#20) from bugfix/mkdir-already-exists-409 into dev
Reviewed-on: #20
2026-04-11 17:59:16 +00:00
f5a9a1209f fix: map CodeAlreadyExists to HTTP 409 Conflict
Updated the `agentErrToHTTP` switch statement to explicitly catch
`connect.CodeAlreadyExists` (as well as
`connect.CodeFailedPrecondition`)
and return `http.StatusConflict` (409) instead of falling through to the

default 502 Bad Gateway.
2026-04-11 23:54:48 +06:00
8d0356e372 fix: stop overwriting agent gRPC errors with CodeInternal
Removed the `connect.NewError(connect.CodeInternal, ...)` wrapper in the
Server's MakeDir proxy handler. Previously, this wrapper was catching
specific agent errors (like CodeAlreadyExists) and casting them into
generic Code 13 (Internal) errors, stripping the gRPC metadata.

This change allows the control-plane to act as a transparent pipeline,
ensuring the API gateway can properly interpret and route specific
filesystem failures.
2026-04-11 23:54:23 +06:00
c3c9ced9dd Remove API key auth requirement for sandbox port proxy connections
Sandbox URLs ({port}-{sandbox_id}.{domain}) are now accessible without
authentication. The sandbox ID in the hostname is sufficient for routing.
2026-04-11 13:59:07 +06:00
7d0a21644f Merge pull request 'Visual optimizations for the web UI' (#19) from fix/optimizations into dev
Reviewed-on: #19
2026-04-11 02:24:01 +00:00
26917d432d Add syntax highlighting to file browser, harden capsules list
File browser:
- Add shiki-based syntax highlighting (lazy-loaded, zero initial bundle
  impact) with support for 30+ languages
- Cap highlighting at 2000 lines to avoid freezing on large files
- Pre-compute preview lines as derived state instead of re-splitting
  on every render
- Add content-visibility: auto on code lines for off-screen skip
- Remove per-line CSS transitions (unnecessary paint on 5000 elements)
- Cap row entrance animations to first 30 entries

Capsules list:
- Pause auto-refresh polling when browser tab is hidden
- Add empty state for search with no results
- Fix error state not clearing on successful refresh
- Fix action menu positioning near viewport edges
- Disable create button when no template selected
2026-04-11 07:49:11 +06:00
430fb9e70e Add per-provider brand colors to channels page
Give each provider (Discord, Slack, Teams, Google Chat, Telegram,
Matrix, Webhook) its own distinctive color for badges, row hover
stripes, and dialog tags. Move channel count into the header as a
serif numeral for stronger typographic hierarchy.
2026-04-11 07:14:13 +06:00
0807946d45 Replace template text input with searchable combobox, lock specs for snapshots
Template field is now a filterable dropdown that fetches available
templates on dialog open. Selecting a snapshot auto-fills and disables
vCPU/memory inputs since they must match the original capsule config.
2026-04-11 07:00:59 +06:00
11ca6935a6 Skip row fly-transitions on template filter change to prevent visual flicker
After initial page load animations complete, subsequent filter switches
render instantly (duration: 0) instead of replaying staggered fly-in/out
transitions that caused all rows to flash before filtering took effect.
2026-04-11 06:48:50 +06:00
e2f869bfc2 Minor textual change 2026-04-11 06:23:31 +06:00
21b82c2283 Optimize frontend polling: visibility API, range-based intervals, skip redundant redraws
Adds Page Visibility API to StatsPanel, templates, and capsule detail
pages so polling pauses when the browser tab is hidden. Capsule metrics
now use range-appropriate poll intervals (10s for 5m/10m, up to 120s for
24h) instead of a flat 10s. Chart updates are skipped when the data
fingerprint hasn't changed, avoiding unnecessary Canvas redraws.
2026-04-11 06:20:29 +06:00
dbad418093 Harden channels page: deduplicate dropdowns, add missing provider logos
Consolidate three identical click-outside $effect blocks into a reusable
useClickOutside helper. Extract duplicated events checkbox list into an
eventsDropdownItems snippet shared by create and edit dialogs. Add brand
SVG icons for Teams, Google Chat, and Matrix providers.
2026-04-11 06:18:36 +06:00
2bad843069 Extract SnapshotDialog and DestroyDialog into reusable components
Add lifecycle buttons (pause, resume, snapshot, destroy) to the
individual capsule detail page and refactor both the list and detail
pages to share the new dialog components.
2026-04-11 06:08:19 +06:00
9332f4ac18 Merge pull request 'Terminal connection (PTY)' (#18) from feat/ssh-connection into dev
Reviewed-on: #18
2026-04-10 23:45:10 +00:00
cf191ca821 Harden file browser: cap preview lines, fix race conditions, download UX
- Cap text preview at 5,000 lines with truncation footer and download link
  to prevent browser freeze on large files (300k+ DOM nodes)
- Add request generation counters to discard stale API responses from
  rapid directory/file clicking
- Guard initial $effect with hasInitiallyLoaded to prevent double-load
- Add download loading state with spinner and disabled button
- Delay URL.revokeObjectURL by 5s so browser can start download
2026-04-11 05:43:32 +06:00
d2202c4f49 Harden terminal: binary-safe base64, auto-reconnect, session limits
- Replace btoa/atob with TextEncoder/TextDecoder for binary-safe base64
  encoding — fixes crash on multi-byte UTF-8 input (emoji, CJK, accents)
- Auto-reconnect on abnormal WebSocket close while session is live
- Cap concurrent sessions at 8 with disabled "+" button at limit
- Guard all ws.send() calls with try/catch via wsSend() wrapper
- Clean up input flush timer on session close and component destroy
- Close all sessions when capsule stops running (isRunning → false)
- Clean up orphaned display entry if DOM container fails to render
2026-04-11 05:35:53 +06:00
1826af37a5 Increase multiplexer fork buffer to 4096 to prevent output drops
64-entry buffer was too small for high-throughput PTY output (e.g.
ls -laihR /). The consumer couldn't drain fast enough over the RPC
stream, causing the non-blocking send fallback to silently discard
data. 4096 entries (~64MB at 16KB/chunk) handles sustained output
without drops while still preventing deadlock on stuck consumers.
2026-04-11 05:16:43 +06:00
acc721526d Polish terminal tab: merge status bar into tab strip, normalize sizing
- Merge separate status bar into unified tab bar (one row of chrome instead of two)
- Bump font/button/icon sizes to match rest of capsule page
- VS Code-style tab separators with intelligent hiding around active tab
- Hide tab bar when no sessions exist (empty state has its own CTA)
- Fix xterm background gaps by painting viewport/screen backgrounds
- Increase terminal font from 13px to 14px
2026-04-11 05:10:46 +06:00
4b2ff279f7 Add terminal tab to capsule detail page and fix envd process lookup bugs
- Add multi-session Terminal tab with xterm.js (session tabs, close, reconnect)
- Keep terminal mounted across tab switches to preserve sessions
- Persist active tab in URL (?tab=terminal) so refresh stays on terminal
- Buffer keystrokes (50ms) to reduce per-character RPC overhead
- Add WebSocket auth via ?token= query param for browser WS connections
- Enable ws:true in Vite dev proxy for WebSocket support

envd fixes (pre-existing bugs exposed by multi-session terminals):
- Fix getProcess tag Range: inverted return values caused early stop when
  multiple tagged processes existed, making SendInput fail with "not found"
- Fix multiplexer deadlock: blocking send to cancelled fork's unbuffered
  channel prevented process cleanup. Now uses buffered channels (cap 64)
  with non-blocking fallback
2026-04-11 04:27:16 +06:00
ab3fc4a807 Add interactive PTY terminal sessions for sandboxes
Wire envd's existing PTY process capabilities through the full stack:
hostagent proto (4 new RPCs: PtyAttach, PtySendInput, PtyResize, PtyKill),
envdclient, sandbox manager, and a new WebSocket endpoint at
GET /v1/sandboxes/{id}/pty with bidirectional JSON message protocol.

Sessions use tag-based identity for disconnect/reconnect support,
base64-encoded PTY data for binary safety, and a 120s inactivity timeout.
2026-04-11 02:42:59 +06:00
09f030d202 Replace file browser not-running state with centered empty state
The small bordered card looked broken and misaligned — now uses a
full-width centered layout with floating icon, matching the app's
empty-state pattern.
2026-04-10 23:32:17 +06:00
43c15c86de Merge pull request 'Added browser based filesystem interactions' (#16) from feat/file-interactions into dev
Reviewed-on: #16
2026-04-10 13:40:39 +00:00
851f54a9e1 Polish file browser: add up button, normalize design, improve UX
Add parent directory button in breadcrumb bar, remove redundant ..
row from file list. Normalize styles to use design system tokens
(accent glow, iconFloat, fadeUp). Improve empty states, add staggered
row entrance animation, file extension badge, and clearer UX copy.
2026-04-10 19:24:24 +06:00
4ed17b2776 Fix stale WRENN_SANDBOX_ID and WRENN_TEMPLATE_ID after snapshot restore
After restoring a VM from snapshot, envd had already completed its initial
MMDS poll, so the metadata files in /run/wrenn/ and env vars retained values
from the original sandbox. Call POST /init after WaitUntilReady on both
resume and create-from-template paths to trigger envd to re-read MMDS.
2026-04-10 19:23:48 +06:00
0e6daaabe0 Fix file browser: use ~ as default path, support tilde expansion
- Default to ~ instead of hardcoded /home/user — envd resolves it
  to the actual home dir of the configured user
- Pass ~ and ~/... paths through to envd for server-side expansion
- Resolve actual absolute path from response entries for breadcrumbs
- Fall back to / if home dir is empty or doesn't exist
- Fix leftover label prop on admin templates CopyButton
2026-04-10 19:10:20 +06:00
82531b735c Add Files tab to capsule detail page with file browser and preview
Implements a split-panel file browser: directory tree on the left with
path input and breadcrumb navigation, file preview on the right with
line numbers. Binary/large files (>10MB) show a download prompt instead.

Also adds CopyButton component across capsule, snapshot, and template
pages, and fixes pre-existing type errors in StatsPanel and admin
templates page.
2026-04-10 18:43:11 +06:00
c9283cac70 Add filesystem operations (list, mkdir, remove) across full stack
Plumb ListDir, MakeDir, and RemovePath through all layers:
REST API → host agent RPC → envdclient → envd. These endpoints
enable a web file browser for sandbox filesystem interaction.

New endpoints (all under requireAPIKeyOrJWT):
- POST /v1/sandboxes/{id}/files/list
- POST /v1/sandboxes/{id}/files/mkdir
- POST /v1/sandboxes/{id}/files/remove
2026-04-10 18:05:13 +06:00
c1987b0bda Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-04-10 03:03:04 +06:00
2b31af8fde Merge branch 'main' of git.omukk.dev:wrenn/wrenn into dev 2026-04-10 02:50:50 +06:00
831c898b71 Merge pull request 'Added channels for external notifications' (#13) from feat/channels into dev
Reviewed-on: wrenn/sandbox#13
2026-04-09 19:20:36 +00:00
0f78982186 feat: channel audit logging, name cleaning, message formatting, and dashboard UI
- Add audit log entries for channel create, update, rotate_config, delete
- Clean channel names on create/update (trim, lowercase, spaces → hyphens,
  SafeName validation)
- Format chat notifications with full event details (resource, actor, team,
  timestamp) instead of one-liners
- Fix Discord split-line embeds by setting splitLines=No on shoutrrr URL
- Add channels dashboard page and sidebar navigation
2026-04-10 01:17:03 +06:00
84dd15d22b feat: add notification channels with provider integrations and retry
Implement a channels system for notifying teams via external providers
(Discord, Slack, Teams, Google Chat, Telegram, Matrix, webhook) when
lifecycle events occur (capsule/template/host state changes).

- Channel CRUD API under /v1/channels (JWT-only auth)
- Test endpoint to verify config before saving (POST /v1/channels/test)
- Secret rotation endpoint (PUT /v1/channels/{id}/config)
- AES-256-GCM encryption for provider secrets (WRENN_ENCRYPTION_KEY)
- Redis stream event publishing from audit logger
- Background dispatcher with consumer group and retry (10s, 30s)
- Webhook delivery with HMAC-SHA256 signing (X-WRENN-SIGNATURE)
- shoutrrr integration for chat providers
- Secrets never exposed in API responses
2026-04-09 17:06:06 +06:00
5148b5dd64 Updated CLAUDE.md 2026-04-09 14:28:39 +06:00
37d85ec998 chore: relicense from BSL 1.1 to Apache 2.0
Replace Business Source License with Apache License Version 2.0 across
LICENSE, envd/LICENSE, and NOTICE. Update NOTICE to remove BSL-era
framing that singled out Apache-only portions.
2026-04-09 14:28:19 +06:00
e2beef817d Expose host up/down audit events to BYOC teams and refresh dashboard navigation
Change host marked_down/marked_up audit log scope from "admin" to "team" so
BYOC team members can see when their hosts go unreachable or recover. Rename
BYOC sidebar entry to Hosts, add placeholder billing/usage pages, disable
unimplemented notifications/settings links, and point docs to external site.
2026-04-09 14:24:20 +06:00
a9ca13b238 Changed redis dependency to keydb 2026-04-09 00:47:19 +06:00
e3ffa576ce Fix review findings: IP collision, pause race, proxy path, ENV ordering, conn drain
- Fix IP address collision at slot 32768+ by using bitwise shifts instead of
  byte-truncating division in network slot addressing
- Add per-sandbox lifecycleMu to serialize concurrent Pause/Destroy calls
- Sanitize proxy forwarding path with path.Clean
- Sort ENV keys in recipe shell preamble for deterministic ordering
- Fix ConnTracker goroutine leak by adding cancel channel to Drain/Reset
- Update context_test to assert deterministic ENV ordering
2026-04-08 04:32:41 +06:00
dd50cfdcb1 fix: security hardening from CSO audit
- Add auth failure logging (login, API key, JWT) with IP/email/prefix
- Move OAuth JWT from URL params to short-lived cookies to prevent
  token leakage via browser history, server logs, and Referer headers
- Pin Swagger UI to v5.18.2 with SRI integrity hashes
- Upgrade Go toolchain to 1.25.8 (fixes 5 called stdlib vulns)
- Fix unchecked error in host agent credential refresh
- Add .gstack to .gitignore for security report artifacts
2026-04-08 03:46:31 +06:00
3675ecba65 chore: add gstack skill routing rules to CLAUDE.md 2026-04-08 02:28:02 +06:00
c8615466be Enforce mandatory mTLS for CP↔agent communication
Both the control plane and host agent now refuse to start without valid
mTLS configuration, closing the unauthenticated proxy/RPC attack surface
that existed when running in plain HTTP fallback mode.
2026-04-08 02:25:43 +06:00
2737288a2b Merge pull request 'Changes for a python code interpreter' (#12) from feat/python-code-interpreter into dev
Reviewed-on: wrenn/sandbox#12
2026-04-07 20:18:06 +00:00
0ea0e7cc70 Fix expandEnv regex, init script crash, healthcheck deadline, and test issues
- Fix envRegex: remove spurious (\$)? group that swallowed $$$, handle ${}
- wrenn-init.sh: add || true to networking commands under set -e, remove dead code
- waitForHealthcheck: use context deadline for unlimited retries instead of implicit 100 cap
- Make parseSandboxEnv a package-level function (unused receiver)
- Fix WrappedCommand test: map iteration order dependency, pre-expand env values
- Fix error wrapping: %v → %w per project conventions
- test-jupyter-kernel.py: move import to top-level, fix misleading comment
2026-04-08 02:14:53 +06:00
11e08e5b96 Merge branch 'dev' into feat/python-code-interpreter 2026-04-07 19:35:55 +00:00
4dc8cc3867 Removed incorrect example cert format 2026-04-07 19:35:26 +00:00
9852f96127 Modified expandEnv to use regex.
Updated recipefile with test script to check code execution with state
management
2026-04-07 22:56:56 +06:00
bf05677bef Merge branch 'dev' into feat/python-code-interpreter 2026-04-06 20:45:54 +00:00
4f340b8847 feat: add env expansion, sandbox env fetching, and configurable
healthchecks

Fix ENV instructions to expand $VAR references at set time using the
current env state, preventing self-referencing values like
PATH=/opt/venv/bin:$PATH from producing recursive expansions. Remove
expandEnv from shellPrefix to avoid double expansion.

Fetch sandbox environment variables via `env` before recipe execution
so ENV steps resolve against actual runtime values from the base
template image.

Replace hardcoded healthcheck timing with a Dockerfile-like flag parser
supporting --interval, --timeout, --start-period, and --retries. Add
start-period grace window and bounded retry counting to
waitForHealthcheck.

Add python-interpreter-v0-beta recipe and healthcheck files.
2026-04-07 01:15:43 +06:00
f57fe85492 Merge pull request 'Minor temporary fix for sitewide metrics' (#11) from patch/analytics into dev
Reviewed-on: wrenn/sandbox#11
2026-04-04 07:11:49 +00:00
9a52b47786 Minor temporary fix for sitewide metrics 2026-04-04 13:11:18 +06:00
ab38c8372c Merge pull request 'Feature: HTTP communication with sandbox' (#10) from code-interpreter into dev
Reviewed-on: wrenn/sandbox#10
2026-04-02 17:41:07 +00:00
8b5fa3438e Replace gopsutil port scanner with direct /proc/net/tcp reading
The envd port scanner used gopsutil's net.Connections() which walks
/proc/{pid}/fd to enumerate socket inodes. This corrupts Go runtime
semaphore state when the VM is paused mid-operation and restored from
a Firecracker snapshot.

Replace with a direct /proc/net/tcp + /proc/net/tcp6 parser that reads
a single file per address family — no /proc/{pid}/fd walk, no goroutines,
no WaitGroups. Also replace concurrent-map (smap) in the scanner with a
plain sync.RWMutex-protected map, since concurrent-map's Items() spawns
goroutines with a WaitGroup internally, which is equally unsafe across
snapshot boundaries.

Use socket inode instead of PID for the port forwarding map key, since
inode is available directly from /proc/net/tcp without the fd walk.
2026-04-01 15:47:28 +06:00
2b4c5e0176 Add pre-pause proxy connection drain and sandbox proxy caching
Introduce ConnTracker (atomic.Bool + WaitGroup) to track in-flight proxy
connections per sandbox. Before pausing a VM, the manager drains active
connections with a 2s grace period, preventing Go runtime corruption
inside the guest caused by stale TCP state surviving Firecracker
snapshot/restore.

Also add:
- AcquireProxyConn on Manager for atomic lookup + connection tracking
- Proxy cache (120s TTL) on CP SandboxProxyWrapper with single-query
  DB lookup (GetSandboxProxyTarget) to avoid two round-trips
- Reset() on ConnTracker to re-enable connections if pause fails
2026-04-01 15:09:44 +06:00
377e856c8f Fix lint warnings: drop deprecated Name field from snapshot response, check errcheck in benchmark
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:28:57 +06:00
948db13bed Add skip_pre_post build option, cancel endpoint, and recipe package
- skip_pre_post flag on builds bypasses apt update/clean pre/post steps for
  faster iteration when the recipe handles its own environment setup
- POST /v1/admin/builds/{id}/cancel endpoint marks an in-progress build as
  cancelled; UpdateBuildStatus now also sets completed_at for 'cancelled'
- internal/recipe: typed recipe parser and executor (RUN/ENV/COPY steps)
  replacing the raw string slice approach in the build worker
- pre/post build commands prefixed with RUN to match recipe step format
2026-03-30 21:24:52 +06:00
25ce0729d5 Add mTLS to CP→agent channel
- Internal ECDSA P-256 CA (WRENN_CA_CERT/WRENN_CA_KEY env vars); when absent
  the system falls back to plain HTTP so dev mode works without certificates
- Host leaf cert (7-day TTL, IP SAN) issued at registration and renewed on
  every JWT refresh; fingerprint + expiry stored in DB (cert_expires_at column
  replaces the removed mtls_enabled flag)
- CP ephemeral client cert (24-hour TTL) via CPCertStore with atomic hot-swap;
  background goroutine renews it every 12 hours without restarting the server
- Host agent uses tls.Listen + httpServer.Serve so GetCertificate callback is
  respected (ListenAndServeTLS always reads cert from disk)
- Sandbox reverse proxy now uses pool.Transport() so it shares the same TLS
  config as the Connect RPC clients instead of http.DefaultTransport
- Credentials file renamed host-credentials.json with cert_pem/key_pem/
  ca_cert_pem fields; duplicate register/refresh response structs collapsed
  to authResponse
2026-03-30 21:24:35 +06:00
88f919c4ca Rename sandbox prefix to cl-, add MMDS metadata, fix proxy port routing
- Change sandbox ID prefix from sb- to cl- (capsule) throughout
- Fix proxy URL regex character class: base36 uses 0-9a-z, not just hex
- Add MMDS V2 config and metadata to VM boot flow so envd can read
  WRENN_SANDBOX_ID and WRENN_TEMPLATE_ID from inside the guest
- Pass TemplateID through VMConfig into both fresh and snapshot boot paths
2026-03-30 17:12:05 +06:00
8f06fc554a Replace Full snapshot fallback with file-level diff merge
Always use Firecracker Diff snapshots (fast, only changed pages) and
merge diff files at the file level when the generation cap is reached.
The previous approach used Firecracker's Full snapshot type which dumps
all memory to disk and can timeout, losing all snapshot data on failure.

Add snapshot.MergeDiffs() which reads each block from the appropriate
generation's diff file via the header mapping and writes them into a
single consolidated file with a fresh generation-0 header.
2026-03-29 02:33:33 +06:00
1ca10230a9 Prefix network namespaces with wrenn-, add stale cleanup, lower diff cap
Rename ns-{idx} to wrenn-ns-{idx} and veth-{idx} to wrenn-veth-{idx}
to avoid collisions with other tools. Add CleanupStaleNamespaces() at
agent startup to remove orphaned namespaces, veths, iptables rules, and
routes from a previous crash. Lower maxDiffGenerations from 10 to 8 to
prevent Go runtime memory corruption from snapshot/restore drift.
2026-03-29 02:14:30 +06:00
46d60fc5a5 Seed minimal template in DB and protect it from deletion
Insert a minimal template row (all-zeros UUID) so it appears in both
team and admin template listings. Guard delete endpoints to prevent
removal of the minimal template.
2026-03-29 01:34:54 +06:00
906cc42d13 Rename AGENT_*/CP_LISTEN_ADDR env vars to WRENN_* prefix
AGENT_FILES_ROOTDIR → WRENN_DIR, AGENT_LISTEN_ADDR → WRENN_HOST_LISTEN_ADDR,
AGENT_CP_URL → WRENN_CP_URL, AGENT_HOST_INTERFACE → WRENN_HOST_INTERFACE,
CP_LISTEN_ADDR → WRENN_CP_LISTEN_ADDR. Consolidates all env vars under a
consistent WRENN_ namespace.
2026-03-29 00:30:20 +06:00
75b28ed899 Add UUID-based template IDs and team-scoped template directory layout
Introduces internal/layout package for centralized path construction,
migrates templates from name-based TEXT primary keys to UUID PKs with
team-scoped directories (WRENN_DIR/images/teams/{team_id}/{template_id}).
The built-in minimal template uses sentinel zero UUIDs. Proto messages
carry team_id + template_id alongside deprecated template name field.
Team deletion now cleans up template files across all hosts.
2026-03-29 00:30:10 +06:00
03e96629c7 Remove slug from team page UI 2026-03-28 20:45:57 +06:00
34af77e0d8 Fix snapshot race, delete auth, sparse dd, default disk to 5GB
Snapshot race fix:
- Pre-mark sandbox as "paused" in DB before issuing CreateSnapshot and
  PauseSandbox RPCs, preventing the reconciler from marking it "stopped"
  during the flatten window when the sandbox is gone from the host
  agent's in-memory map but DB still says "running"
- Revert status to "running" on RPC failure
- Check ctx.Err() before writing response to avoid writing to dead
  connections when client disconnects during long snapshot operations

Delete auth fix:
- Block non-admin deletion of platform templates (team_id = all-zeros)
  at DELETE /v1/snapshots/{name} with 403, preventing file deletion
  before the team ownership check fails

Sparse dd:
- Add conv=sparse to dd in FlattenSnapshot so flattened images preserve
  sparseness (~200MB actual vs 5GB logical)

Default disk size:
- Change default disk_size_mb from 20GB to 5GB across migration,
  manager, service, build, and EnsureImageSizes
- Disable split-button dropdown arrow for platform templates in
  dashboard snapshots page (teams cannot delete platform templates)
2026-03-28 14:30:18 +06:00
c89a664a37 Switch API ID format from UUID to base36 for compact, E2B-style IDs
DB stays native UUID; the format/parse layer now encodes 16 UUID bytes
as 25-char lowercase alphanumeric (base36) strings instead of the
standard 36-char hex-with-dashes format. e.g. sb-2e5glxi4g3qnhwci95qev0cg0
2026-03-27 00:53:51 +06:00
3509ca90e8 Add pre/post build stages, fix exec timeout, expand guest PATH
Build phases:
- Pre-build (apt update) and post-build (apt clean, autoremove, rm lists)
  run with 10-minute timeout; user recipe commands keep 30s timeout
- Log entries include phase field for UI grouping
- Always send explicit TimeoutSec to host agent (0 defaulted to 30s)

Frontend:
- Pre-build/post-build steps show phase label without exposing commands
- Recipe steps numbered independently starting from 1

Guest PATH:
- Add /usr/games:/usr/local/games to wrenn-init.sh PATH export
  (standard Ubuntu paths, needed for packages like cowsay)
2026-03-27 00:28:32 +06:00
c8acac92cc Add pre/post build stages to template builds
Pre-build: apt update
Post-build: apt clean, apt autoremove, rm apt lists

Total steps count includes pre/post commands for accurate progress bars.
2026-03-27 00:00:48 +06:00
5cb37bf2a0 Add admin template deletion with broadcast to all hosts
- DELETE /v1/admin/templates/{name} endpoint (admin-only)
- Broadcasts DeleteSnapshot RPC to all online hosts before removing DB record
- Frontend admin templates page uses deleteAdminTemplate() instead of
  team-scoped deleteSnapshot()
- Delete button shown for all template types, not just snapshots
2026-03-26 23:53:08 +06:00
c0d6381bbe Add disk_size_mb, auto-expand base images, admin templates endpoint
Disk sizing:
- Add disk_size_mb column to sandboxes table (default 20480 = 20GB)
- Add disk_size_mb to CreateSandboxRequest proto, passed through the
  full chain: service → RPC → host agent → sandbox manager → devicemapper
- devicemapper.CreateSnapshot takes separate cowSizeBytes param so the
  sparse CoW file can be sized independently from the origin
- EnsureImageSizes() runs at host agent startup: expands any base image
  smaller than 20GB via truncate + resize2fs (sparse, no extra physical
  disk). Sandboxes then get the full 20GB via fast dm-snapshot path
- FlattenRootfs shrinks output images with resize2fs -M so stored
  templates are compact; EnsureImageSizes re-expands on next startup

Admin templates visibility:
- Add GET /v1/admin/templates endpoint listing all templates across teams
- Frontend admin templates page uses listAdminTemplates() instead of
  team-scoped listSnapshots()
- Platform templates (team_id = all-zeros UUID) now visible to all teams:
  GetTemplateByTeam, ListTemplatesByTeam, ListTemplatesByTeamAndType
  queries include platform team_id in WHERE clause
2026-03-26 23:45:41 +06:00
4ddd494160 Switch database IDs from TEXT to native UUID
Consolidate 16 migrations into one with UUID columns for all entity
IDs. TEXT is kept only for polymorphic fields (audit_logs.actor_id,
resource_id) and template names. The id package now generates UUIDs
via google/uuid, with Format*/Parse* helpers for the prefixed wire
format (sb-{uuid}, usr-{uuid}, etc.). Auth context, services, and
handlers pass pgtype.UUID internally; conversion to/from prefixed
strings happens at API and RPC boundaries. Adds PlatformTeamID
(all-zeros UUID) for shared resources.
2026-03-26 16:16:21 +06:00
cdd89a7cee Fix review issues: detached contexts, loop device leak, timer leak, size_bytes
- Use context.Background() with timeout in destroySandbox/failBuild so
  cleanup and DB writes survive parent context cancellation on shutdown
- Fix loop device refcount leak in FlattenRootfs when dmDevice is nil
- Replace time.After with time.NewTimer in healthcheck polling to avoid
  goroutine leak when healthcheck passes early
- Capture size_bytes from CreateSnapshot/FlattenRootfs RPC responses
  instead of hardcoding 0 in the templates table insert
- Avoid leaking internal error details to API clients in build handler
2026-03-26 15:31:38 +06:00
1ce62934b3 Add template build system with admin panel, async workers, and FlattenRootfs RPC
Introduces an end-to-end template building pipeline: admins submit a recipe
(list of shell commands) via the dashboard, a Redis-backed worker pool spins
up a sandbox, executes each command, and produces either a full snapshot
(with healthcheck) or an image-only template (rootfs flattened via a new
FlattenRootfs host-agent RPC). Build progress and per-step logs are persisted
to a new template_builds table and polled by the frontend.

Backend:
- New FlattenRootfs RPC (proto + host agent + sandbox manager)
- BuildService with Redis queue (BLPOP) and configurable worker pool (default 2)
- Admin-only REST endpoints: POST/GET /v1/admin/builds, GET /v1/admin/builds/{id}
- Migration for template_builds table with JSONB logs and recipe columns
- sqlc queries for build CRUD and progress updates

Frontend:
- /admin/templates page with Templates + Builds tabs
- Create Template dialog with recipe textarea, healthcheck, specs
- Build history with expandable per-step logs, status badges, progress bars
- Auto-polling every 3s for active builds
- AdminSidebar updated with Templates nav item
2026-03-26 15:27:21 +06:00
6898528096 Replace one-shot clock_settime with chrony for continuous guest time sync
Switch from the envd /init endpoint pushing host time via syscall to
chronyd reading the KVM PTP hardware clock (/dev/ptp0) continuously.
This fixes clock drift between init calls and handles snapshot resume
gracefully.

Changes:
- Add clocksource=kvm-clock kernel boot arg
- Start chronyd in wrenn-init.sh before tini (PHC /dev/ptp0, makestep 1.0 -1)
- Remove clock_settime logic from envd SetData and shouldSetSystemTime
- Remove client.Init() clock sync calls from sandbox manager (3 sites)
- Remove Init() method from envdclient (no longer needed)
- Simplify rootfs scripts: socat/chrony now come from apt in the container
  image, only envd/wrenn-init/tini are injected by build scripts
2026-03-26 04:47:44 +06:00
12d1e356fa Minor UI copy updates across capsules and templates pages 2026-03-26 03:58:12 +06:00
139f86bf9c Fix static build: disable prerender for dynamic capsule detail route
The [id] route cannot be prerendered at build time since IDs are unknown.
With adapter-static's index.html fallback, the route is handled client-side.
2026-03-26 02:13:12 +06:00
b0a8b498a8 WIP: Add Caddy reverse proxy for dev environment
Add Caddy to docker-compose as the single entry point on port 8000:
- localhost -> /api/* stripped and proxied to CP:8080, /* to frontend:5173
- *.localhost -> proxied to CP:8080 (sandbox proxy catch-all)
- Direct /v1/*, /auth/*, /docs routes proxied to CP

Move CP from :8000 to :8080 (its default). Caddy takes :8000.
Update .env.example, vite proxy target (kept as fallback), and Makefile
dev targets (pg_isready via docker exec, frontend binds 0.0.0.0).

This is an intermediate state — needs further work for the full code
interpreter feature.
2026-03-26 02:12:21 +06:00
4be65b0abb WIP: Add sandbox proxy catch-all to control plane
Add SandboxProxyWrapper that intercepts requests with Host headers
matching {port}-{sandbox_id}.{domain} and proxies them through the
owning host agent's /proxy endpoint.

Authentication is via X-API-Key only (no JWT). The API key's team must
own the sandbox. Export EnsureScheme from lifecycle package for reuse.

Request flow: SDK -> Caddy -> CP catch-all -> Host Agent -> sandbox VM.

This is an intermediate state — needs further work for the full code
interpreter feature.
2026-03-26 02:12:10 +06:00
f4675ebfc0 WIP: Add HTTP proxy endpoint to host agent
Add /proxy/{sandbox_id}/{port}/* handler that reverse-proxies HTTP
requests to services running inside sandbox VMs. The sandbox's host IP
(10.11.0.{idx}) is used as the upstream target.

Includes port validation (1-65535) and shared HTTP transport for
connection pooling. Supports WebSocket upgrades for protocols like
Jupyter's streaming API.

This is an intermediate state — needs further work for the full code
interpreter feature.
2026-03-26 02:12:01 +06:00
602ee470d9 WIP: Add socat injection to rootfs build scripts
Inject a statically-linked socat binary into rootfs images. envd's
port forwarder requires socat to bridge localhost-listening services
(e.g. Jupyter kernel) to the guest TAP interface.

Both scripts follow the same 3-step resolution: check rootfs, check
host, build from source (http://www.dest-unreach.org/socat/ v1.8.1.1).
Static linkage is verified before injection.

This is an intermediate state — needs further work for the full code
interpreter feature.
2026-03-26 02:11:54 +06:00
8cdf91d895 Merge pull request 'Added metrics' (#9) from metrics into dev
Reviewed-on: wrenn/sandbox#9
2026-03-25 16:40:06 +00:00
ed7880bc6c Add per-capsule stats detail page with live CPU/RAM charts
- New detail page at /dashboard/capsules/[id] with Stats and Files tabs
- Stats tab shows capsule info card (status, template, CPU, memory, disk,
  started, idle timeout) and two stacked Chart.js charts with live values
- Metrics API client with 10s polling and moving-average smoothing
- Capsule ID in list table is now a clickable link to the detail page
- Layout breadcrumb header (Capsules > sb-xxx) with back navigation
- Fix metrics sampler: use v.PID() directly as Firecracker PID since
  unshare -m execs (not forks) through the bash/ip-netns-exec/firecracker
  chain, so all share the same PID. Removes unused findChildPID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:31:05 +06:00
27ff828e60 Push GetSandboxMetricPoints time filter into SQL
The query was fetching all rows for a (sandbox_id, tier) pair and
filtering by timestamp in Go. For repeatedly-paused sandboxes the
24h tier can accumulate up to 30 days of data, causing up to 120x
over-fetching for a 6h range request.

Add AND ts >= $3 to the query so Postgres filters on the primary key
(sandbox_id, tier, ts) directly. Drop the redundant Go-side loop.
2026-03-25 21:53:19 +06:00
6eacf0f735 Fix LIKE pattern injection in user email search
Escape LIKE metacharacters (% and _) in the email prefix before passing
to the SQL query, and enforce the documented '@' requirement to prevent
broad user enumeration. Move search logic out of TeamService into
usersHandler since it is a site-wide lookup, not team-scoped.
2026-03-25 21:53:09 +06:00
88cb24bb86 Minor improvement 2026-03-25 21:27:11 +06:00
49b0b646a8 Add 5m, 1h, 6h, 12h range filters to metrics endpoint
Maps each user-facing range to the appropriate underlying ring buffer
tier and applies a time cutoff filter. No new ring buffers needed —
5m/10m read from the 10m tier, 1h/2h from the 2h tier, 6h/12h/24h
from the 24h tier.
2026-03-25 20:44:28 +06:00
9acdbb5ae9 Add per-sandbox CPU/memory/disk metrics collection
Samples /proc/{fc_pid}/stat (CPU%), /proc/{fc_pid}/status (VmRSS), and
stat() on CoW files at 500ms intervals per running sandbox. Three tiered
ring buffers downsample into 30s and 5min averages for 10min/2h/24h
retention. Metrics are flushed to DB on pause (all tiers) and destroy
(24h only). New GetSandboxMetrics and FlushSandboxMetrics RPCs on the
host agent, proxied through GET /v1/sandboxes/{id}/metrics?range= on
the control plane. Returns live data for running sandboxes, DB data for
paused, and 404 for stopped.
2026-03-25 20:10:33 +06:00
7473c15f52 Bugfix: cgroup2 related error inside the sandbox 2026-03-25 19:45:57 +06:00
8d5ba3873a Fix capsules table blink on background poll refresh
Poll fetches now silently update data without triggering loading
states, spinner animations, or row fadeUp re-animations. Only manual
refresh shows the spin indicator.
2026-03-25 19:44:13 +06:00
b0e6f5ffb3 Bolder stats page layout with stronger visual hierarchy
- Accent stripes: 3px → 5px; indicator dots: 6px → 8px
- Peak values step down to text-[1.714rem]/text-secondary so Now values read as the clear hero
- Now labels: semibold + uppercase for weight parity with the metric
- Cell padding py-5 → py-6; outer gap-7/pt-4 → gap-8/pt-6 for breathing room
- Chart fills: 7-8% → 11-13% opacity; lines: 1.5 → 2px
- Tick labels brighter (#635f5c), grid lines slightly more visible
- Running capsules chart: min-height 220 → 260px
2026-03-25 18:18:04 +06:00
a69b0f579c Split CPU and RAM into separate side-by-side charts
CPU (vCPUs) and RAM (GB) use different units and scales, so combining
them on a dual-axis chart was misleading. Each now has its own chart
card, laid out side-by-side.
2026-03-25 16:39:25 +06:00
45793e181c Move metrics to after templates in sidebar nav 2026-03-25 16:08:38 +06:00
e3750f79f9 Fix metrics sampler to record zero-value snapshots when idle
SampleSandboxMetrics previously filtered WHERE status IN ('running',
'starting', 'paused'), which returned no rows when all capsules were
stopped. This caused zero snapshots to be skipped, leaving the
time-series charts with no trailing data points instead of showing
the expected zero values.

Remove the WHERE filter so the query groups by all teams that have
any sandbox row. The per-status FILTER clauses on the aggregates
already produce correct zero counts for stopped capsules.

Also includes the per-VM RAM ceiling formula change (sum(ceil(each/2))
instead of ceil(sum/2)).
2026-03-25 15:50:19 +06:00
930da8a578 Move metrics to dedicated nav item, simplify capsules page
- Add Metrics nav item to sidebar with bar chart icon
- Create /dashboard/metrics page wrapping StatsPanel
- Remove tabs from capsules page (list is now the only view)
- Flatten capsules route: /capsules directly shows the list,
  removing the /list and /stats sub-routes
- Strip redundant title/subtitle from StatsPanel (page header
  provides context)
2026-03-25 15:24:21 +06:00
47b0ed5b52 Fix metrics correctness, redesign stats page
- Replace stale snapshot read (GetCurrentMetrics) with live query
  (GetLiveMetrics) against sandboxes table — always returns correct
  zeros when no capsules are running
- Fix CPU reserved formula: running + starting only; paused VMs no
  longer contribute vCPUs (RAM reservation for paused unchanged)
- Merge top cards into 3 paired Now/Peak cards with colored accent
  borders (green/blue/amber matching chart colors)
- Move Live badge from Running Capsules card to page-level header
- Add colored category dots to card and chart headers
- Charts stacked vertically, flex-1 to fill remaining page height
- vCPUs chart color changed to blue (#5a9fd4), RAM stays amber
2026-03-25 15:11:46 +06:00
fee66bda50 Add live stats page with metrics sampling and route split
- New sandbox_metrics_snapshots table sampled every 10s (60-day retention)
- Background MetricsSampler goroutine wired into control plane startup
- GET /v1/sandboxes/stats?range=5m|1h|6h|24h|30d endpoint with adaptive
  polling intervals; reserved CPU/RAM uses ceil(paused/2) formula
- StatsPanel component: 4 stat cards + 2 Chart.js line charts (straight
  lines, integer y-axis for running count, dual-axis for CPU/RAM)
- Range filter persisted in URL query param; polls update data silently
  (no blink — loading state only shown on initial mount)
- Split /dashboard/capsules into /list and /stats sub-routes with shared
  layout; capsuleRunningCount store syncs badge across routes
- CreateCapsuleDialog extracted as reusable component
2026-03-25 14:41:05 +06:00
2349f585ae Bolder, more delightful frontend across all pages
- app.css: replace flat --shadow-sm token with real shadows; add
  --shadow-card and --shadow-dialog tokens; add @keyframes status-ping
  and .animate-status-ping utility (outward ring ripple, GPU-composited
  via will-change) for live running status dots
- login: headline 5rem → 6.5rem with tighter leading/tracking; expand
  container to 460px; add sage-green dot grid texture layer beneath the
  mouse-reactive glow for industrial depth
- capsules: upgrade all running dots (header chip + row indicators +
  status bar) from opacity-fade to ring ripple; apply --shadow-dialog
  to Launch and Snapshot dialogs
- keys: apply --shadow-dialog to all three dialogs
- audit: remove duplicate @keyframes fadeUp and iconFloat (redundant
  with app.css definitions, audit's fadeUp also subtly diverged)
- sidebar: active indicator bar taller and thicker (h-5 w-[3px] → h-6
  w-1); active bg more vivid (accent/12%); label font-medium →
  font-semibold; team dialog gets --shadow-dialog
2026-03-25 12:55:23 +06:00
d4eb24be7e Added snapshot name dialogue on the UI 2026-03-25 05:30:31 +06:00
0414fbe733 Merge pull request 'Added audit logs for users' (#7) from audit-logs into dev
Reviewed-on: wrenn/sandbox#7
2026-03-24 23:21:09 +00:00
6b76abe38e Remove expandable metadata from audit log rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 05:19:32 +06:00
3ce8fdcb02 Add audit logs frontend page
Infinite-scroll table with hierarchical filter dropdown, expandable
metadata rows, and status-coded visual signals per event severity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 05:18:04 +06:00
1be30034bd Add audit log infrastructure and GET /v1/audit-logs endpoint
Introduces an append-only audit trail for all user and system actions:
sandbox lifecycle (create/pause/resume/destroy/auto-pause), snapshots,
team rename, API key create/revoke, member add/remove/leave/role_update,
and BYOC host add/delete/marked_down/marked_up.

- New audit_logs table (migration) with team_id, actor, resource,
  action, scope (team|admin), status (success|info|warning|error),
  metadata, and created_at
- AuditLogger (internal/audit) with named fire-and-forget methods per
  event; system actor used for background events (HostMonitor, TTL reaper)
- GET /v1/audit-logs: JWT-only, cursor pagination (max 200), multi-value
  filters for resource_type and action (comma-sep or repeated params);
  members see team-scoped events only, admins/owners see all
- AuthContext extended with APIKeyID + APIKeyName so API key requests
  record meaningful actor identity
- HostMonitor wired with AuditLogger for auto-pause and host marked_down
2026-03-25 05:15:16 +06:00
9878156798 Merge pull request 'Set up working host registration (including BYOC) with the CP' (#6) from host-registration into dev
Reviewed-on: wrenn/sandbox#6
2026-03-24 21:19:12 +00:00
e069b3e679 Add BYOC page, admin section, and is_byoc team visibility gating
- Frontend: BYOC hosts page (/dashboard/byoc) with register/delete flows,
  shimmer loading, pulsing online status, animated token reveal checkmark
- Frontend: Admin section (/admin/hosts) with platform + BYOC tabs, stat
  pills, skeleton loading, slide-in animations for new rows
- Frontend: AdminSidebar component with accent top bar and admin pill badge
- Frontend: BYOC nav item shown only when team.is_byoc is true (derived
  from teams store, not JWT); disabled for members
- Frontend: Admin shield button in Sidebar, visible only to platform admins
- Backend: is_admin in JWT claims + requireAdmin middleware (DB-validated)
- Backend: is_byoc added to teamResponse so frontend derives visibility
  from fresh team data rather than stale JWT fields
- Backend: SetBYOC admin endpoint (PUT /v1/admin/teams/{id}/byoc)
- Backend: Admin hosts list enriches BYOC entries with team_name
- Host agent: load .env file via godotenv on startup
2026-03-25 03:10:41 +06:00
9bf67aa7f7 Implement host registration, JWT refresh tokens, and multi-host scheduling
Replaces the hardcoded CP_HOST_AGENT_ADDR single-agent setup with a
DB-driven registration system supporting multiple host agents (BYOC).

Key changes:
- Host agents register via one-time token, receive a 7-day JWT + 60-day
  refresh token; heartbeat loop auto-refreshes on 401/403 and pauses all
  sandboxes if refresh fails
- HostClientPool: lazy Connect RPC client cache keyed by host ID, replacing
  the single static agent client throughout the API and service layers
- RoundRobinScheduler: picks an online host for each new sandbox via
  ListActiveHosts; extensible for future scheduling strategies
- HostMonitor (replaces Reconciler): passive heartbeat staleness check marks
  hosts unreachable and sandboxes missing after 90s; active reconciliation
  per online host restores missing-but-alive sandboxes and stops orphans
- Graceful host delete: returns 409 with affected sandbox list without
  ?force=true; force-delete destroys sandboxes then evicts pool client
- Snapshot delete broadcasts to all online hosts (templates have no host_id)
- sandbox.Manager.PauseAll: pauses all running VMs on CP connectivity loss
- New migration: host_refresh_tokens table with token rotation (issue-then-
  revoke ordering to prevent lockout on mid-rotation crash)
- New sandbox status 'missing' (reversible, unlike 'stopped') and host
  status 'unreachable'; both reflected in OpenAPI spec
- Fix: refresh token auth failure now returns 401 (was 400 via generic
  'invalid' substring match in serviceErrToHTTP)
2026-03-24 18:32:05 +06:00
f968da9768 Minor frontend enhancements 2026-03-24 17:25:00 +06:00
3932bc056e Add user names, team-scoped sandbox guard, and login robustness fixes
- Add name column to users (migration + sqlc regen); propagate through JWT
  claims, auth context, all auth/OAuth handlers, service layer, and frontend
- Sidebar and team page show name instead of email; team page splits Name/Email
  into separate columns
- Block sandbox creation in UI and API when user has no active team context
- loginTeam helper falls back to first active team when no default is set,
  fixing login for invited users with no is_default membership
- Exclude soft-deleted teams from GetDefaultTeamForUser, GetBYOCTeams queries
- Guard host creation against soft-deleted teams in service/host.go
- SwitchTeam re-fetches name from DB instead of trusting stale JWT claim
- Reset teams store on login so stale data from a previous session never persists
- Update openapi.yaml: add name to SignupRequest and AuthResponse schemas
2026-03-24 16:56:10 +06:00
aaeccd32ce Merge pull request 'Frontend consistency and improvements' (#5) from frontend-enhancement into dev
Reviewed-on: wrenn/sandbox#5
2026-03-24 10:00:27 +00:00
915d934c26 Frontend consistency pass: delight, audit, and normalization
Delight (keys page):
- Animated checkmark draw + circle pop on key reveal dialog open
- Key display area pulses accent glow on open to draw eye to "copy this"
- Copy button spring-bounces on successful copy (re-triggers on repeat)
- Empty state key icon floats (iconFloat, now global)
- Row hover uses scaleY left-accent stripe (matches capsules pattern)
- New key row flashes accent on reveal dialog dismiss (matches capsule-born)

Audit fixes (all dashboard pages):
- Page titles standardized to em dash: "Wrenn — X" across all four pages
- formatDate/timeAgo extracted to src/lib/utils/format.ts (string | undefined
  signatures); keys and snapshots now import from there instead of duplicating
- team formatDate gains undefined guard (kept local, date-only format differs)
- spin-once and iconFloat keyframes moved to app.css as globals; scoped copies
  removed from capsules and keys
- Snapshots empty state icon was referencing undefined @keyframes float; fixed
  to iconFloat

Normalization:
- Snapshots table rows: replaced ::before pseudo-element accent (opacity-only,
  single color) with DOM row-stripe element using scaleY transition, type-keyed
  color (green for snapshots, blue for images) — matches capsules pattern
- Create Key dialog: max-w-[400px] → max-w-[420px] to align with form dialogs
- Snapshots count and empty-state heading are now terminology-aware: shows
  "templates/snapshots/images" based on active filter; empty heading for all
  filter reads "No templates yet" instead of "No snapshots yet"

Not done (documented in audit, deferred):
- Sidebar nav items pointing to unimplemented routes (audit, usage, billing,
  notifications, settings) — left as-is, needs product decision
- Dialog max-widths fully normalized beyond Create Key — minor, deferred
- capsules timeAgo not imported from shared util (formatTime differs intentionally)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:51:11 +06:00
336080bb6d Merge pull request 'Added team related functionalities' (#4) from team-management into dev
Reviewed-on: wrenn/sandbox#4
2026-03-24 08:58:32 +00:00
90c296f5e1 Polish team page: delight micro-interactions and layout improvements
- Slug + Team ID rows collapsed into a 2-column grid for better density
- "you" badge moved inline with email instead of stacked below it
- Copy checkmark draws itself via SVG stroke-dashoffset animation
- New member row flashes accent-green on entry
- Removed member row slides out smoothly (fly transition)
- Member rows use staggered fly-in on page load
- Team name briefly highlights accent color after a successful rename
- Search result avatars get colorized initials based on email character
2026-03-24 14:56:19 +06:00
bf494f73fc Fix team name blink on navigation by lifting teams into a singleton store
Teams list was fetched on every Sidebar mount (each page navigation),
causing a flash from '…' to the real name on every tab switch. Move teams
into a module-level reactive store (teams.svelte.ts) that fetches once per
session and is shared between Sidebar and the team page.
2026-03-24 14:44:09 +06:00
71a7fdb76f Fix user search to trigger on 3 characters without requiring @
The anti-enumeration guard required @ in the email prefix, causing the
typeahead to silently return nothing until the user typed @. Replace with
a minimum 3-character length check to match the frontend trigger condition.
2026-03-24 14:41:01 +06:00
b3e8bdd171 Refine team management: name chars, danger zone, no-team state
- Allow hyphens, @, and apostrophes in team names (backend regex)
- After delete/leave, switch to next available team instead of logging
  out; if no teams remain, show a toast prompting to create one
- Disable delete/leave button when user has only one team, with
  explanatory hint to create another team first
- Show empty state on /dashboard/team when auth has no team context,
  pointing user to the sidebar to create a team
- Fetch all teams in parallel with team detail on page load to power
  the isLastTeam guard
2026-03-24 14:34:20 +06:00
1e681da738 Add team management frontend
- New /dashboard/team page with inline team name editing, slug/ID copy,
  members table with split-button (remove + make admin/member), add member
  typeahead, and danger zone (delete/leave) with confirmation dialogs
- Sidebar now fetches real teams from API, supports team switching and
  team creation via dialog
- Rename nav item Members → Team, route /dashboard/members → /dashboard/team
- New src/lib/api/team.ts with typed functions for all team endpoints
2026-03-24 14:21:53 +06:00
8e5d426638 Add team management endpoints
- Three-role model (owner/admin/member) with owner protection invariants
- Team CRUD: create, rename (admin+), soft-delete with VM cleanup (owner only)
- Member management: add by email, remove, role updates (admin+), leave
- Switch-team endpoint re-issues JWT after DB membership verification
- User email prefix search for add-member UI autocomplete
- JWT carries role as a hint; all authorization decisions verified from DB
- Team slug: immutable 12-char hex (e.g. a1b2c3-d1e2f3), reserved on soft-delete
- Migration adds slug + deleted_at to teams; backfills existing rows
2026-03-24 13:29:54 +06:00
4e26d7a292 Merge pull request 'Minor frontend enhancement' (#3) from frontend into dev
Reviewed-on: wrenn/sandbox#3
2026-03-24 06:36:17 +00:00
79eba782fb Updated design docs 2026-03-24 12:34:58 +06:00
b786a825d4 Polish dashboard frontend: spacing, copy, resilience
- Increase content padding (p-7→p-8) and table cell padding (px-4→px-5,
  py-3→py-4 for data rows) across capsules, keys, and snapshots pages
- Improve animation performance: wrenn-glow uses opacity instead of
  box-shadow (compositor-only, no paint cost)
- Add prefers-reduced-motion media query covering inline style animations
- Fix OAuth error display on login page (read ?error= param on mount)
- Harden clipboard copy with try-catch and toast fallback
- Improve empty state copy, dialog microcopy, and error messages
- Add retry button to error banners on keys page
- Replace "All systems operational" footer bar with a clean 1px divider
- Fix text truncation on long capsule/snapshot names (min-w-0 + truncate)
2026-03-24 12:33:18 +06:00
71564b202e Merge branch 'main' of git.omukk.dev:wrenn/sandbox into dev 2026-03-24 01:11:43 +06:00
5f0dbadea6 Fix snapshot and sandbox delete consistency
- Snapshot delete: make agent RPC failure a hard error so DB record is
  not removed when files cannot be deleted from disk
- Snapshot overwrite: call agent to delete old files before removing the
  DB record, preventing stale memfile.{uuid} generations from accumulating
  on disk across repeated overwrites
- Sandbox destroy: only swallow CodeNotFound from the agent (sandbox
  already gone / TTL-reaped); any other error now propagates to the caller
  instead of being silently ignored
2026-03-23 02:59:30 +06:00
36782e1b4f Add tini as PID 1, guest clock sync, and fix PATH in guest VMs
- Use tini as PID 1 in wrenn-init.sh so zombie processes are reaped
  and signals are forwarded correctly to envd
- Set standard PATH in wrenn-init.sh so child processes spawned by envd
  can find common binaries (fixes "nice: ls command not found")
- Add envdclient.Init() to POST /init on envd after every boot/resume,
  syncing the guest clock via unix.ClockSettime — critical after snapshot
  resume where the guest clock is frozen
- Run Init in a background goroutine so it doesn't block the CreateSandbox
  RPC response; a slow Init (vCPU busy with envd startup) was causing the
  RPC context to be canceled before the response reached the control plane
- Update rootfs-from-container.sh and update-debug-rootfs.sh to inject
  tini into the rootfs, checking the container image and host first,
  downloading from GitHub releases as fallback
2026-03-23 02:45:27 +06:00
97292ba0bf Added basic frontend (#1)
Reviewed-on: wrenn/sandbox#1
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
2026-03-22 19:01:38 +00:00
866f3ac012 Consolidate host agent path env vars into single AGENT_FILES_ROOTDIR
Replace AGENT_KERNEL_PATH, AGENT_IMAGES_PATH, AGENT_SANDBOXES_PATH,
AGENT_SNAPSHOTS_PATH, and AGENT_TOKEN_FILE with a single
AGENT_FILES_ROOTDIR (default /var/lib/wrenn) that derives all
subdirectory paths automatically.
2026-03-17 05:59:26 +06:00
2c66959b92 Add host registration, heartbeat, and multi-host management
Implements the full host ↔ control plane connection flow:

- Host CRUD endpoints (POST/GET/DELETE /v1/hosts) with role-based access:
  regular hosts admin-only, BYOC hosts for admins and team owners
- One-time registration token flow: admin creates host → gets token (1hr TTL
  in Redis + Postgres audit trail) → host agent registers with specs → gets
  long-lived JWT (1yr)
- Host agent registration client with automatic spec detection (arch, CPU,
  memory, disk) and token persistence to disk
- Periodic heartbeat (30s) via POST /v1/hosts/{id}/heartbeat with X-Host-Token
  auth and host ID cross-check
- Token regeneration endpoint (POST /v1/hosts/{id}/token) for retry after
  failed registration
- Tag management (add/remove/list) with team-scoped access control
- Host JWT with typ:"host" claim, cross-use prevention in both VerifyJWT and
  VerifyHostJWT
- requireHostToken middleware for host agent authentication
- DB-level race protection: RegisterHost uses AND status='pending' with
  rows-affected check; Redis GetDel for atomic token consume
- Migration for future mTLS support (cert_fingerprint, mtls_enabled columns)
- Host agent flags: --register (one-time token), --address (required ip:port)
- serviceErrToHTTP extended with "forbidden" → 403 mapping
- OpenAPI spec, .env.example, and README updated
2026-03-17 05:51:28 +06:00
e4ead076e3 Add admin users, BYOC teams, hosts schema, and Redis for host registration
Introduce three migrations: admin permissions (is_admin + permissions table),
BYOC team tracking, and multi-host support (hosts, host_tokens, host_tags).
Add Redis to dev infra and wire up client in control plane for ephemeral
host registration tokens. Add go-redis dependency.
2026-03-17 03:26:42 +06:00
1d59b50e49 Remove empty admin UI stubs
The internal/admin/ package was never imported or mounted — just
placeholder files. Removing to avoid confusion before the real
dashboard is built.
2026-03-16 05:39:43 +06:00
f38d5812d1 Extract shared service layer for sandbox, API key, and template operations
Moves business logic from API handlers into internal/service/ so that
both the REST API and the upcoming dashboard can share the same operations
without duplicating code. API handlers now delegate to the service layer
and only handle HTTP-specific concerns (request parsing, response formatting).
2026-03-16 05:39:30 +06:00
931b7d54b3 Add GitHub OAuth login with provider registry
Implement OAuth 2.0 login via GitHub as an alternative to email/password.
Uses a provider registry pattern (internal/auth/oauth/) so adding Google
or other providers later requires only a new Provider implementation.

Flow: GET /v1/auth/oauth/github redirects to GitHub, callback exchanges
the code for a user profile, upserts the user + team atomically, and
redirects to the frontend with a JWT token.

Key changes:
- Migration: make password_hash nullable, add oauth_providers table
- Provider registry with GitHubProvider (profile + email fallback)
- CSRF state cookie with HMAC-SHA256 validation
- Race-safe registration (23505 collision retries as login)
- Startup validation: CP_PUBLIC_URL required when OAuth is configured

Not fully tested — needs integration tests with a real GitHub OAuth app
and end-to-end testing with the frontend callback page.
2026-03-15 06:31:58 +06:00
477d4f8cf6 Add auto-pause TTL and ping endpoint for sandbox inactivity management
Replace the existing auto-destroy TTL behavior with auto-pause: when a
sandbox exceeds its timeout_sec of inactivity, the TTL reaper now pauses
it (snapshot + teardown) instead of destroying it, preserving the ability
to resume later.

Key changes:
- TTL reaper calls Pause instead of Destroy, with fallback to Destroy if
  pause fails (e.g. Firecracker process already gone)
- New PingSandbox RPC resets the in-memory LastActiveAt timer
- New POST /v1/sandboxes/{id}/ping REST endpoint resets both agent memory
  and DB last_active_at
- ListSandboxes RPC now includes auto_paused_sandbox_ids so the reconciler
  can distinguish auto-paused sandboxes from crashed ones in a single call
- Reconciler polls every 5s (was 30s) and marks auto-paused as "paused"
  vs orphaned as "stopped"
- Resume RPC accepts timeout_sec from DB so TTL survives pause/resume cycles
- Reaper checks every 2s (was 10s) and uses a detached context to avoid
  incomplete pauses on app shutdown
- Default timeout_sec changed from 300 to 0 (no auto-pause unless requested)
2026-03-15 05:15:18 +06:00
88246fac2b Fix sandbox lifecycle cleanup and dmsetup remove reliability
- Add retry with backoff to dmsetupRemove for transient "device busy"
  errors caused by kernel not releasing the device immediately after
  Firecracker exits. Only retries on "Device or resource busy"; other
  errors (not found, permission denied) return immediately.

- Thread context.Context through RemoveSnapshot/RestoreSnapshot so
  retries respect cancellation. Use context.Background() in all error
  cleanup paths to prevent cancelled contexts from skipping cleanup
  and leaking dm devices on the host.

- Resume vCPUs on pause failure: if snapshot creation or memfile
  processing fails after freezing the VM, unfreeze vCPUs so the
  sandbox stays usable instead of becoming a frozen zombie.

- Fix resource leaks in Pause when CoW rename or metadata write fails:
  properly clean up network, slot, loop device, and remove from boxes
  map instead of leaving a dead sandbox with leaked host resources.

- Fix Resume WaitUntilReady failure: roll back CoW file to the snapshot
  directory instead of deleting it, preserving the paused state so the
  user can retry.

- Skip m.loops.Release when RemoveSnapshot fails during pause since
  the stale dm device still references the origin loop device.

- Fix incorrect VCPUs placeholder in Resume VMConfig that used memory
  size instead of a sensible default.
2026-03-14 06:42:34 +06:00
1846168736 Fix device-mapper "Device or resource busy" error on sandbox resume
Pause was logging RemoveSnapshot failures as warnings and continuing,
which left stale dm devices behind. Resume then failed trying to create
a device with the same name.

- Make RemoveSnapshot failure a hard error in Pause (clean up remaining
  resources and return error instead of silently proceeding)
- Add defensive stale device cleanup in RestoreSnapshot before creating
  the new dm device
2026-03-14 03:57:14 +06:00
c92cc29b88 Add authentication, authorization, and team-scoped access control
Implement email/password auth with JWT sessions and API key auth for
sandbox lifecycle. Users get a default team on signup; sandboxes,
snapshots, and API keys are scoped to teams.

- Add user, team, users_teams, and team_api_keys tables (goose migrations)
- Add JWT middleware (Bearer token) for user management endpoints
- Add API key middleware (X-API-Key header, SHA-256 hashed) for sandbox ops
- Add signup/login handlers with transactional user+team creation
- Add API key CRUD endpoints (create/list/delete)
- Replace owner_id with team_id on sandboxes and templates
- Update all handlers to use team-scoped queries
- Add godotenv for .env file loading
- Update OpenAPI spec and test UI with auth flows
2026-03-14 03:57:06 +06:00
712b77b01c Add script to create rootfs from Docker container 2026-03-13 09:41:58 +06:00
80a99eec87 Add diff snapshots for re-pause to avoid UFFD fault-in storm
Use Firecracker's Diff snapshot type when re-pausing a previously
resumed sandbox, capturing only dirty pages instead of a full memory
dump. Chains up to 10 incremental generations before collapsing back
to a Full snapshot. Multi-generation diff files (memfile.{buildID})
are supported alongside the legacy single-file format in resume,
template creation, and snapshot existence checks.
2026-03-13 09:41:58 +06:00
a0d635ae5e Fix path traversal in template/snapshot names and network cleanup leaks
Add SafeName validator (allowlist regex) to reject directory traversal
in user-supplied template and snapshot names. Validated at both API
handlers (400 response) and sandbox manager (defense in depth).

Refactor CreateNetwork with rollback slice so partially created
resources (namespace, veth, routes, iptables rules) are cleaned up
on any error. Refactor RemoveNetwork to collect and return errors
instead of silently ignoring them.
2026-03-13 08:40:36 +06:00
63e9132d38 Add device-mapper snapshots, test UI, fix pause ordering and lint errors
- Replace reflink rootfs copy with device-mapper snapshots (shared
  read-only loop device per base template, per-sandbox sparse CoW file)
- Add devicemapper package with create/restore/remove/flatten operations
  and refcounted LoopRegistry for base image loop devices
- Fix pause ordering: destroy VM before removing dm-snapshot to avoid
  "device busy" error (FC must release the dm device first)
- Add test UI at GET /test for sandbox lifecycle management (create,
  pause, resume, destroy, exec, snapshot create/list/delete)
- Fix DirSize to report actual disk usage (stat.Blocks * 512) instead
  of apparent size, so sparse CoW files report correctly
- Add timing logs to pause flow for performance diagnostics
- Fix all lint errors across api, network, vm, uffd, and sandbox packages
- Remove obsolete internal/filesystem package (replaced by devicemapper)
- Update CLAUDE.md with device-mapper architecture documentation
2026-03-13 08:25:40 +06:00
778894b488 Made license related changes 2026-03-13 05:42:10 +06:00
a1bd439c75 Add sandbox snapshot and restore with UFFD lazy memory loading
Implement full snapshot lifecycle: pause (snapshot + free resources),
resume (UFFD-based lazy restore), and named snapshot templates that
can spawn new sandboxes from frozen VM state.

Key changes:
- Snapshot header system with generational diff mapping (inspired by e2b)
- UFFD server for lazy page fault handling during snapshot restore
- Stable rootfs symlink path (/tmp/fc-vm/) for snapshot compatibility
- Templates DB table and CRUD API endpoints (POST/GET/DELETE /v1/snapshots)
- CreateSnapshot/DeleteSnapshot RPCs in hostagent proto
- Reconciler excludes paused sandboxes (expected absent from host agent)
- Snapshot templates lock vcpus/memory to baked-in values
- Proper cleanup of uffd sockets and pause snapshot files on destroy
2026-03-12 09:19:37 +06:00
9b94df7f56 Rewrite CLAUDE.md and README.md
CLAUDE.md: replace bloated 850-line version with focused 230-line
guide. Fix inaccuracies (module path, build dir, Connect RPC vs gRPC,
buf vs protoc). Add detailed architecture with request flows, code
generation workflow, rootfs update process, and two-module gotchas.

README.md: add core deployment instructions (prerequisites, build,
host setup, configuration, running, rootfs workflow).
2026-03-11 06:37:11 +06:00
0c245e9e1c Fix guest VM outbound networking and DNS resolution
Add resolv.conf to wrenn-init so guests can resolve DNS, and fix the
host MASQUERADE rule to match vpeerIP (the actual source after namespace
SNAT) instead of hostIP.
2026-03-11 06:02:31 +06:00
b4d8edb65b Add streaming exec and file transfer endpoints
Add WebSocket-based streaming exec endpoint and streaming file
upload/download endpoints to the control plane API. Includes new
host agent RPC methods (ExecStream, StreamWriteFile, StreamReadFile),
envd client streaming support, and OpenAPI spec updates.
2026-03-11 05:42:42 +06:00
ec3360d9ad Add minimal control plane with REST API, database, and reconciler
- REST API (chi router): sandbox CRUD, exec, pause/resume, file write/read
- PostgreSQL persistence via pgx/v5 + sqlc (sandboxes table with goose migration)
- Connect RPC client to host agent for all VM operations
- Reconciler syncs host agent state with DB every 30s (detects TTL-reaped sandboxes)
- OpenAPI 3.1 spec served at /openapi.yaml, Swagger UI at /docs
- Added WriteFile/ReadFile RPCs to hostagent proto and implementations
- File upload via multipart form, download via JSON body POST
- sandbox_id propagated from control plane to host agent on create
2026-03-10 16:50:12 +06:00
d7b25b0891 updated license structure 2026-03-10 04:32:29 +06:00
34c89e814d Added basic license information 2026-03-10 04:28:51 +06:00
6f0c365d44 Add host agent RPC server with sandbox lifecycle management
Implement the host agent as a Connect RPC server that orchestrates
sandbox creation, destruction, pause/resume, and command execution.
Includes sandbox manager with TTL-based reaper, network slot allocator,
rootfs cloning, hostagent proto definition with generated stubs, and
test/debug scripts. Fix Firecracker process lifetime bug where VM was
tied to HTTP request context instead of background context.
2026-03-10 03:54:53 +06:00
c31ce90306 Centralize envd proto source of truth to proto/envd/
Remove duplicate proto files from envd/spec/ and update envd's
buf.gen.yaml to generate stubs from the canonical proto/envd/ location.
Both modules now generate their own Connect RPC stubs from the same
source protos.
2026-03-10 02:49:31 +06:00
7753938044 Add host agent with VM lifecycle, TAP networking, and envd client
Implements Phase 1: boot a Firecracker microVM, execute a command inside
it via envd, and get the output back. Uses raw Firecracker HTTP API via
Unix socket (not the Go SDK) for full control over the VM lifecycle.

- internal/vm: VM manager with create/pause/resume/destroy, Firecracker
  HTTP client, process launcher with unshare + ip netns exec isolation
- internal/network: per-sandbox network namespace with veth pair, TAP
  device, NAT rules, and IP forwarding
- internal/envdclient: Connect RPC client for envd process/filesystem
  services with health check retry
- cmd/host-agent: demo binary that boots a VM, runs "echo hello", prints
  output, and cleans up
- proto/envd: canonical proto files with buf + protoc-gen-connect-go
  code generation
- images/wrenn-init.sh: minimal PID 1 init script for guest VMs
- CLAUDE.md: updated architecture to reflect TAP networking (not vsock)
  and Firecracker HTTP API (not Go SDK)
2026-03-10 00:06:47 +06:00
a3898d68fb Port envd from e2b with internalized shared packages and Connect RPC
- Copy envd source from e2b-dev/infra, internalize shared dependencies
  into envd/internal/shared/ (keys, filesystem, id, smap, utils)
- Switch from gRPC to Connect RPC for all envd services
- Update module paths to git.omukk.dev/wrenn/{sandbox,sandbox/envd}
- Add proto specs (process, filesystem) with buf-based code generation
- Implement full envd: process exec, filesystem ops, port forwarding,
  cgroup management, MMDS integration, and HTTP API
- Update main module dependencies (firecracker SDK, pgx, goose, etc.)
- Remove placeholder .gitkeep files replaced by real implementations
2026-03-09 21:03:19 +06:00
100 changed files with 2307 additions and 948 deletions

View File

@ -47,6 +47,10 @@ WRENN_CA_KEY=
# Channels (notification destinations)
# AES-256-GCM key for encrypting channel secrets. Generate with: openssl rand -hex 32
WRENN_ENCRYPTION_KEY=
# Allow notification channels to target private/loopback/link-local addresses.
# Off by default to prevent SSRF; set to true only on self-hosted deployments
# that deliver to internal endpoints.
WRENN_CHANNELS_ALLOW_PRIVATE=false
# OAuth
OAUTH_GITHUB_CLIENT_ID=

1
.gitignore vendored
View File

@ -33,7 +33,6 @@ go.work.sum
## AI
.claude/
e2b/
.impeccable.md
.gstack
.mcp.json

View File

@ -60,13 +60,13 @@ User SDK → HTTPS/WS → Control Plane → Connect RPC → Host Agent → HTTP/
envd is a standalone Rust binary (Tokio + Axum + connectrpc-rs). It is completely independent from the Go module — the only connection is the protobuf contract. It compiles to a statically linked musl binary baked into rootfs images.
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The reconciler (`internal/api/reconciler.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The host monitor (`internal/api/host_monitor.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
### Control Plane
**Internal packages:** `internal/api/`, `internal/email/`
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`, `pkg/netutil/`
**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext` + hook interfaces), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`)
@ -88,7 +88,7 @@ It can optionally implement any of these hook interfaces (the OSS server type-as
Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.WithVersion(...))`. All 20 initialization steps live in `pkg/cpserver/run.go`: config → pgxpool → `db.Queries` → Redis → mTLS CA → host client pool → scheduler → OAuth → channels → audit logger → `api.New()` → background workers → HTTP server. Everything flows through constructor injection.
- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`. Accepts `[]cpextension.Extension` — each extension's `RegisterRoutes()` is called after all core routes are registered.
- **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped".
- **Host monitor** (`internal/api/host_monitor.go`): safety-net reconciliation goroutine (every 5min, wired in `pkg/cpserver/run.go` via `NewHostMonitor`). Primary state sync is push-based (host agent callbacks + CP background goroutines); the monitor is the fallback for missed events, host-death detection, and transient-status resolution. For each online host it calls `ListSandboxes()` and reconciles orphaned DB entries.
- **Dashboard** (SvelteKit + Tailwind + Bits UI, built to static files in `frontend/build/`, served by Caddy as a reverse proxy)
- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/*.sql``pkg/db/`. Migrations in `db/migrations/` (goose, plain SQL). `db/migrations/embed.go` exposes `migrations.FS` so the cloud repo can run OSS migrations via `go:embed`.
- **Config** (`pkg/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config.
@ -251,7 +251,7 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `
## Coding Conventions
- **Go style**: `gofmt`, `go vet`, `context.Context` everywhere, errors wrapped with `fmt.Errorf("action: %w", err)`, `slog` for logging, no global state
- **Naming**: Sandbox IDs `sb-` + 8 hex, API keys `wrn_` + 32 chars, Host IDs `host-` + 8 hex
- **Naming**: IDs are a type prefix + a 25-char base36-encoded UUID (`pkg/id`) — sandboxes `cl-`, users `usr-`, teams `team-`, hosts `host-`, etc. API keys are `wrn_` + 32 hex
- **Dependencies**: Use `go get` to add Go deps, never hand-edit go.mod. For envd-rs deps: edit `envd-rs/Cargo.toml`
- **Generated code**: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore
- **Migrations**: Always use `make migrate-create name=xxx`, never create migration files manually

View File

@ -1,53 +0,0 @@
use std::sync::Arc;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use crate::auth::token::SecureToken;
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
/// Paths excluded from general token auth.
/// Format: "METHOD/path"
const AUTH_EXCLUDED: &[&str] = &[
"GET/health",
"GET/activity",
"GET/files",
"POST/files",
"POST/init",
"POST/snapshot/prepare",
];
/// Axum middleware that checks X-Access-Token header.
pub async fn auth_layer(request: Request, next: Next, access_token: Arc<SecureToken>) -> Response {
if access_token.is_set() {
let method = request.method().as_str();
let path = request.uri().path();
let key = format!("{method}{path}");
let is_excluded = AUTH_EXCLUDED.iter().any(|p| *p == key);
let header_val = request
.headers()
.get(ACCESS_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !access_token.equals(header_val) && !is_excluded {
tracing::error!("unauthorized access attempt");
return (
StatusCode::UNAUTHORIZED,
axum::Json(json!({
"code": 401,
"message": "unauthorized access, please provide a valid access token or method signing if supported"
})),
)
.into_response();
}
}
next.run(request).await
}

View File

@ -1,3 +1,2 @@
pub mod middleware;
pub mod signing;
pub mod token;

View File

@ -1,3 +1,5 @@
use subtle::ConstantTimeEq;
use crate::auth::token::SecureToken;
use crate::crypto;
use zeroize::Zeroize;
@ -67,7 +69,9 @@ pub fn validate_signing(
let expected = generate_signature(token, path, username, operation, signature_expiration)
.map_err(|e| format!("error generating signing key: {e}"))?;
if expected != sig {
// Constant-time compare: both values are fixed-length `v1_<64 hex>` digests,
// so this matches the timing-side-channel posture of SecureToken::equals.
if expected.as_bytes().ct_eq(sig.as_bytes()).unwrap_u8() != 1 {
return Err("invalid signature".into());
}

View File

@ -30,8 +30,8 @@ impl FilesystemServiceImpl {
}
}
fn resolve_path(&self, path: &str, ctx: &Context) -> Result<String, ConnectError> {
let username = extract_username(ctx).unwrap_or_else(|| self.state.defaults.user());
fn resolve_path(&self, path: &str) -> Result<String, ConnectError> {
let username = self.state.defaults.user();
let user = lookup_user(&username).map_err(|e| {
ConnectError::new(ErrorCode::Unauthenticated, format!("invalid user: {e}"))
})?;
@ -44,20 +44,13 @@ impl FilesystemServiceImpl {
}
}
fn extract_username(ctx: &Context) -> Option<String> {
ctx.extensions.get::<AuthUser>().map(|u| u.0.clone())
}
#[derive(Clone)]
pub struct AuthUser(pub String);
impl Filesystem for FilesystemServiceImpl {
async fn stat(
&self,
ctx: Context,
request: buffa::view::OwnedView<StatRequestView<'static>>,
) -> Result<(StatResponse, Context), ConnectError> {
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
let entry = build_entry_info(&path)?;
Ok((
StatResponse {
@ -73,7 +66,7 @@ impl Filesystem for FilesystemServiceImpl {
ctx: Context,
request: buffa::view::OwnedView<MakeDirRequestView<'static>>,
) -> Result<(MakeDirResponse, Context), ConnectError> {
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
match std::fs::metadata(&path) {
Ok(meta) => {
@ -97,7 +90,7 @@ impl Filesystem for FilesystemServiceImpl {
}
}
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
let username = self.state.defaults.user();
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
ensure_dirs(&path, user.uid, user.gid)
@ -118,10 +111,10 @@ impl Filesystem for FilesystemServiceImpl {
ctx: Context,
request: buffa::view::OwnedView<MoveRequestView<'static>>,
) -> Result<(MoveResponse, Context), ConnectError> {
let source = self.resolve_path(request.source, &ctx)?;
let destination = self.resolve_path(request.destination, &ctx)?;
let source = self.resolve_path(request.source)?;
let destination = self.resolve_path(request.destination)?;
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
let username = self.state.defaults.user();
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
if let Some(parent) = Path::new(&destination).parent() {
@ -157,7 +150,7 @@ impl Filesystem for FilesystemServiceImpl {
depth = 1;
}
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
// The recursive walk stats every entry (plus uid/gid lookups) — on a
// large tree that is seconds of blocking syscalls, so it runs on the
@ -200,7 +193,7 @@ impl Filesystem for FilesystemServiceImpl {
ctx: Context,
request: buffa::view::OwnedView<RemoveRequestView<'static>>,
) -> Result<(RemoveResponse, Context), ConnectError> {
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
// remove_dir_all recurses through the whole tree — blocking pool, not
// a runtime worker thread.
@ -250,7 +243,7 @@ impl Filesystem for FilesystemServiceImpl {
) -> Result<(CreateWatcherResponse, Context), ConnectError> {
use notify::{RecursiveMode, Watcher};
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
let recursive = request.recursive;
if let Ok(true) = crate::rpc::entry::is_network_mount(&path) {

View File

@ -1,3 +1,5 @@
import { apiFailure } from '$lib/api/client';
export type AuthResponse = {
user_id: string;
team_id: string;
@ -38,8 +40,8 @@ async function authFetch<T = AuthResponse>(url: string, body: Record<string, str
const data = await res.json();
if (!res.ok) {
const message = data?.error?.message ?? 'Something went wrong';
return { ok: false, error: message };
const failure = apiFailure(data);
return { ok: false, error: failure.error };
}
return { ok: true, data: data as T };

View File

@ -1,7 +1,42 @@
import { goto } from '$app/navigation';
import { auth, readCSRFToken } from '$lib/auth.svelte';
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
export type ApiFailure = {
ok: false;
/** Human-readable message, ready to show in a toast. */
error: string;
/** Machine-readable error code, e.g. "sandbox_not_running". */
code?: string;
/** Server request ID — quote it when reporting issues. */
requestId?: string;
/** Whether retrying the same request may succeed. */
retryable?: boolean;
details?: Record<string, unknown>;
};
export type ApiResult<T> = { ok: true; data: T } | ApiFailure;
/**
* Normalizes the API error envelope
* `{"error":{code,message,request_id,retryable,details}}` into an ApiFailure.
* Internal errors get the request ID appended so users can quote it.
*/
export function apiFailure(data: unknown, fallback = 'Something went wrong'): ApiFailure {
const e = (data as { error?: Record<string, unknown> } | null)?.error;
let message = typeof e?.message === 'string' && e.message ? e.message : fallback;
const requestId = typeof e?.request_id === 'string' ? e.request_id : undefined;
if (requestId && e?.code === 'internal_error') {
message += ` (ref: ${requestId})`;
}
return {
ok: false,
error: message,
code: typeof e?.code === 'string' ? e.code : undefined,
requestId,
retryable: e?.retryable === true,
details: (e?.details as Record<string, unknown> | undefined) ?? undefined
};
}
async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
if (res.status === 204 || res.status === 202) {
@ -19,8 +54,17 @@ async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
}
}
const data = await res.json();
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
let data: unknown;
try {
data = await res.json();
} catch {
// Non-JSON body (e.g. a 502/504 from the proxy when the control plane
// is unreachable). Surface the status rather than masking it as a
// connection failure in the caller's catch.
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true, data: undefined as T };
}
if (!res.ok) return apiFailure(data);
return { ok: true, data: data as T };
}

View File

@ -1,5 +1,5 @@
import { readCSRFToken } from '$lib/auth.svelte';
import { apiFetch, type ApiResult } from '$lib/api/client';
import { apiFailure, apiFetch, type ApiResult } from '$lib/api/client';
export type FileEntry = {
name: string;
@ -78,7 +78,8 @@ export async function readFile(
if (!res.ok) {
try {
const data = await res.json();
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
const failure = apiFailure(data, 'Failed to read file');
return { ok: false, error: failure.error };
} catch {
return { ok: false, error: `HTTP ${res.status}` };
}
@ -114,7 +115,15 @@ export async function downloadFile(
signal,
});
if (!res.ok) throw new Error('Download failed');
if (!res.ok) {
let data: unknown;
try {
data = await res.json();
} catch {
throw new Error(`Download failed (HTTP ${res.status})`);
}
throw new Error(apiFailure(data, 'Download failed').error);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);

View File

@ -1,4 +1,4 @@
import { apiFetch } from './client';
import { apiFetch, type ApiFailure } from './client';
export type Host = {
id: string;
@ -55,11 +55,11 @@ export async function createHost(
export async function deleteHost(
id: string,
force = false
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
): Promise<{ ok: true } | ApiFailure> {
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
const res = await apiFetch<void>('DELETE', url);
if (!res.ok) {
return res as { ok: false; error: string };
return res;
}
return { ok: true };
}

View File

@ -39,7 +39,7 @@
};
const ACTIONS_BY_RESOURCE: Record<string, string[]> = {
sandbox: ['create', 'pause', 'resume', 'destroy'],
sandbox: ['create', 'pause', 'resume', 'destroy', 'error'],
snapshot: ['create', 'delete'],
template: ['delete'],
build: ['create', 'cancel'],
@ -56,6 +56,7 @@
pause: 'Paused',
resume: 'Resumed',
destroy: 'Destroyed',
error: 'Error',
delete: 'Deleted',
rename: 'Renamed',
revoke: 'Revoked',
@ -207,6 +208,9 @@
const DELETED_BADGE = '\x00DELETED\x00';
const deletedBadgeHtml = '<span class="deleted-user-badge">deleted-user</span>';
// The interpolated fields (user/API-key/team names, emails) are constrained
// on the backend to an HTML-safe charset — no < > & " ' can be stored — so
// the only markup here is the trusted DELETED_BADGE swap.
function renderDeleted(text: string): string {
return text.replaceAll(DELETED_BADGE, deletedBadgeHtml);
}
@ -219,6 +223,10 @@
case 'sandbox:pause': return `${actor} paused a capsule`;
case 'sandbox:resume': return `${actor} resumed a capsule`;
case 'sandbox:destroy': return `${actor} destroyed a capsule`;
case 'sandbox:error':
if (meta.phase === 'resume') return 'A capsule failed to resume';
if (meta.phase === 'create') return 'A capsule failed to start';
return 'A capsule encountered an error';
case 'snapshot:create': return `${actor} created a snapshot`;
case 'snapshot:delete': return `${actor} deleted a snapshot`;
case 'template:delete': return `${actor} deleted template "${log.resource_id}"`;

View File

@ -37,7 +37,7 @@
};
const ACTIONS_BY_RESOURCE: Record<string, string[]> = {
sandbox: ['create', 'pause', 'resume', 'destroy'],
sandbox: ['create', 'pause', 'resume', 'destroy', 'error'],
snapshot: ['create', 'delete'],
team: ['rename'],
api_key: ['create', 'revoke'],
@ -50,6 +50,7 @@
pause: 'Paused',
resume: 'Resumed',
destroy: 'Destroyed',
error: 'Error',
delete: 'Deleted',
rename: 'Renamed',
revoke: 'Revoked',
@ -195,6 +196,9 @@
const DELETED_BADGE = '\x00DELETED\x00';
const deletedBadgeHtml = '<span class="deleted-user-badge">deleted-user</span>';
// The interpolated fields (user/API-key/team names, emails) are constrained
// on the backend to an HTML-safe charset — no < > & " ' can be stored — so
// the only markup here is the trusted DELETED_BADGE swap.
function renderDeleted(text: string): string {
return text.replaceAll(DELETED_BADGE, deletedBadgeHtml);
}
@ -207,6 +211,10 @@
case 'sandbox:pause': return `${actor} paused a capsule`;
case 'sandbox:resume': return `${actor} resumed a capsule`;
case 'sandbox:destroy': return `${actor} destroyed a capsule`;
case 'sandbox:error':
if (meta.phase === 'resume') return 'A capsule failed to resume';
if (meta.phase === 'create') return 'A capsule failed to start';
return 'A capsule encountered an error';
case 'snapshot:create': return `${actor} created a template`;
case 'snapshot:delete': return `${actor} deleted a template`;
case 'team:rename': return `${actor} renamed the team from "${meta.old_name}" to "${meta.new_name}"`;

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFailure } from '$lib/api/client';
type EndpointStatus = 'loading' | 'available' | 'not_available' | 'error';
let status = $state<EndpointStatus>('loading');
@ -16,7 +17,7 @@
status = 'error';
try {
const data = await res.json();
errorMsg = data?.error?.message ?? `Server returned ${res.status}`;
errorMsg = apiFailure(data, `Server returned ${res.status}`).error;
} catch {
errorMsg = `Server returned ${res.status}`;
}

View File

@ -10,7 +10,7 @@ source "$(cd "$(dirname "$0")" && pwd)/build-common.sh"
# Alpine is musl-based: the static envd + static tini run fine. bash is added so
# wrenn-user has a familiar login shell; wrenn-init itself only needs /bin/sh.
PREP="set -e
apk add --no-cache socat chrony sudo wget curl ca-certificates git iproute2 tini bash
apk add --no-cache socat chrony sudo wget curl ca-certificates git iproute2 tini bash gcc make nano vim
adduser -D wrenn-user
${WRENN_SUDOERS_SETUP}"

View File

@ -12,7 +12,7 @@ source "$(cd "$(dirname "$0")" && pwd)/build-common.sh"
# tini is AUR-only on Arch (not in core/extra), so it is not installed here —
# rootfs-from-container.sh injects the static tini binary instead.
PREP="set -e
pacman -Sy --noconfirm --needed socat chrony sudo wget curl ca-certificates git iproute2 inetutils
pacman -Sy --noconfirm --needed socat chrony sudo wget curl ca-certificates git iproute2 inetutils gcc make nano vim
useradd -m -s /bin/bash wrenn-user
${WRENN_SUDOERS_SETUP}
pacman -Scc --noconfirm || true"

View File

@ -11,7 +11,7 @@ source "$(cd "$(dirname "$0")" && pwd)/build-common.sh"
PREP="set -e
# install_weak_deps=False keeps the image lean. The guest never runs systemd:
# PID 1 is wrenn-init -> tini -> envd.
dnf install -y --setopt=install_weak_deps=False socat chrony sudo wget curl ca-certificates git iproute hostname tini
dnf install -y --setopt=install_weak_deps=False socat chrony sudo wget curl ca-certificates git iproute hostname tini gcc make nano vim
useradd -m -s /bin/bash wrenn-user
${WRENN_SUDOERS_SETUP}
dnf clean all"

View File

@ -12,7 +12,7 @@ export DEBIAN_FRONTEND=noninteractive
apt-get update
# --no-install-recommends keeps the image lean (avoids pulling systemd-adjacent
# recommends). The guest never runs systemd: PID 1 is wrenn-init -> tini -> envd.
apt-get install -y --no-install-recommends socat chrony sudo wget curl ca-certificates git iproute2 hostname tini
apt-get install -y --no-install-recommends socat chrony sudo wget curl ca-certificates git iproute2 hostname tini gcc make nano vim
# Remove the stock 'ubuntu' user (uid 1000) shipped by the base image; it is
# replaced by wrenn-user. Also drop its cloud-init sudoers drop-in.
userdel -r ubuntu 2>/dev/null || true

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -39,17 +40,17 @@ func requireRunningSandbox(w http.ResponseWriter, r *http.Request, queries *db.Q
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return db.Sandbox{}, pgtype.UUID{}, "", false
}
sb, err := queries.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return db.Sandbox{}, pgtype.UUID{}, "", false
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")")
writeErr(w, r, apperr.SandboxNotRunning.New().With("status", sb.Status))
return db.Sandbox{}, pgtype.UUID{}, "", false
}
@ -71,7 +72,7 @@ func upgradeAndAuthenticate(w http.ResponseWriter, r *http.Request) (*websocket.
func upgradeAndAuthenticateWith(w http.ResponseWriter, r *http.Request, up *websocket.Upgrader) (*websocket.Conn, auth.AuthContext, error) {
ac, hasAuth := auth.FromContext(r.Context())
if !hasAuth {
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
writeErr(w, r, apperr.AuthSessionRequired.New())
return nil, auth.AuthContext{}, fmt.Errorf("unauthenticated")
}
conn, err := up.Upgrade(w, r, nil)

View File

@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -143,25 +144,26 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
// Validate port.
portNum, err := strconv.Atoi(port)
if err != nil || portNum < 1 || portNum > 65535 {
http.Error(w, "invalid port", http.StatusBadRequest)
writeErr(w, r, apperr.InvalidRequest.Msg("Invalid port."))
return
}
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
http.Error(w, "invalid sandbox ID", http.StatusBadRequest)
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
agentURL, err := h.proxyTarget(r.Context(), sandboxID)
if err != nil {
var notRunning errProxySandboxNotRunning
switch {
case errors.Is(err, errProxySandboxNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.As(err, new(errProxySandboxNotRunning)):
http.Error(w, err.Error(), http.StatusConflict)
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
case errors.As(err, &notRunning):
writeErr(w, r, apperr.SandboxNotRunning.New().With("status", notRunning.status))
default:
http.Error(w, err.Error(), http.StatusServiceUnavailable)
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
}
return
}
@ -196,7 +198,7 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
"error", err,
)
h.evictProxyCache(sandboxID)
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
},
}
proxy.ServeHTTP(w, r)

View File

@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -29,7 +30,7 @@ func newAdminCapsuleHandler(svc *service.SandboxService, db *db.Queries, pool *l
func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSandboxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
@ -48,8 +49,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if sb.ID.Valid {
h.audit.LogSandboxDestroySystem(r.Context(), id.PlatformTeamID, sb.ID, "cleanup_after_create_error", nil)
}
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -60,7 +60,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
func (h *adminCapsuleHandler) List(w http.ResponseWriter, r *http.Request) {
sandboxes, err := h.svc.List(r.Context(), id.PlatformTeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -78,13 +78,13 @@ func (h *adminCapsuleHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Get(r.Context(), sandboxID, id.PlatformTeamID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return
}
@ -98,7 +98,7 @@ func (h *adminCapsuleHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
@ -106,8 +106,7 @@ func (h *adminCapsuleHandler) Destroy(w http.ResponseWriter, r *http.Request) {
err = h.svc.Destroy(r.Context(), sandboxID, id.PlatformTeamID)
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -125,22 +124,21 @@ func (h *adminCapsuleHandler) Snapshot(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
var req adminSnapshotRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
}
sb, name, err := h.svc.CreateSnapshot(r.Context(), sandboxID, id.PlatformTeamID, req.Name)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
ac := auth.MustFromContext(r.Context())

View File

@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -81,13 +82,13 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createAPIKeyRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
result, err := h.svc.Create(r.Context(), ac.TeamID, ac.UserID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create API key")
writeErr(w, r, err)
return
}
@ -104,7 +105,7 @@ func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
keys, err := h.svc.ListWithCreator(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys")
writeErr(w, r, err)
return
}
@ -123,12 +124,12 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
keyID, err := id.ParseAPIKeyID(keyIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid API key ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid API key ID."))
return
}
if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")
writeErr(w, r, err)
return
}

View File

@ -1,6 +1,7 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
@ -8,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
@ -48,10 +50,14 @@ func parseAuditParams(r *http.Request) (before time.Time, beforeID pgtype.UUID,
if s := r.URL.Query().Get("limit"); s != "" {
n, parseErr := strconv.Atoi(s)
if parseErr != nil || n < 1 {
if parseErr != nil {
err = parseErr
return
}
if n < 1 {
err = fmt.Errorf("limit must be a positive integer, got %d", n)
return
}
limit = n
}
@ -107,7 +113,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
before, beforeID, limit, err := parseAuditParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid query parameters."))
return
}
@ -121,7 +127,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -134,7 +140,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
before, beforeID, limit, err := parseAuditParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid query parameters."))
return
}
@ -148,7 +154,7 @@ func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -19,11 +19,14 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
const (
@ -186,7 +189,7 @@ func (h *authHandler) issueSession(
email, name, role string,
isAdmin bool,
) error {
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), clientIP(r))
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
return err
}
@ -194,18 +197,6 @@ func (h *authHandler) issueSession(
return nil
}
// clientIP returns the request's apparent client IP, honoring
// X-Forwarded-For when behind a reverse proxy.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}
type signupResponse struct {
Message string `json:"message"`
}
@ -214,22 +205,22 @@ type signupResponse struct {
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
var req signupRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Name = strings.TrimSpace(req.Name)
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
if err := validate.Email(req.Email); err != nil {
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "A valid email address is required.").With("field", "email"))
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "password"))
return
}
if req.Name == "" || len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
if err := validate.DisplayName(req.Name); err != nil {
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").With("field", "name"))
return
}
@ -243,28 +234,27 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
case "inactive":
// Unactivated user — allow re-signup after cooldown.
if time.Since(existing.CreatedAt.Time) < signupCooldown {
writeError(w, http.StatusConflict, "signup_cooldown",
"an activation email was recently sent to this address — please check your inbox or try again later")
writeErr(w, r, apperr.AuthSignupCooldown.New())
return
}
// Cooldown passed — delete the old row and proceed with fresh signup.
if err := h.db.HardDeleteUser(ctx, existing.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to clean up previous signup")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
default:
// active, disabled, deleted — email is taken.
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
writeErr(w, r, apperr.AuthEmailTaken.New())
return
}
} else if !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -278,10 +268,10 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
writeErr(w, r, apperr.AuthEmailTaken.Wrap(err))
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to create user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -292,7 +282,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(userID), activationTTL).Err(); err != nil {
slog.Error("signup: failed to store activation token in redis", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create activation token")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -319,12 +309,12 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
var req activateRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
return
}
@ -334,28 +324,28 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
if errors.Is(err, redis.Nil) {
writeError(w, http.StatusBadRequest, "invalid_token", "activation link is invalid or has expired")
writeErr(w, r, apperr.AuthTokenInvalid.Wrap(err))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
user, err := h.db.GetUserByID(ctx, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if user.Status != "inactive" {
writeError(w, http.StatusBadRequest, "already_activated", "this account has already been activated")
writeErr(w, r, apperr.AuthAlreadyActivated.New())
return
}
@ -365,7 +355,7 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
Status: "active",
}); err != nil {
slog.Error("activate: failed to set user status", "user_id", id.FormatUserID(userID), "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to activate user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -373,7 +363,7 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, userID, user.Name)
if err != nil {
slog.Error("activate: failed to create default team", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to set up account")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -381,12 +371,12 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
// Fire OnSignup before issuing a session — billing must succeed first.
if err := fireOnSignup(ctx, h.authHooks, userID, team.ID, user.Email); err != nil {
slog.Error("activate: OnSignup hook failed", "user_id", id.FormatUserID(userID), "error", err)
writeError(w, http.StatusInternalServerError, "signup_hook_failed", "failed to finalize account setup")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if err := h.issueSession(w, r, userID, team.ID, user.Email, user.Name, role, isAdmin); err != nil {
slog.Error("activate: failed to issue session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create session")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
fireOnLogin(ctx, h.authHooks, userID)
@ -405,13 +395,13 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required")
writeErr(w, r, apperr.ValidationFailed.Msg("Email and password are required."))
return
}
@ -421,21 +411,21 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("login failed: unknown email", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if !user.PasswordHash.Valid {
slog.Warn("login failed: no password set", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil {
slog.Warn("login failed: wrong password", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
@ -444,18 +434,18 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
// OK — proceed.
case "inactive":
slog.Warn("login failed: account not activated", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusForbidden, "account_not_activated", "please check your email and activate your account before signing in")
writeErr(w, r, apperr.AuthAccountNotActivated.New())
return
case "disabled":
slog.Warn("login failed: account disabled", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusForbidden, "account_disabled", "your account has been deactivated — contact your administrator to regain access")
writeErr(w, r, apperr.AuthAccountDisabled.New())
return
case "deleted":
slog.Warn("login failed: account deleted", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
default:
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
@ -463,14 +453,14 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, user.ID, user.Name)
if err != nil {
slog.Error("login: failed to ensure default team", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
isAdmin := user.IsAdmin || isFirstUser
if err := h.issueSession(w, r, user.ID, team.ID, user.Email, user.Name, role, isAdmin); err != nil {
slog.Error("login: failed to issue session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create session")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
fireOnLogin(ctx, h.authHooks, user.ID)
@ -494,17 +484,17 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
var req switchTeamRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.TeamID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "team_id is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The team_id field is required.").With("field", "team_id"))
return
}
teamID, err := id.ParseTeamID(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "The team_id field is invalid.").With("field", "team_id"))
return
}
@ -514,14 +504,14 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
team, err := h.db.GetTeam(ctx, teamID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "team not found")
writeErr(w, r, apperr.TeamNotFound.Wrap(err))
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if team.DeletedAt.Valid {
writeError(w, http.StatusNotFound, "not_found", "team not found")
writeErr(w, r, apperr.TeamNotFound.New())
return
}
@ -532,26 +522,26 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusForbidden, "forbidden", "not a member of this team")
writeErr(w, r, apperr.Forbidden.WrapMsg(err, "You are not a member of this team."))
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
// Fetch current name from DB — JWT name is not trusted here (may be stale or empty for old tokens).
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
// Rotate the SID so any leaked old cookie loses access at the moment of
// privilege change.
newSess, err := h.sessions.Rotate(ctx, ac.SessionID, ac.UserID, teamID, user.Email, user.Name, membership.Role, user.IsAdmin, r.UserAgent(), clientIP(r))
newSess, err := h.sessions.Rotate(ctx, ac.SessionID, ac.UserID, teamID, user.Email, user.Name, membership.Role, user.IsAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
slog.Error("switch team: failed to rotate session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to switch team")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
setSessionCookies(w, newSess.RawSID, newSess.CSRFToken, isSecure(r))
@ -582,7 +572,7 @@ func (h *authHandler) LogoutAll(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
if err := h.sessions.RevokeAllForUser(r.Context(), ac.UserID); err != nil {
slog.Error("logout-all: revoke failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
clearSessionCookies(w, isSecure(r))

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"git.omukk.dev/wrenn/wrenn/internal/recipe"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
@ -39,13 +40,13 @@ func (h *buildStreamHandler) Stream(w http.ResponseWriter, r *http.Request) {
buildIDStr := chi.URLParam(r, "id")
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
return
}
build, err := h.db.GetTemplateBuild(r.Context(), buildID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "build not found")
writeErr(w, r, apperr.BuildNotFound.Wrap(err))
return
}

View File

@ -2,7 +2,6 @@ package api
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@ -11,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -120,18 +120,18 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(ct, "multipart/") {
// 100 MB max for multipart (archive + JSON config).
if err := r.ParseMultipartForm(100 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart form")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Failed to parse the multipart form."))
return
}
// Parse JSON config from "config" field.
configStr := r.FormValue("config")
if configStr == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "multipart form requires a 'config' JSON field")
writeErr(w, r, apperr.ValidationFailed.Msg("The config field is required in the multipart form.").With("field", "config"))
return
}
if err := json.Unmarshal([]byte(configStr), &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid config JSON in multipart form")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid config JSON in the multipart form."))
return
}
@ -143,32 +143,32 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
lr := io.LimitReader(file, maxArchiveSize+1)
archive, err = io.ReadAll(lr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to read archive file")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Failed to read the archive file."))
return
}
if int64(len(archive)) > maxArchiveSize {
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request", "archive exceeds 100 MB limit")
writeErr(w, r, apperr.PayloadTooLarge.Msg("The archive exceeds the 100 MB limit."))
return
}
archiveName = header.Filename
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "name is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The name field is required.").With("field", "name"))
return
}
if err := validate.SafeName(req.Name); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Invalid template name.").With("field", "name"))
return
}
if len(req.Recipe) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "recipe must contain at least one command")
writeErr(w, r, apperr.ValidationFailed.Msg("The recipe must contain at least one command.").With("field", "recipe"))
return
}
@ -186,7 +186,7 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
slog.Error("failed to create build", "error", err)
writeError(w, http.StatusInternalServerError, "build_error", "failed to create build")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -199,7 +199,7 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
func (h *buildHandler) List(w http.ResponseWriter, r *http.Request) {
builds, err := h.svc.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list builds")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -217,13 +217,13 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
return
}
build, err := h.svc.Get(r.Context(), buildID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "build not found")
writeErr(w, r, apperr.BuildNotFound.Wrap(err))
return
}
@ -234,7 +234,7 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.db.ListTemplates(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -275,31 +275,30 @@ func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := validate.SafeName(name); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
return
}
ctx := r.Context()
tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "template not found")
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
return
}
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
writeError(w, http.StatusForbidden, "forbidden", "system base templates cannot be deleted")
writeErr(w, r, apperr.TemplateProtected.New())
return
}
// Remove the files from every host before dropping the DB record, so a
// failure leaves the template intact and retryable rather than orphaned.
if err := deleteSnapshotEverywhere(ctx, h.db, h.pool, tmpl.TeamID, tmpl.ID); err != nil {
writeError(w, http.StatusConflict, "delete_failed",
"could not remove template files from all hosts: "+err.Error())
writeErr(w, r, apperr.Conflict.WrapMsg(err, "Could not remove template files from all hosts. Try again when all hosts are online."))
return
}
if err := h.db.DeleteTemplate(ctx, tmpl.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -314,12 +313,14 @@ func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
return
}
if err := h.svc.Cancel(r.Context(), buildID); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
// Cancel returns typed apperr errors (BuildNotFound / Conflict); pass
// them through so not-found stays 404 rather than collapsing to 409.
writeErr(w, r, err)
return
}

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/channels"
@ -79,7 +80,7 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
@ -91,8 +92,7 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
Events: req.Events,
})
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -112,7 +112,7 @@ func (h *channelHandler) List(w http.ResponseWriter, r *http.Request) {
chs, err := h.svc.List(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list channels")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -131,16 +131,16 @@ func (h *channelHandler) Get(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
ch, err := h.svc.Get(r.Context(), channelID, ac.TeamID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "channel not found")
writeErr(w, r, apperr.NotFound.WrapMsg(err, "Channel not found."))
} else {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get channel")
writeErr(w, r, apperr.Internal.Wrap(err))
}
return
}
@ -155,20 +155,19 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
var req updateChannelRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
ch, err := h.svc.Update(r.Context(), channelID, ac.TeamID, req.Name, req.Events)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -180,13 +179,12 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
func (h *channelHandler) Test(w http.ResponseWriter, r *http.Request) {
var req testChannelRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if err := h.svc.Test(r.Context(), req.Provider, req.Config); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -200,20 +198,19 @@ func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
var req rotateConfigRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
ch, err := h.svc.RotateConfig(r.Context(), channelID, ac.TeamID, req.Config)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -228,12 +225,12 @@ func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
if err := h.svc.Delete(r.Context(), channelID, ac.TeamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete channel")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -65,18 +66,18 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
var req execRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Cmd == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "cmd is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The cmd field is required.").With("field", "cmd"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -96,8 +97,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
Cwd: req.Cwd,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -123,8 +123,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
Cwd: req.Cwd,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -53,7 +54,7 @@ func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}

View File

@ -8,6 +8,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -42,35 +43,35 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "file exceeds 100 MB limit")
writeErr(w, r, apperr.PayloadTooLarge.Msg("File exceeds the 100 MB limit."))
return
}
writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Expected multipart/form-data."))
return
}
filePath := r.FormValue("path")
if filePath == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "file field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The file field is required.").With("field", "file"))
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file")
writeErr(w, r, apperr.Internal.WrapMsg(err, "Failed to read the uploaded file."))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -79,8 +80,7 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
Path: filePath,
Content: content,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -104,18 +104,18 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
var req readFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -124,8 +124,7 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -40,7 +41,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
contentType := r.Header.Get("Content-Type")
_, params, err := mime.ParseMediaType(contentType)
if err != nil || params["boundary"] == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data with boundary")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Expected multipart/form-data with a boundary."))
return
}
@ -56,7 +57,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
break
}
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Malformed multipart request body."))
return
}
switch part.FormName() {
@ -72,18 +73,18 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
}
if filePath == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
if filePart == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "file field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The file field is required.").With("field", "file"))
return
}
defer filePart.Close()
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -105,7 +106,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
},
},
}); err != nil {
writeError(w, http.StatusBadGateway, "agent_error", "failed to send file metadata")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -119,7 +120,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
if sendErr := stream.Send(&pb.WriteFileStreamRequest{
Content: &pb.WriteFileStreamRequest_Chunk{Chunk: chunk},
}); sendErr != nil {
writeError(w, http.StatusBadGateway, "agent_error", "failed to stream file chunk")
writeErr(w, r, apperr.HostUnreachable.Wrap(sendErr))
return
}
}
@ -127,7 +128,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
break
}
if err != nil {
writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file")
writeErr(w, r, apperr.Internal.WrapMsg(err, "Failed to read the uploaded file."))
return
}
}
@ -135,8 +136,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
// Close and receive response.
streamClosed = true
if _, err := stream.CloseAndReceive(); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -156,17 +156,17 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
var req readFileRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -176,8 +176,7 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
defer stream.Close()

View File

@ -5,6 +5,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -66,17 +67,17 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
var req listDirRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -86,8 +87,7 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
Depth: req.Depth,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -111,17 +111,17 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
var req makeDirRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -130,8 +130,7 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -150,17 +149,17 @@ func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
var req removeRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -168,8 +167,7 @@ func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
SandboxId: sandboxIDStr,
Path: req.Path,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -187,7 +188,7 @@ func (h *hostHandler) isAdmin(r *http.Request, userID pgtype.UUID) bool {
func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createHostRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
@ -203,7 +204,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
if req.TeamID != "" {
teamID, err := id.ParseTeamID(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID.").With("field", "team_id"))
return
}
params.TeamID = teamID
@ -211,8 +212,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.Create(r.Context(), params)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -232,7 +232,7 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.List(r.Context(), ac.TeamID, admin)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -284,14 +284,13 @@ func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
host, err := h.svc.Get(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -306,14 +305,13 @@ func (h *hostHandler) DeletePreview(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
preview, err := h.svc.DeletePreview(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -333,7 +331,7 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
@ -353,18 +351,11 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
// Check if it's a "has running sandboxes" error and return a structured 409.
var hasSandboxes *service.HostHasSandboxesError
if errors.As(err, &hasSandboxes) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]any{
"code": "has_active_sandboxes",
"message": "host has active sandboxes; use ?force=true to destroy them and delete the host",
"sandbox_ids": hasSandboxes.SandboxIDs,
},
})
writeErr(w, r, apperr.HostHasActiveSandboxes.Wrap(err).With("sandbox_ids", hasSandboxes.SandboxIDs))
return
}
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
}
// AdminList handles GET /v1/admin/hosts.
@ -372,7 +363,7 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
func (h *hostHandler) AdminList(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.ListAdmin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -422,14 +413,13 @@ func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
result, err := h.svc.RegenerateToken(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -443,16 +433,16 @@ func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
var req registerHostRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
return
}
if req.Address == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "address is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The address field is required.").With("field", "address"))
return
}
@ -465,8 +455,7 @@ func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
Address: req.Address,
})
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -487,13 +476,13 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
// Prevent a host from heartbeating for a different host.
if hostID != hc.HostID {
writeError(w, http.StatusForbidden, "forbidden", "host ID mismatch")
writeErr(w, r, apperr.Forbidden.Msg("Host ID does not match the authenticated host."))
return
}
@ -501,8 +490,7 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
prevHost, _ := h.queries.GetHost(r.Context(), hc.HostID)
if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -525,23 +513,22 @@ func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
var req addTagRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Tag == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "tag is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The tag field is required.").With("field", "tag"))
return
}
if err := h.svc.AddTag(r.Context(), hostID, ac.TeamID, admin, req.Tag); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -556,13 +543,12 @@ func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
if err := h.svc.RemoveTag(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID), tag); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -574,18 +560,17 @@ func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
func (h *hostHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req refreshTokenRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "refresh_token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The refresh_token field is required.").With("field", "refresh_token"))
return
}
result, err := h.svc.Refresh(r.Context(), req.RefreshToken)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -606,14 +591,13 @@ func (h *hostHandler) ListTags(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
tags, err := h.svc.ListTags(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -3,7 +3,6 @@ package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
@ -15,6 +14,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
@ -22,6 +22,7 @@ import (
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
const (
@ -119,13 +120,13 @@ func (h *meHandler) GetMe(w http.ResponseWriter, r *http.Request) {
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -154,13 +155,13 @@ func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
var req updateNameRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" || len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
if err := validate.DisplayName(req.Name); err != nil {
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").With("field", "name"))
return
}
@ -168,7 +169,7 @@ func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
ID: ac.UserID,
Name: req.Name,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update name")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -188,46 +189,46 @@ func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
var req changePasswordRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if user.PasswordHash.Valid {
// Changing existing password — verify current.
if req.CurrentPassword == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "current_password is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The current_password field is required.").With("field", "current_password"))
return
}
if err := auth.CheckPassword(user.PasswordHash.String, req.CurrentPassword); err != nil {
writeError(w, http.StatusUnauthorized, "wrong_password", "current password is incorrect")
writeErr(w, r, apperr.AuthInvalidCredentials.WrapMsg(err, "Current password is incorrect."))
return
}
} else {
// OAuth user adding a password — confirm must match.
if req.ConfirmPassword == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "confirm_password is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The confirm_password field is required.").With("field", "confirm_password"))
return
}
if req.NewPassword != req.ConfirmPassword {
writeError(w, http.StatusBadRequest, "invalid_request", "passwords do not match")
writeErr(w, r, apperr.ValidationFailed.Msg("Passwords do not match.").With("field", "confirm_password"))
return
}
}
if len(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "new_password"))
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -235,7 +236,7 @@ func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ID: ac.UserID,
PasswordHash: pgtype.Text{String: hash, Valid: true},
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -328,16 +329,16 @@ func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request)
func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
var req confirmPasswordResetRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
return
}
if len(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "new_password"))
return
}
@ -349,29 +350,29 @@ func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request)
// preventing concurrent requests from both consuming the same token.
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
if errors.Is(err, redis.Nil) {
writeError(w, http.StatusBadRequest, "invalid_token", "reset token is invalid or has expired")
writeErr(w, r, apperr.AuthTokenInvalid.Wrap(err))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
user, err := h.db.GetUserByID(ctx, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -379,7 +380,7 @@ func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request)
ID: userID,
PasswordHash: pgtype.Text{String: hash, Valid: true},
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -411,13 +412,13 @@ func (h *meHandler) ConnectProvider(w http.ResponseWriter, r *http.Request) {
p, ok := h.oauthRegistry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
return
}
state, err := generateState()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -455,19 +456,19 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
// Ensure the user will still have at least one login method after disconnecting.
if !user.PasswordHash.Valid && len(providers) <= 1 {
writeError(w, http.StatusBadRequest, "last_login_method", "cannot disconnect your only login method — add a password first")
writeErr(w, r, apperr.InvalidRequest.Msg("You cannot disconnect your only login method — add a password first."))
return
}
@ -480,7 +481,7 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
}
}
if !found {
writeError(w, http.StatusNotFound, "not_found", "provider not connected")
writeErr(w, r, apperr.NotFound.Msg("This provider is not connected to your account."))
return
}
@ -488,7 +489,7 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
UserID: ac.UserID,
Provider: provider,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to disconnect provider")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -502,29 +503,28 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
var req deleteAccountRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if !strings.EqualFold(strings.TrimSpace(req.Confirmation), user.Email) {
writeError(w, http.StatusBadRequest, "invalid_request", "confirmation does not match your email address")
writeErr(w, r, apperr.ValidationFailed.Msg("Confirmation does not match your email address.").With("field", "confirmation"))
return
}
teamsBlocking, err := h.db.CountUserOwnedTeamsWithOtherMembers(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to check team ownership")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if teamsBlocking > 0 {
writeError(w, http.StatusConflict, "owns_team_with_members",
fmt.Sprintf("you own %d team(s) with other members — transfer ownership or remove members before deleting your account", teamsBlocking))
writeErr(w, r, apperr.Conflict.Msgf("You own %d team(s) with other members — transfer ownership or remove members before deleting your account.", teamsBlocking).With("owned_teams_with_members", teamsBlocking))
return
}
@ -534,20 +534,19 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
// DB-only cleanup in a transaction.
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
for _, teamID := range soleTeams {
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error",
fmt.Sprintf("failed to delete sole-owned team %s", id.FormatTeamID(teamID)))
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
}
tx, err := h.pool.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to start transaction")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
defer func() { _ = tx.Rollback(ctx) }()
@ -555,17 +554,17 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
qtx := h.db.WithTx(tx)
if err := qtx.DeleteAPIKeysByCreator(ctx, ac.UserID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete user's API keys")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if err := qtx.SoftDeleteUser(ctx, ac.UserID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit account deletion")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -1,7 +1,6 @@
package api
import (
"context"
"net/http"
"time"
@ -9,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -46,7 +46,7 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
@ -56,13 +56,13 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
}
validRanges := map[string]bool{"5m": true, "10m": true, "1h": true, "2h": true, "6h": true, "12h": true, "24h": true}
if !validRanges[rangeTier] {
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h")
writeErr(w, r, apperr.ValidationFailed.Msg("The range parameter must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h.").With("field", "range"))
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return
}
@ -70,9 +70,9 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
case "running":
h.getFromAgent(w, r, sandboxIDStr, rangeTier, sb.HostID)
case "paused":
h.getFromDB(ctx, w, sandboxIDStr, sandboxID, rangeTier)
h.getFromDB(w, r, sandboxIDStr, sandboxID, rangeTier)
default:
writeError(w, http.StatusNotFound, "not_found", "metrics not available for sandbox in state: "+sb.Status)
writeErr(w, r, apperr.NotFound.Msg("Metrics are not available for a sandbox in state "+sb.Status+".").With("status", sb.Status))
}
}
@ -81,7 +81,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
agent, err := agentForHost(ctx, h.db, h.pool, hostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -90,8 +90,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
Range: rangeTier,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -126,7 +125,8 @@ var rangeToDB = map[string]struct {
"24h": {"24h", 24 * time.Hour},
}
func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWriter, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
func (h *sandboxMetricsHandler) getFromDB(w http.ResponseWriter, r *http.Request, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
ctx := r.Context()
mapping := rangeToDB[rangeTier]
rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{
SandboxID: sandboxID,
@ -134,7 +134,7 @@ func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWr
Ts: time.Now().Add(-mapping.cutoff).Unix(),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to read metrics")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -17,11 +17,14 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
type oauthHandler struct {
@ -51,13 +54,13 @@ func (h *oauthHandler) Redirect(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
p, ok := h.registry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
return
}
state, err := generateState()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -88,7 +91,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
p, ok := h.registry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
return
}
@ -270,6 +273,18 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return
}
// A brand-new account: the email and provider-supplied name enter our
// database here. Validate the email, and coerce the name into the safe
// display-name charset (OAuth names cannot be rejected interactively, so
// they are cleaned rather than refused).
if err := validate.Email(email); err != nil {
slog.Warn("oauth: provider returned an invalid email", "provider", provider)
redirectWithError(w, r, redirectBase, "invalid_email")
return
}
emailLocal, _, _ := strings.Cut(email, "@")
displayName := validate.SanitizeDisplayName(profile.Name, validate.SanitizeDisplayName(emailLocal, "user"))
// New OAuth identity — check for email collision.
existingUser, err := h.db.GetUserByEmail(ctx, email)
if err == nil {
@ -315,7 +330,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
_, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{
ID: userID,
Email: email,
Name: profile.Name,
Name: displayName,
})
if err != nil {
var pgErr *pgconn.PgError
@ -332,7 +347,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
}
teamID := id.NewTeamID()
teamName := profile.Name + "'s Team"
teamName := displayName + "'s Team"
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
ID: teamID,
Name: teamName,
@ -397,7 +412,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
Secure: isSecure(r),
})
if err := h.issueSessionAndRedirect(w, r, userID, teamID, email, profile.Name, "owner", isFirstUser, redirectBase); err != nil {
if err := h.issueSessionAndRedirect(w, r, userID, teamID, email, displayName, "owner", isFirstUser, redirectBase); err != nil {
slog.Error("oauth: failed to issue session", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
return
@ -461,7 +476,7 @@ func (h *oauthHandler) issueSessionAndRedirect(
isAdmin bool,
redirectBase string,
) error {
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), clientIP(r))
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
return err
}

View File

@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -52,7 +53,7 @@ func (h *processHandler) ListProcesses(w http.ResponseWriter, r *http.Request) {
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -60,8 +61,7 @@ func (h *processHandler) ListProcesses(w http.ResponseWriter, r *http.Request) {
SandboxId: sandboxIDStr,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -92,7 +92,7 @@ func (h *processHandler) KillProcess(w http.ResponseWriter, r *http.Request) {
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -112,8 +112,7 @@ func (h *processHandler) KillProcess(w http.ResponseWriter, r *http.Request) {
}
if _, err := agent.KillProcess(ctx, connect.NewRequest(killReq)); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -128,7 +127,7 @@ func (h *processHandler) ConnectProcess(w http.ResponseWriter, r *http.Request)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}

View File

@ -13,6 +13,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -108,7 +109,7 @@ func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -84,13 +85,13 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSandboxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
ac := auth.MustFromContext(r.Context())
if !ac.TeamID.Valid {
writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate")
writeErr(w, r, apperr.Forbidden.Msg("No active team context; re-authenticate."))
return
}
@ -107,8 +108,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
if sb.ID.Valid {
h.audit.LogSandboxDestroySystem(r.Context(), ac.TeamID, sb.ID, "cleanup_after_create_error", nil)
}
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -120,7 +120,7 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
sandboxes, err := h.svc.List(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -139,13 +139,13 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return
}
@ -167,15 +167,14 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID)
h.audit.LogSandboxPause(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -189,15 +188,14 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID)
h.audit.LogSandboxResume(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -211,13 +209,12 @@ func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -231,15 +228,14 @@ func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
err = h.svc.Destroy(r.Context(), sandboxID, ac.TeamID)
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/channels"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -42,19 +43,19 @@ type sandboxEventRequest struct {
func (h *sandboxEventHandler) Handle(w http.ResponseWriter, r *http.Request) {
var req sandboxEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Event == "" || req.SandboxID == "" || req.HostID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "event, sandbox_id, and host_id are required")
writeErr(w, r, apperr.ValidationFailed.Msg("The event, sandbox_id, and host_id fields are required."))
return
}
hc := auth.MustHostFromContext(r.Context())
callerHostID := id.FormatHostID(hc.HostID)
if callerHostID != req.HostID {
writeError(w, http.StatusForbidden, "forbidden", "host_id does not match authenticated host")
writeErr(w, r, apperr.Forbidden.Msg("The host_id does not match the authenticated host."))
return
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
)
@ -26,7 +27,7 @@ func (h *sessionsHandler) List(w http.ResponseWriter, r *http.Request) {
rows, err := h.sessions.ListForUser(r.Context(), ac.UserID)
if err != nil {
slog.Error("list sessions: db error", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sessions")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
out := make([]sessionRow, 0, len(rows))
@ -51,12 +52,12 @@ func (h *sessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
sid := chi.URLParam(r, "id")
if sid == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "missing session id")
writeErr(w, r, apperr.InvalidRequest.Msg("The session id is required."))
return
}
if err := h.sessions.DeleteForUser(r.Context(), sid, ac.UserID); err != nil {
slog.Error("delete session: db error", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete session")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if sid == ac.SessionID {

View File

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -119,16 +120,16 @@ type createSnapshotRequest struct {
func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSnapshotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.SandboxID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "sandbox_id is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The sandbox_id field is required.").With("field", "sandbox_id"))
return
}
sandboxID, err := id.ParseSandboxID(req.SandboxID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
ac := auth.MustFromContext(r.Context())
@ -138,8 +139,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
// result via the SSE template.snapshot.create event (or by polling).
sb, name, err := h.sandboxSvc.CreateSnapshot(r.Context(), sandboxID, ac.TeamID, req.Name)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
h.audit.LogSnapshotCreateRequested(r.Context(), ac, name)
@ -154,7 +154,7 @@ func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
templates, err := h.svc.List(r.Context(), ac.TeamID, typeFilter)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -175,7 +175,7 @@ func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := validate.SafeName(name); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid snapshot name: %s", err))
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid snapshot name."))
return
}
ctx := r.Context()
@ -183,29 +183,28 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "template not found")
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
return
}
// Platform templates can only be deleted by admins via /v1/admin/templates.
if tmpl.TeamID == id.PlatformTeamID {
writeError(w, http.StatusForbidden, "forbidden", "platform templates cannot be deleted here")
writeErr(w, r, apperr.TemplateProtected.Msg("Platform templates cannot be deleted here."))
return
}
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
writeError(w, http.StatusForbidden, "forbidden", "system base templates cannot be deleted")
writeErr(w, r, apperr.TemplateProtected.New())
return
}
if err := deleteSnapshotEverywhere(ctx, h.db, h.pool, tmpl.TeamID, tmpl.ID); err != nil {
h.audit.LogSnapshotDelete(r.Context(), ac, name, err)
writeError(w, http.StatusConflict, "delete_failed",
"could not remove snapshot files from all hosts: "+err.Error())
writeErr(w, r, apperr.Conflict.WrapMsg(err, "Could not remove snapshot files from all hosts. Try again when all hosts are online."))
return
}
if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil {
h.audit.LogSnapshotDelete(r.Context(), ac, name, err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/id"
)
@ -25,7 +26,7 @@ func newSSEHandler(broker *SSEBroker) *sseHandler {
func (h *sseHandler) Stream(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
writeErr(w, r, apperr.AuthSessionRequired.New())
return
}
h.serveSSE(w, r, id.FormatTeamID(ac.TeamID), false)
@ -36,7 +37,7 @@ func (h *sseHandler) Stream(w http.ResponseWriter, r *http.Request) {
func (h *sseHandler) AdminStream(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "admin session required")
writeErr(w, r, apperr.Unauthorized.Msg("An admin session is required."))
return
}
h.serveSSE(w, r, id.FormatTeamID(ac.TeamID), true)

View File

@ -5,6 +5,7 @@ import (
"net/http"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/service"
)
@ -53,14 +54,14 @@ func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) {
}
tr := service.TimeRange(rangeParam)
if !service.ValidRange(tr) {
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 1h, 6h, 24h, 30d")
writeErr(w, r, apperr.ValidationFailed.Msg("The range parameter must be one of: 5m, 1h, 6h, 24h, 30d.").With("field", "range"))
return
}
current, peaks, series, err := h.svc.GetStats(r.Context(), ac.TeamID, tr)
if err != nil {
slog.Error("stats handler: get stats failed", "team_id", ac.TeamID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve stats")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
@ -84,11 +85,11 @@ func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthConte
teamIDStr := chi.URLParam(r, "id")
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
return pgtype.UUID{}, false
}
if ac.TeamID != teamID {
writeError(w, http.StatusForbidden, "forbidden", "JWT team does not match requested team; use switch-team first")
writeErr(w, r, apperr.Forbidden.Msg("Your active team does not match the requested team. Switch teams first."))
return pgtype.UUID{}, false
}
return teamID, true
@ -101,8 +102,7 @@ func (h *teamHandler) List(w http.ResponseWriter, r *http.Request) {
teams, err := h.svc.ListTeamsForUser(r.Context(), ac.UserID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -125,15 +125,14 @@ func (h *teamHandler) Create(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Name = strings.TrimSpace(req.Name)
team, err := h.svc.CreateTeam(r.Context(), ac.UserID, req.Name)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -163,15 +162,13 @@ func (h *teamHandler) Get(w http.ResponseWriter, r *http.Request) {
team, err := h.svc.GetTeam(r.Context(), teamID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
members, err := h.svc.GetMembers(r.Context(), teamID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -199,7 +196,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Name = strings.TrimSpace(req.Name)
@ -211,8 +208,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -230,8 +226,7 @@ func (h *teamHandler) Delete(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.DeleteTeam(r.Context(), teamID, ac.UserID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -248,8 +243,7 @@ func (h *teamHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members, err := h.svc.GetMembers(r.Context(), teamID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -273,19 +267,18 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "email is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The email field is required.").With("field", "email"))
return
}
member, err := h.svc.AddMember(r.Context(), teamID, ac.UserID, req.Email)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -324,13 +317,12 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
targetUserID, err := id.ParseUserID(targetUserIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
if err := h.svc.RemoveMember(r.Context(), teamID, ac.UserID, targetUserID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -350,7 +342,7 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
targetUserID, err := id.ParseUserID(targetUserIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
@ -358,13 +350,12 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if err := h.svc.UpdateMemberRole(r.Context(), teamID, ac.UserID, targetUserID, req.Role); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -387,8 +378,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.LeaveTeam(r.Context(), teamID, ac.UserID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -404,7 +394,7 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
return
}
@ -412,13 +402,12 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if err := h.svc.SetBYOC(r.Context(), teamID, req.Enabled); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -440,8 +429,7 @@ func (h *teamHandler) AdminListTeams(w http.ResponseWriter, r *http.Request) {
teams, total, err := h.svc.AdminListTeams(r.Context(), perPage, offset)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -502,13 +490,12 @@ func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) {
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
return
}
if err := h.svc.AdminDeleteTeam(r.Context(), teamID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/service"
)
@ -41,7 +42,7 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
var err error
from, err = time.Parse("2006-01-02", s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "from must be YYYY-MM-DD")
writeErr(w, r, apperr.ValidationFailed.Msg("The from parameter must be YYYY-MM-DD.").With("field", "from"))
return
}
} else {
@ -52,7 +53,7 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
var err error
to, err = time.Parse("2006-01-02", s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "to must be YYYY-MM-DD")
writeErr(w, r, apperr.ValidationFailed.Msg("The to parameter must be YYYY-MM-DD.").With("field", "to"))
return
}
} else {
@ -60,18 +61,18 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
}
if from.After(to) {
writeError(w, http.StatusBadRequest, "invalid_request", "from must be before or equal to to")
writeErr(w, r, apperr.ValidationFailed.Msg("The from date must be before or equal to the to date."))
return
}
if to.Sub(from).Hours()/24 > 92 {
writeError(w, http.StatusBadRequest, "invalid_request", "range cannot exceed 92 days")
writeErr(w, r, apperr.ValidationFailed.Msg("The range cannot exceed 92 days."))
return
}
points, err := h.svc.GetUsage(r.Context(), ac.TeamID, from, to)
if err != nil {
slog.Error("usage handler: get usage failed", "team_id", ac.TeamID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve usage")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -2,6 +2,7 @@ package api
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
@ -9,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
@ -36,7 +38,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
prefix := strings.TrimSpace(r.URL.Query().Get("email"))
if len(prefix) < 3 || !strings.Contains(prefix, "@") {
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'")
writeErr(w, r, apperr.ValidationFailed.Msg("The email prefix must be at least 3 characters and contain '@'.").With("field", "email"))
return
}
@ -45,7 +47,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "search failed")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -74,8 +76,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
users, total, err := h.svc.AdminListUsers(r.Context(), perPage, offset)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -122,7 +123,7 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
@ -130,12 +131,12 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
Active bool `json:"active"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if ac.UserID == userID && !req.Active {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot deactivate your own account")
writeErr(w, r, apperr.InvalidRequest.Msg("You cannot deactivate your own account."))
return
}
@ -147,22 +148,25 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
// Look up user email for audit log before changing status.
user, err := h.db.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
writeErr(w, r, apperr.UserNotFound.Wrap(err))
return
}
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
httpStatus, code, msg := serviceErrToHTTP(err)
writeError(w, httpStatus, code, msg)
writeErr(w, r, err)
return
}
if req.Active {
h.audit.LogUserActivate(r.Context(), ac, userID, user.Email)
} else {
// Disabled users must be kicked out of every active session.
// Disabled users must be kicked out of every active session. A failure
// here leaves the account's cached sessions live (Session.Get only
// re-checks user status on a cache miss), so it must be surfaced loudly
// rather than swallowed — an operator may need to force revocation.
if err := h.sessions.RevokeAllForUser(r.Context(), userID); err != nil {
_ = err
slog.Error("deactivate user: revoke sessions failed",
"user_id", id.FormatUserID(userID), "error", err)
}
h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email)
}
@ -177,7 +181,7 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
@ -185,13 +189,13 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
Admin bool `json:"admin"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
user, err := h.db.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
writeErr(w, r, apperr.UserNotFound.Wrap(err))
return
}
@ -205,18 +209,18 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
ID: userID,
IsAdmin: true,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email)
} else {
affected, err := h.db.RevokeUserAdmin(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if affected == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin")
writeErr(w, r, apperr.InvalidRequest.Msg("Cannot remove the last admin."))
return
}
h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email)

View File

@ -3,39 +3,29 @@ package api
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/id"
)
type errorResponse struct {
Error errorDetail `json:"error"`
}
type errorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{
Error: errorDetail{Code: code, Message: message},
})
// writeErr resolves any error to its client-safe envelope and writes it,
// logging the cause chain keyed by request ID. Handlers construct errors
// from the pkg/apperr catalog.
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
apperr.WriteHTTP(w, r, err)
}
// formatUUIDForRPC converts a pgtype.UUID to a hex string for RPC messages.
@ -43,32 +33,6 @@ func formatUUIDForRPC(u pgtype.UUID) string {
return id.UUIDString(u)
}
// agentErrToHTTP maps a Connect RPC error to an HTTP status, error code, and message.
func agentErrToHTTP(err error) (int, string, string) {
switch connect.CodeOf(err) {
case connect.CodeNotFound:
return http.StatusNotFound, "not_found", err.Error()
case connect.CodeInvalidArgument:
return http.StatusBadRequest, "invalid_request", err.Error()
case connect.CodeAlreadyExists:
return http.StatusConflict, "already_exists", err.Error()
case connect.CodeFailedPrecondition:
return http.StatusConflict, "conflict", err.Error()
case connect.CodePermissionDenied:
return http.StatusForbidden, "forbidden", err.Error()
case connect.CodeUnavailable:
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
case connect.CodeUnimplemented:
return http.StatusNotImplemented, "agent_error", err.Error()
case connect.CodeDeadlineExceeded:
return http.StatusGatewayTimeout, "timeout", "command timed out"
case connect.CodeInternal:
return http.StatusInternalServerError, "agent_error", err.Error()
default:
return http.StatusBadGateway, "agent_error", err.Error()
}
}
// requestLogger returns middleware that logs each request.
func requestLogger() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
@ -77,6 +41,7 @@ func requestLogger() func(http.Handler) http.Handler {
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
slog.Info("request",
"request_id", apperr.RequestID(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
@ -90,45 +55,6 @@ func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}
// serviceErrToHTTP maps a service-layer error to an HTTP status, code, and message.
// It inspects the underlying Connect RPC error if present, otherwise returns 500.
func serviceErrToHTTP(err error) (int, string, string) {
msg := err.Error()
// Check for Connect RPC errors wrapped by the service layer.
var connectErr *connect.Error
if errors.As(err, &connectErr) {
return agentErrToHTTP(connectErr)
}
// Map well-known service error patterns.
// Return generic messages for most cases to avoid leaking internal details.
switch {
case strings.Contains(msg, "not found"):
return http.StatusNotFound, "not_found", "resource not found"
case strings.Contains(msg, "not running"):
return http.StatusConflict, "invalid_state", "resource is not running"
case strings.Contains(msg, "not paused"):
return http.StatusConflict, "invalid_state", "resource is not paused"
case strings.Contains(msg, "conflict:"):
return http.StatusConflict, "conflict", strings.TrimPrefix(msg, "conflict: ")
case strings.Contains(msg, "forbidden"):
return http.StatusForbidden, "forbidden", "forbidden"
case strings.Contains(msg, "invalid or expired"):
return http.StatusUnauthorized, "unauthorized", "invalid or expired credentials"
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
strings.Contains(msg, "no host has sufficient resources"):
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
case strings.HasPrefix(msg, "invalid metadata: "):
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
case strings.Contains(msg, "invalid"):
return http.StatusBadRequest, "invalid_request", "invalid request"
default:
slog.Error("unhandled service error", "error", err)
return http.StatusInternalServerError, "internal_error", "an internal error occurred"
}
}
type statusWriter struct {
http.ResponseWriter
status int

View File

@ -3,6 +3,7 @@ package api
import (
"net/http"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -35,12 +36,18 @@ func requireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
writeErr(w, r, apperr.Unauthorized.New())
return
}
user, err := queries.GetUserByID(r.Context(), ac.UserID)
if err != nil || !user.IsAdmin {
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
if err != nil {
// A transient DB failure is not an authorization decision — a
// legitimate admin must not see "Admin access required" (403).
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if !user.IsAdmin {
writeErr(w, r, apperr.Forbidden.Msg("Admin access required."))
return
}
next.ServeHTTP(w, r)

View File

@ -4,6 +4,7 @@ import (
"crypto/subtle"
"net/http"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
)
@ -29,7 +30,7 @@ func requireCSRF() func(http.Handler) http.Handler {
header := r.Header.Get("X-CSRF-Token")
if err != nil || cookie.Value == "" || header == "" ||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 {
writeError(w, http.StatusForbidden, "csrf_failed", "missing or invalid CSRF token")
writeErr(w, r, apperr.AuthCSRF.New())
return
}
next.ServeHTTP(w, r)

View File

@ -3,6 +3,7 @@ package api
import (
"net/http"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/id"
)
@ -14,19 +15,19 @@ func requireHostToken(secret []byte) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := r.Header.Get("X-Host-Token")
if tokenStr == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "X-Host-Token header required")
writeErr(w, r, apperr.Unauthorized.Msg("The X-Host-Token header is required."))
return
}
claims, err := auth.VerifyHostJWT(secret, tokenStr)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired host token")
writeErr(w, r, apperr.Unauthorized.WrapMsg(err, "Host token is invalid or has expired."))
return
}
hostID, err := id.ParseHostID(claims.HostID)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid host ID in token")
writeErr(w, r, apperr.Unauthorized.WrapMsg(err, "Host token carries an invalid host ID."))
return
}

View File

@ -3718,7 +3718,7 @@ components:
$ref: "#/components/schemas/Error"
ServiceUnavailable:
description: The host serving this capsule is unreachable (host_unavailable)
description: Temporarily unavailable — no capacity (capacity_unavailable), host draining (host_draining), or the capsule's host is unreachable (host_unreachable, returned as 502)
content:
application/json:
schema:
@ -4426,21 +4426,13 @@ components:
description: IDs of capsules that would be destroyed on force-delete.
HostHasCapsulesError:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: has_active_sandboxes
message:
type: string
sandbox_ids:
type: array
items:
type: string
description: IDs of active capsules blocking deletion.
allOf:
- $ref: "#/components/schemas/Error"
description: |
Standard error envelope returned by host delete (409) when the host
still has active capsules. `error.code` is `has_active_sandboxes` and
`error.details.sandbox_ids` lists the IDs of the capsules blocking
deletion — pass `?force=true` to destroy them and delete the host.
AddTagRequest:
type: object
@ -4724,14 +4716,37 @@ components:
Error:
type: object
description: |
Standard error envelope returned by every non-2xx JSON response.
`code` is a stable machine-readable identifier (snake_case, e.g.
`sandbox_not_running`, `capacity_unavailable`, `internal_error`).
`message` is human-readable and safe to display. `request_id` matches
the `X-Request-ID` response header and the server logs — quote it when
reporting issues. `retryable` indicates the same request may succeed
if retried. `details` carries optional machine-readable context
(e.g. `{"status": "paused"}` on `sandbox_not_running`, or
`{"field": "path"}` on `validation_failed`).
properties:
error:
type: object
required: [code, message, retryable]
properties:
code:
type: string
example: sandbox_not_running
message:
type: string
example: Sandbox is not running.
request_id:
type: string
example: req_8f3ka92b1c04d7e6
retryable:
type: boolean
example: false
details:
type: object
additionalProperties: true
example: { status: paused }
AuditLogEntry:
type: object

View File

@ -307,15 +307,20 @@ func (c *SandboxEventConsumer) handleFailed(ctx context.Context, sandboxID pgtyp
return
}
action := "create"
// The lifecycle op that failed is recorded as a distinct "error" action
// (attributed to system) rather than a second "create"/"resume" row, so the
// audit log reads "a capsule encountered an error" instead of masquerading
// as a duplicate, system-attributed creation. The user-attributed create row
// was already written at request-accept time by the handler.
phase := "create"
if event.Event == events.CapsuleResume {
action = "resume"
phase = "resume"
}
reason := event.Metadata["reason"]
if reason == "" {
reason = action + "_failed"
reason = phase + "_failed"
}
meta := map[string]any{"reason": reason}
meta := map[string]any{"reason": reason, "phase": phase}
if event.Error != "" {
meta["error"] = event.Error
}
@ -325,7 +330,7 @@ func (c *SandboxEventConsumer) handleFailed(ctx context.Context, sandboxID pgtyp
ActorType: "system",
ResourceType: "sandbox",
ResourceID: id.FormatSandboxID(sandboxID),
Action: action,
Action: "error",
Scope: "team",
Status: "error",
Metadata: meta,

View File

@ -11,6 +11,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
@ -61,6 +62,7 @@ func New(
version string,
) *Server {
r := chi.NewRouter()
r.Use(apperr.Middleware)
r.Use(requestLogger())
// Apply extension middleware before routes so it wraps all OSS routes.

View File

@ -155,6 +155,15 @@ func impliedSandboxStatus(event events.Event) (string, bool) {
if event.Outcome != events.OutcomeSuccess {
return "", false
}
// Only system-initiated completion events (host callbacks, TTL reaper) imply
// a terminal status. User/API-key create/resume/pause events are published at
// request-accept time — while the sandbox is still transient (starting /
// resuming / pausing) — so their hydrated DB row is authoritative and must
// not be overridden. Overriding them masks the transient state on the
// dashboard (a launching capsule jumps straight to "running").
if event.Actor.Type != events.ActorSystem {
return "", false
}
switch event.Event {
case events.CapsulePause:
return "paused", true

View File

@ -74,13 +74,6 @@ func LoadTokenFile(path string) (*TokenFile, error) {
if err != nil {
return nil, err
}
// Support legacy format (raw JWT string) for backwards compatibility.
trimmed := strings.TrimSpace(string(data))
if !strings.HasPrefix(trimmed, "{") {
// Old format: just the JWT, no refresh token.
hostID, _ := hostIDFromJWT(trimmed)
return &TokenFile{HostID: hostID, JWT: trimmed}, nil
}
var tf TokenFile
if err := json.Unmarshal(data, &tf); err != nil {
return nil, fmt.Errorf("parse credentials file: %w", err)

View File

@ -19,7 +19,9 @@ import (
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
"git.omukk.dev/wrenn/wrenn/pkg/network"
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
)
@ -56,11 +58,11 @@ func parseUUIDString(s string) (pgtype.UUID, error) {
func parseSandboxIDs(teamIDStr, templateIDStr string) (teamID, templateID pgtype.UUID, err error) {
teamID, err = parseUUIDString(teamIDStr)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, connect.NewError(connect.CodeInvalidArgument, err)
return pgtype.UUID{}, pgtype.UUID{}, apperr.ToConnect(apperr.InvalidRequest.Wrap(err))
}
templateID, err = parseUUIDString(templateIDStr)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, connect.NewError(connect.CodeInvalidArgument, err)
return pgtype.UUID{}, pgtype.UUID{}, apperr.ToConnect(apperr.InvalidRequest.Wrap(err))
}
return teamID, templateID, nil
}
@ -83,10 +85,7 @@ func (s *Server) CreateSandbox(
int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), 0,
msg.DefaultUser, msg.DefaultEnv)
if err != nil {
if errors.Is(err, sandbox.ErrDraining) {
return nil, connect.NewError(connect.CodeUnavailable, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err))
return nil, mapSandboxError(fmt.Errorf("create sandbox: %w", err))
}
return connect.NewResponse(&pb.CreateSandboxResponse{
@ -183,21 +182,32 @@ func (s *Server) FlattenRootfs(
}), nil
}
// mapSandboxError translates sandbox.Manager errors to Connect error codes
// via sentinel errors (errors.Is). Adding a new precondition sentinel in the
// sandbox package only requires extending this switch — no string sniffing.
// mapSandboxError translates sandbox.Manager errors to Connect errors
// carrying an apperr ErrorInfo detail, so the control plane surfaces the
// precise catalog error instead of guessing from the Connect code. Sentinels
// are matched with errors.Is — no string sniffing. Unmatched errors become
// internal_error: the full cause crosses the (internal) RPC channel for CP
// logs, while the client-safe detail stays generic.
func mapSandboxError(err error) error {
switch {
case errors.Is(err, sandbox.ErrNotFound):
return connect.NewError(connect.CodeNotFound, err)
case errors.Is(err, sandbox.ErrNotRunning), errors.Is(err, sandbox.ErrNotPaused):
return connect.NewError(connect.CodeFailedPrecondition, err)
return apperr.ToConnect(apperr.SandboxNotFound.Wrap(err))
case errors.Is(err, sandbox.ErrNotRunning):
return apperr.ToConnect(apperr.SandboxNotRunning.Wrap(err))
case errors.Is(err, sandbox.ErrNotPaused):
return apperr.ToConnect(apperr.SandboxNotPaused.Wrap(err))
case errors.Is(err, sandbox.ErrDraining):
return connect.NewError(connect.CodeUnavailable, err)
return apperr.ToConnect(apperr.HostDraining.Wrap(err))
case errors.Is(err, sandbox.ErrInvalidRange):
return connect.NewError(connect.CodeInvalidArgument, err)
return apperr.ToConnect(apperr.InvalidRequest.WrapMsg(err, "Invalid metrics range."))
case errors.Is(err, sandbox.ErrTemplateNotFound):
return apperr.ToConnect(apperr.TemplateNotFound.WrapMsg(err, "The template's rootfs image is not available on this host."))
case errors.Is(err, sandbox.ErrEnvdNotReady):
return apperr.ToConnect(apperr.SandboxUnresponsive.WrapMsg(err, "The sandbox VM started but its agent did not become ready."))
case errors.Is(err, network.ErrNoFreeSlots):
return apperr.ToConnect(apperr.CapacityUnavailable.WrapMsg(err, "This host has no free network slots. Try again shortly."))
default:
return connect.NewError(connect.CodeInternal, err)
return apperr.ToConnect(err)
}
}
@ -212,9 +222,9 @@ func (s *Server) GetTemplateSize(
size, err := s.mgr.TemplateRootfsSize(teamID, templateID)
if err != nil {
if os.IsNotExist(err) {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, apperr.ToConnect(apperr.TemplateNotFound.Wrap(err))
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("get template size: %w", err))
return nil, mapSandboxError(fmt.Errorf("get template size: %w", err))
}
return connect.NewResponse(&pb.GetTemplateSizeResponse{
SizeBytes: size,
@ -226,10 +236,7 @@ func (s *Server) PingSandbox(
req *connect.Request[pb.PingSandboxRequest],
) (*connect.Response[pb.PingSandboxResponse], error) {
if err := s.mgr.Ping(req.Msg.SandboxId); err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeFailedPrecondition, err)
return nil, mapSandboxError(err)
}
return connect.NewResponse(&pb.PingSandboxResponse{}), nil
}
@ -265,15 +272,42 @@ func (s *Server) Exec(
}), nil
}
// envdErr propagates an error from the envd client, preserving its Connect
// error code (e.g. AlreadyExists, NotFound) so the control plane maps it to
// the correct HTTP status. Non-Connect errors fall back to CodeInternal.
// envdErr propagates an error from the envd client. envd is a separate binary
// that never attaches an apperr ErrorInfo detail, so its Connect code is the
// only signal — and the control plane's fromConnect flattens any detail-less
// error to internal_error. So client-fault codes are translated to their
// catalog Def here, keeping envd's message (it describes the caller's own
// request, e.g. a bad path inside their sandbox); without this a missing file
// would surface as a misleading 500 instead of a 404. Transport failures
// reaching envd become sandbox_unresponsive, everything else internal_error
// with the cause preserved for CP logs.
func envdErr(action string, err error) error {
code := connect.CodeOf(err)
if code == connect.CodeUnknown {
code = connect.CodeInternal
wrapped := fmt.Errorf("%s: %w", action, err)
switch connect.CodeOf(err) {
case connect.CodeNotFound:
return apperr.ToConnect(apperr.NotFound.WrapMsg(wrapped, connectMessage(err)))
case connect.CodeInvalidArgument:
return apperr.ToConnect(apperr.InvalidRequest.WrapMsg(wrapped, connectMessage(err)))
case connect.CodeAlreadyExists:
return apperr.ToConnect(apperr.Conflict.WrapMsg(wrapped, connectMessage(err)))
case connect.CodePermissionDenied:
return apperr.ToConnect(apperr.Forbidden.Wrap(wrapped))
case connect.CodeUnavailable:
return apperr.ToConnect(apperr.SandboxUnresponsive.Wrap(wrapped))
default:
return apperr.ToConnect(wrapped)
}
return connect.NewError(code, fmt.Errorf("%s: %w", action, err))
}
// connectMessage returns the human-readable message from a Connect error. envd
// phrases its client-fault messages in terms of the caller's own request
// ("path not found"), so they are safe to surface to API clients.
func connectMessage(err error) string {
var ce *connect.Error
if errors.As(err, &ce) {
return ce.Message()
}
return "The request could not be completed."
}
func (s *Server) WriteFile(
@ -284,7 +318,7 @@ func (s *Server) WriteFile(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
if err := client.WriteFile(ctx, msg.Path, msg.Content); err != nil {
@ -302,7 +336,7 @@ func (s *Server) ReadFile(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
content, err := client.ReadFile(ctx, msg.Path)
@ -321,7 +355,7 @@ func (s *Server) ListDir(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
resp, err := client.ListDir(ctx, msg.Path, msg.Depth)
@ -345,7 +379,7 @@ func (s *Server) MakeDir(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
resp, err := client.MakeDir(ctx, msg.Path)
@ -366,7 +400,7 @@ func (s *Server) RemovePath(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
if err := client.Remove(ctx, msg.Path); err != nil {
@ -393,7 +427,7 @@ func (s *Server) ExecStream(
events, err := s.mgr.ExecStream(execCtx, msg.SandboxId, msg.Cmd, msg.Args...)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("exec stream: %w", err))
return mapSandboxError(fmt.Errorf("exec stream: %w", err))
}
for ev := range events {
@ -442,20 +476,20 @@ func (s *Server) WriteFileStream(
// First message must contain metadata.
if !stream.Receive() {
if err := stream.Err(); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
return nil, apperr.ToConnect(err)
}
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("empty stream"))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("The upload stream was empty."))
}
first := stream.Msg()
meta := first.GetMeta()
if meta == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("first message must contain metadata"))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("The first stream message must contain metadata."))
}
client, err := s.mgr.GetClient(meta.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
// Use io.Pipe to stream raw chunks into envd's REST endpoint body.
@ -484,18 +518,18 @@ func (s *Server) WriteFileStream(
errCh <- nil
}()
// Send the raw streaming body to envd.
// Send the raw streaming body to envd. No username is sent, so envd resolves
// the sandbox default user — the same user process execution runs as.
base := client.BaseURL()
u := fmt.Sprintf("%s/files?%s", base, url.Values{
"path": {meta.Path},
"username": {"root"},
"path": {meta.Path},
}.Encode())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, u, pr)
if err != nil {
pw.CloseWithError(err)
<-errCh
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
return nil, apperr.ToConnect(fmt.Errorf("create request: %w", err))
}
httpReq.Header.Set("Content-Type", "application/octet-stream")
@ -503,18 +537,18 @@ func (s *Server) WriteFileStream(
if err != nil {
pw.CloseWithError(err)
<-errCh
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("write file stream: %w", err))
return nil, envdErr("write file stream", err)
}
defer resp.Body.Close()
// Wait for the writer goroutine.
if writerErr := <-errCh; writerErr != nil {
return nil, connect.NewError(connect.CodeInternal, writerErr)
return nil, apperr.ToConnect(writerErr)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body)))
return nil, envdErr("write file stream", fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body)))
}
slog.Debug("streaming file write complete", "sandbox_id", meta.SandboxId, "path", meta.Path)
@ -530,29 +564,30 @@ func (s *Server) ReadFileStream(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return connect.NewError(connect.CodeNotFound, err)
return mapSandboxError(err)
}
// No username is sent, so envd resolves the sandbox default user, matching
// process execution.
base := client.BaseURL()
u := fmt.Sprintf("%s/files?%s", base, url.Values{
"path": {msg.Path},
"username": {"root"},
"path": {msg.Path},
}.Encode())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
return apperr.ToConnect(fmt.Errorf("create request: %w", err))
}
resp, err := client.StreamingHTTPClient().Do(httpReq)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("read file stream: %w", err))
return envdErr("read file stream", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return connect.NewError(connect.CodeInternal, fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body)))
return envdErr("read file stream", fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body)))
}
// Stream file content in 64KB chunks.
@ -580,7 +615,7 @@ func (s *Server) ReadFileStream(
break
}
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("read body: %w", err))
return envdErr("read file stream", fmt.Errorf("read body: %w", err))
}
}
@ -687,7 +722,7 @@ func (s *Server) PtyAttach(
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd, msg.User, msg.Reconnect)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
return mapSandboxError(fmt.Errorf("pty attach: %w", err))
}
for ev := range events {
@ -723,7 +758,7 @@ func (s *Server) PtySendInput(
msg := req.Msg
if err := s.mgr.PtySendInput(ctx, msg.SandboxId, msg.Tag, msg.Data); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty send input: %w", err))
return nil, mapSandboxError(fmt.Errorf("pty send input: %w", err))
}
return connect.NewResponse(&pb.PtySendInputResponse{}), nil
@ -736,7 +771,7 @@ func (s *Server) PtyResize(
msg := req.Msg
if err := s.mgr.PtyResize(ctx, msg.SandboxId, msg.Tag, msg.Cols, msg.Rows); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty resize: %w", err))
return nil, mapSandboxError(fmt.Errorf("pty resize: %w", err))
}
return connect.NewResponse(&pb.PtyResizeResponse{}), nil
@ -749,7 +784,7 @@ func (s *Server) PtyKill(
msg := req.Msg
if err := s.mgr.PtyKill(ctx, msg.SandboxId, msg.Tag); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty kill: %w", err))
return nil, mapSandboxError(fmt.Errorf("pty kill: %w", err))
}
return connect.NewResponse(&pb.PtyKillResponse{}), nil
@ -805,10 +840,7 @@ func (s *Server) StartBackground(
pid, err := s.mgr.StartBackground(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Envs, msg.Cwd)
if err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("start background: %w", err))
return nil, mapSandboxError(fmt.Errorf("start background: %w", err))
}
return connect.NewResponse(&pb.StartBackgroundResponse{
@ -823,10 +855,7 @@ func (s *Server) ListProcesses(
) (*connect.Response[pb.ListProcessesResponse], error) {
procs, err := s.mgr.ListProcesses(ctx, req.Msg.SandboxId)
if err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("list processes: %w", err))
return nil, mapSandboxError(fmt.Errorf("list processes: %w", err))
}
entries := make([]*pb.ProcessEntry, 0, len(procs))
@ -859,7 +888,7 @@ func (s *Server) KillProcess(
case *pb.KillProcessRequest_Tag:
tag = sel.Tag
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("pid or tag is required"))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("A pid or tag is required."))
}
// Map signal string to envd enum.
@ -870,14 +899,11 @@ func (s *Server) KillProcess(
case "SIGTERM":
signal = envdpb.Signal_SIGNAL_SIGTERM
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unsupported signal: %s (use SIGKILL or SIGTERM)", msg.Signal))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msgf("Unsupported signal: %s (use SIGKILL or SIGTERM).", msg.Signal))
}
if err := s.mgr.KillProcess(ctx, msg.SandboxId, pid, tag, signal); err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("kill process: %w", err))
return nil, mapSandboxError(fmt.Errorf("kill process: %w", err))
}
return connect.NewResponse(&pb.KillProcessResponse{}), nil
@ -898,15 +924,12 @@ func (s *Server) ConnectProcess(
case *pb.ConnectProcessRequest_Tag:
tag = sel.Tag
default:
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("pid or tag is required"))
return apperr.ToConnect(apperr.InvalidRequest.Msg("A pid or tag is required."))
}
events, err := s.mgr.ConnectProcess(ctx, msg.SandboxId, pid, tag)
if err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return connect.NewError(connect.CodeNotFound, err)
}
return connect.NewError(connect.CodeInternal, fmt.Errorf("connect process: %w", err))
return mapSandboxError(fmt.Errorf("connect process: %w", err))
}
for ev := range events {

View File

@ -276,7 +276,7 @@ func execUser(
) (BuildLogEntry, bool) {
username := st.Key
// Create user if not exists, with home directory and bash shell.
// Grant passwordless sudo access (E2B convention).
// Grant passwordless sudo access (matches the wrenn-user default in base images).
// Uses printf %s to avoid shell injection in the sudoers line.
script := fmt.Sprintf(
"id %s >/dev/null 2>&1 || (adduser --disabled-password --gecos '' --shell /bin/bash %s && printf '%%s ALL=(ALL) NOPASSWD:ALL\\n' %s >> /etc/sudoers)",

View File

@ -1 +0,0 @@
package snapshot

View File

@ -1 +0,0 @@
package snapshot

150
pkg/apperr/apperr.go Normal file
View File

@ -0,0 +1,150 @@
// Package apperr is Wrenn's application error domain — the single source of
// truth for error codes, HTTP statuses, client-safe messages, and retryability.
//
// Every error that can reach a client is declared once as a Def in
// catalog.go. Code that fails wraps its cause with a Def
// (Def.Wrap, Def.Msg, ...), and the HTTP layer resolves any error to a
// wire-safe *Error with From. The cause chain is preserved for logs but never
// serialized to clients.
package apperr
import (
"errors"
"fmt"
"maps"
"net/http"
)
// Error is a resolved application error. Code, Status, Message, Retryable and
// Details are client-safe; the wrapped cause is for logs only.
type Error struct {
Code string
Status int
Message string
Retryable bool
Details map[string]any
cause error
}
func (e *Error) Error() string {
if e.cause != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.cause)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *Error) Unwrap() error { return e.cause }
// Is makes errors.Is(err, SomeDef.New()) and cross-instance comparisons match
// on Code, so two Errors with the same code compare equal regardless of
// message, details, or cause.
func (e *Error) Is(target error) bool {
var t *Error
if errors.As(target, &t) {
return t.Code == e.Code
}
return false
}
// With returns a copy of the error with an extra client-visible detail.
// Details must be safe to show to API consumers.
func (e *Error) With(key string, value any) *Error {
c := *e
c.Details = maps.Clone(c.Details)
if c.Details == nil {
c.Details = map[string]any{}
}
c.Details[key] = value
return &c
}
// Def is a catalog entry: an error code with its fixed HTTP status,
// default client-safe message, and retryability. Defs are declared in
// catalog.go and instantiated at failure sites.
type Def struct {
Code string
Status int
Retryable bool
Message string
}
// New returns an Error with the Def's default message.
func (d Def) New() *Error {
return &Error{Code: d.Code, Status: d.Status, Message: d.Message, Retryable: d.Retryable}
}
// Msg returns an Error with a custom client-safe message.
func (d Def) Msg(message string) *Error {
e := d.New()
e.Message = message
return e
}
// Msgf returns an Error with a formatted client-safe message.
// The arguments become part of the client response — never pass raw
// internal error values; use Wrap for those.
func (d Def) Msgf(format string, args ...any) *Error {
return d.Msg(fmt.Sprintf(format, args...))
}
// Wrap returns an Error carrying cause for logs, with the Def's default
// client-safe message. The cause is never serialized to clients.
func (d Def) Wrap(cause error) *Error {
e := d.New()
e.cause = cause
return e
}
// WrapMsg is Wrap with a custom client-safe message.
func (d Def) WrapMsg(cause error, message string) *Error {
e := d.Wrap(cause)
e.Message = message
return e
}
// Is reports whether err resolves to this Def's code.
func (d Def) Is(err error) bool {
var e *Error
return errors.As(err, &e) && e.Code == d.Code
}
// From resolves any error to a client-safe *Error.
//
// - *Error anywhere in the chain: returned as-is.
// - context deadline/cancellation: Timeout — matched on the cause chain
// (errors.Is) before the Connect branch, so a transport error that wraps a
// live context deadline still surfaces as a retryable timeout rather than
// being flattened to internal_error.
// - Connect RPC errors: resolved via the attached ErrorInfo detail when the
// far side provided one, otherwise Internal (see fromConnect).
// - anything else: Internal — the cause is preserved for logging, the
// client sees only the generic message.
func From(err error) *Error {
if err == nil {
return nil
}
var e *Error
if errors.As(err, &e) {
return e
}
if isContextTimeout(err) {
return Timeout.Wrap(err)
}
if ce := asConnectError(err); ce != nil {
return fromConnect(ce)
}
return Internal.Wrap(err)
}
// Cause returns the wrapped internal cause, or nil.
func (e *Error) Cause() error { return e.cause }
// HTTPStatus returns the error's HTTP status, defaulting to 500 for
// zero values so a misconstructed Error can never turn into a 200.
func (e *Error) HTTPStatus() int {
if e.Status == 0 {
return http.StatusInternalServerError
}
return e.Status
}

117
pkg/apperr/apperr_test.go Normal file
View File

@ -0,0 +1,117 @@
package apperr
import (
"context"
"errors"
"fmt"
"net/http"
"testing"
)
func TestDefConstructors(t *testing.T) {
d := Def{Code: "test_failed", Status: http.StatusTeapot, Retryable: true, Message: "default message"}
tests := []struct {
name string
err *Error
wantMessage string
wantCause bool
}{
{"New", d.New(), "default message", false},
{"Msg", d.Msg("custom"), "custom", false},
{"Msgf", d.Msgf("custom %d", 42), "custom 42", false},
{"Wrap", d.Wrap(errors.New("boom")), "default message", true},
{"WrapMsg", d.WrapMsg(errors.New("boom"), "custom"), "custom", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.err.Code != "test_failed" || tt.err.Status != http.StatusTeapot || !tt.err.Retryable {
t.Errorf("def fields not carried: %+v", tt.err)
}
if tt.err.Message != tt.wantMessage {
t.Errorf("Message = %q, want %q", tt.err.Message, tt.wantMessage)
}
if (tt.err.Cause() != nil) != tt.wantCause {
t.Errorf("Cause() = %v, wantCause %v", tt.err.Cause(), tt.wantCause)
}
})
}
}
func TestErrorsIsMatchesOnCode(t *testing.T) {
d := Def{Code: "thing_missing", Status: 404, Message: "not found"}
other := Def{Code: "other_code", Status: 404, Message: "not found"}
wrapped := fmt.Errorf("outer: %w", d.Msg("different message"))
if !errors.Is(wrapped, d.New()) {
t.Error("errors.Is should match same code through wrapping")
}
if errors.Is(wrapped, other.New()) {
t.Error("errors.Is should not match different code")
}
if !d.Is(wrapped) {
t.Error("Def.Is should match same code through wrapping")
}
if other.Is(wrapped) {
t.Error("Def.Is should not match different code")
}
}
func TestWithDoesNotMutateOriginal(t *testing.T) {
d := Def{Code: "x", Status: 400, Message: "m"}
base := d.New().With("a", 1)
derived := base.With("b", 2)
if len(base.Details) != 1 {
t.Errorf("base details mutated: %v", base.Details)
}
if derived.Details["a"] != 1 || derived.Details["b"] != 2 {
t.Errorf("derived details wrong: %v", derived.Details)
}
}
func TestFrom(t *testing.T) {
d := Def{Code: "known", Status: 404, Message: "known thing missing"}
t.Run("nil", func(t *testing.T) {
if From(nil) != nil {
t.Error("From(nil) should be nil")
}
})
t.Run("passthrough", func(t *testing.T) {
e := d.Wrap(errors.New("cause"))
got := From(fmt.Errorf("outer: %w", e))
if got.Code != "known" {
t.Errorf("Code = %q, want known", got.Code)
}
})
t.Run("context deadline", func(t *testing.T) {
got := From(fmt.Errorf("op: %w", context.DeadlineExceeded))
if got.Code != Timeout.Code {
t.Errorf("Code = %q, want %q", got.Code, Timeout.Code)
}
})
t.Run("unknown becomes internal", func(t *testing.T) {
cause := errors.New("db exploded at /var/lib/secret")
got := From(cause)
if got.Code != Internal.Code {
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
}
if got.Message != Internal.Message {
t.Errorf("internal error must not leak cause; Message = %q", got.Message)
}
if !errors.Is(got, cause) {
t.Error("cause must be preserved in chain for logging")
}
})
}
func TestHTTPStatusDefaultsTo500(t *testing.T) {
e := &Error{Code: "misconstructed"}
if e.HTTPStatus() != http.StatusInternalServerError {
t.Errorf("HTTPStatus = %d, want 500", e.HTTPStatus())
}
}

229
pkg/apperr/catalog.go Normal file
View File

@ -0,0 +1,229 @@
package apperr
import "net/http"
// registry maps code → Def so errors received over RPC (see connect.go) and
// codes from extensions resolve back to their catalog entry.
var registry = map[string]Def{}
func register(d Def) Def {
registry[d.Code] = d
return d
}
// Generic codes. Prefer a domain-specific Def when the client can act on the
// distinction; use these with Msg for one-off cases.
var (
Internal = register(Def{
Code: "internal_error",
Status: http.StatusInternalServerError,
Message: "Something went wrong on our end. Try again; if it keeps failing, contact support with the request ID.",
})
InvalidRequest = register(Def{
Code: "invalid_request",
Status: http.StatusBadRequest,
Message: "The request is invalid.",
})
ValidationFailed = register(Def{
Code: "validation_failed",
Status: http.StatusBadRequest,
Message: "One or more fields are invalid.",
})
Unauthorized = register(Def{
Code: "unauthorized",
Status: http.StatusUnauthorized,
Message: "Authentication required.",
})
Forbidden = register(Def{
Code: "forbidden",
Status: http.StatusForbidden,
Message: "You don't have permission to do that.",
})
NotFound = register(Def{
Code: "not_found",
Status: http.StatusNotFound,
Message: "The requested resource was not found.",
})
Conflict = register(Def{
Code: "conflict",
Status: http.StatusConflict,
Message: "The request conflicts with the current state of the resource.",
})
PayloadTooLarge = register(Def{
Code: "payload_too_large",
Status: http.StatusRequestEntityTooLarge,
Message: "The request payload is too large.",
})
RateLimited = register(Def{
Code: "rate_limited",
Status: http.StatusTooManyRequests,
Retryable: true,
Message: "Too many requests. Slow down and try again.",
})
Timeout = register(Def{
Code: "timeout",
Status: http.StatusGatewayTimeout,
Retryable: true,
Message: "The operation timed out. Try again.",
})
Unavailable = register(Def{
Code: "service_unavailable",
Status: http.StatusServiceUnavailable,
Retryable: true,
Message: "The service is temporarily unavailable. Try again shortly.",
})
NotImplemented = register(Def{
Code: "not_implemented",
Status: http.StatusNotImplemented,
Message: "This operation is not supported.",
})
)
// Auth and account codes.
var (
AuthInvalidCredentials = register(Def{
Code: "auth_invalid_credentials",
Status: http.StatusUnauthorized,
Message: "Invalid email or password.",
})
AuthSessionRequired = register(Def{
Code: "auth_session_required",
Status: http.StatusUnauthorized,
Message: "A valid session or API key is required.",
})
AuthInvalidAPIKey = register(Def{
Code: "auth_invalid_api_key",
Status: http.StatusUnauthorized,
Message: "Invalid API key.",
})
AuthCSRF = register(Def{
Code: "auth_csrf_failed",
Status: http.StatusForbidden,
Message: "CSRF token missing or invalid. Refresh the page and try again.",
})
AuthAccountNotActivated = register(Def{
Code: "auth_account_not_activated",
Status: http.StatusForbidden,
Message: "Check your email and activate your account before signing in.",
})
AuthAccountDisabled = register(Def{
Code: "auth_account_disabled",
Status: http.StatusForbidden,
Message: "Your account has been deactivated — contact your administrator to regain access.",
})
AuthEmailTaken = register(Def{
Code: "auth_email_taken",
Status: http.StatusConflict,
Message: "An account with this email already exists.",
})
AuthSignupCooldown = register(Def{
Code: "auth_signup_cooldown",
Status: http.StatusConflict,
Message: "A signup for this email is already pending. Check your inbox or try again later.",
})
AuthTokenInvalid = register(Def{
Code: "auth_token_invalid",
Status: http.StatusBadRequest,
Message: "This link is invalid or has expired.",
})
AuthAlreadyActivated = register(Def{
Code: "auth_already_activated",
Status: http.StatusConflict,
Message: "This account has already been activated.",
})
)
// Sandbox lifecycle and infrastructure codes.
var (
SandboxNotFound = register(Def{
Code: "sandbox_not_found",
Status: http.StatusNotFound,
Message: "Sandbox not found.",
})
SandboxNotRunning = register(Def{
Code: "sandbox_not_running",
Status: http.StatusConflict,
Message: "Sandbox is not running.",
})
SandboxNotPaused = register(Def{
Code: "sandbox_not_paused",
Status: http.StatusConflict,
Message: "Sandbox is not paused.",
})
SandboxUnresponsive = register(Def{
Code: "sandbox_unresponsive",
Status: http.StatusBadGateway,
Retryable: true,
Message: "The sandbox is not responding. Try again shortly.",
})
HostUnreachable = register(Def{
Code: "host_unreachable",
Status: http.StatusBadGateway,
Retryable: true,
Message: "Could not reach the sandbox's host. Try again shortly.",
})
HostDraining = register(Def{
Code: "host_draining",
Status: http.StatusServiceUnavailable,
Retryable: true,
Message: "The host is shutting down and not accepting new work. Try again shortly.",
})
CapacityUnavailable = register(Def{
Code: "capacity_unavailable",
Status: http.StatusServiceUnavailable,
Retryable: true,
Message: "No hosts currently have capacity for this request. Try again shortly.",
})
TemplateNotFound = register(Def{
Code: "template_not_found",
Status: http.StatusNotFound,
Message: "Template not found.",
})
TemplateProtected = register(Def{
Code: "template_protected",
Status: http.StatusForbidden,
Message: "System templates cannot be modified or deleted.",
})
SnapshotNotFound = register(Def{
Code: "snapshot_not_found",
Status: http.StatusNotFound,
Message: "Snapshot not found.",
})
BuildNotFound = register(Def{
Code: "build_not_found",
Status: http.StatusNotFound,
Message: "Build not found.",
})
HostNotFound = register(Def{
Code: "host_not_found",
Status: http.StatusNotFound,
Message: "Host not found.",
})
HostHasActiveSandboxes = register(Def{
Code: "has_active_sandboxes",
Status: http.StatusConflict,
Message: "This host has active sandboxes. Pass ?force=true to destroy them and delete the host.",
})
TeamNotFound = register(Def{
Code: "team_not_found",
Status: http.StatusNotFound,
Message: "Team not found.",
})
UserNotFound = register(Def{
Code: "user_not_found",
Status: http.StatusNotFound,
Message: "User not found.",
})
)
// Lookup returns the catalog Def for a code, or false if unregistered.
// Extensions can add their own codes with Register.
func Lookup(code string) (Def, bool) {
d, ok := registry[code]
return d, ok
}
// Register adds a Def to the catalog registry so errors carrying its code
// resolve to it when received over RPC. Intended for extensions; OSS codes
// are registered at package init. Registering an existing code overwrites it.
func Register(d Def) Def { return register(d) }

119
pkg/apperr/connect.go Normal file
View File

@ -0,0 +1,119 @@
package apperr
import (
"errors"
"fmt"
"net/http"
"connectrpc.com/connect"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
)
// ToConnect converts any error to a *connect.Error carrying an ErrorInfo
// detail. The RPC error message keeps the full cause chain (the CP↔agent
// channel is internal, and the receiver logs it); the ErrorInfo detail holds
// only the client-safe fields, which the receiving side re-surfaces via From.
func ToConnect(err error) *connect.Error {
e := From(err)
if e == nil {
// Defensive: a nil error reaching here is a caller bug. Return a
// generic internal error rather than panicking on the nil deref.
e = Internal.New()
}
ce := connect.NewError(connectCodeFor(e.HTTPStatus()), errors.New(e.Error()))
if detail, derr := connect.NewErrorDetail(errorInfo(e)); derr == nil {
ce.AddDetail(detail)
}
return ce
}
func errorInfo(e *Error) *pb.ErrorInfo {
info := &pb.ErrorInfo{
Code: e.Code,
Message: e.Message,
Retryable: e.Retryable,
HttpStatus: int32(e.HTTPStatus()),
}
if len(e.Details) > 0 {
info.Details = make(map[string]string, len(e.Details))
for k, v := range e.Details {
info.Details[k] = fmt.Sprint(v)
}
}
return info
}
func asConnectError(err error) *connect.Error {
var ce *connect.Error
if errors.As(err, &ce) {
return ce
}
return nil
}
// fromConnect resolves a Connect RPC error to an *Error. Every wrenn service
// attaches an ErrorInfo detail via ToConnect, so an error carrying one is
// reconstructed losslessly. An error WITHOUT a detail did not originate from a
// wrenn handler — it is a transport-level failure synthesized by the Connect
// framework itself (a dropped connection, a client-side deadline) — so it
// resolves to internal_error, with the raw RPC error hidden from the client
// but kept in the chain for logs.
func fromConnect(ce *connect.Error) *Error {
for _, d := range ce.Details() {
msg, err := d.Value()
if err != nil {
continue
}
if info, ok := msg.(*pb.ErrorInfo); ok {
e := &Error{
Code: info.Code,
Status: int(info.HttpStatus),
Message: info.Message,
Retryable: info.Retryable,
cause: ce,
}
if e.Status == 0 {
if d, ok := Lookup(info.Code); ok {
e.Status = d.Status
} else {
e.Status = http.StatusInternalServerError
}
}
if len(info.Details) > 0 {
e.Details = make(map[string]any, len(info.Details))
for k, v := range info.Details {
e.Details[k] = v
}
}
return e
}
}
return Internal.Wrap(ce)
}
// connectCodeFor maps an HTTP status to the Connect code used on the wire.
func connectCodeFor(status int) connect.Code {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
return connect.CodeInvalidArgument
case http.StatusUnauthorized:
return connect.CodeUnauthenticated
case http.StatusForbidden:
return connect.CodePermissionDenied
case http.StatusNotFound:
return connect.CodeNotFound
case http.StatusConflict:
return connect.CodeFailedPrecondition
case http.StatusTooManyRequests:
return connect.CodeResourceExhausted
case http.StatusNotImplemented:
return connect.CodeUnimplemented
case http.StatusBadGateway, http.StatusServiceUnavailable:
return connect.CodeUnavailable
case http.StatusGatewayTimeout:
return connect.CodeDeadlineExceeded
default:
return connect.CodeInternal
}
}

166
pkg/apperr/connect_test.go Normal file
View File

@ -0,0 +1,166 @@
package apperr
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"connectrpc.com/connect"
)
// WriteHTTP(nil) must not panic on the From(nil)==nil deref; it should emit a
// generic 500 envelope rather than a bare 200 or a crash.
func TestWriteHTTPNil(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
WriteHTTP(rec, req, nil)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
var body wireEnvelope
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Error.Code != Internal.Code {
t.Errorf("code = %q, want %q", body.Error.Code, Internal.Code)
}
}
func TestConnectRoundTrip(t *testing.T) {
orig := SandboxNotRunning.
Wrap(errors.New("boxes map miss")).
With("status", "paused")
ce := ToConnect(orig)
if ce.Code() != connect.CodeFailedPrecondition {
t.Errorf("connect code = %v, want FailedPrecondition", ce.Code())
}
got := From(fmt.Errorf("rpc CreateSandbox: %w", ce))
if got.Code != orig.Code {
t.Errorf("Code = %q, want %q", got.Code, orig.Code)
}
if got.Status != orig.Status {
t.Errorf("Status = %d, want %d", got.Status, orig.Status)
}
if got.Message != orig.Message {
t.Errorf("Message = %q, want %q", got.Message, orig.Message)
}
if got.Retryable != orig.Retryable {
t.Errorf("Retryable = %v, want %v", got.Retryable, orig.Retryable)
}
if got.Details["status"] != "paused" {
t.Errorf("Details = %v, want status=paused", got.Details)
}
// The internal cause must not surface in client-safe fields.
if got.Message == orig.Error() {
t.Error("cause chain leaked into Message")
}
}
// Bad-gateway (502) catalog defs must map to a retryable Connect code, not the
// generic Internal, so observability and consumers keying off the raw wire code
// see a transient infrastructure failure rather than an internal-invariant bug.
func TestToConnectBadGateway(t *testing.T) {
for _, def := range []Def{SandboxUnresponsive, HostUnreachable} {
t.Run(def.Code, func(t *testing.T) {
ce := ToConnect(def.Wrap(errors.New("envd dial: connection refused")))
if ce.Code() != connect.CodeUnavailable {
t.Errorf("connect code = %v, want Unavailable", ce.Code())
}
// The ErrorInfo detail keeps the round trip lossless.
got := From(fmt.Errorf("rpc: %w", ce))
if got.Code != def.Code {
t.Errorf("Code = %q, want %q", got.Code, def.Code)
}
if got.Status != http.StatusBadGateway {
t.Errorf("Status = %d, want 502", got.Status)
}
if !got.Retryable {
t.Error("Retryable = false, want true")
}
})
}
}
// Without an ErrorInfo detail, a Connect error did not come from a wrenn
// handler — it is a transport-level failure synthesized by the framework — so
// it resolves to internal_error regardless of its Connect code. The raw cause
// is hidden from the client but stays in the chain for logs. (The old
// guess-from-code mapping is gone.)
func TestFromConnectWithoutDetail(t *testing.T) {
codes := []connect.Code{
connect.CodeUnavailable,
connect.CodeNotFound,
connect.CodeInvalidArgument,
connect.CodeFailedPrecondition,
connect.CodePermissionDenied,
connect.CodeUnauthenticated,
connect.CodeResourceExhausted,
connect.CodeUnimplemented,
connect.CodeDeadlineExceeded,
connect.CodeInternal,
connect.CodeUnknown,
}
for _, code := range codes {
t.Run(code.String(), func(t *testing.T) {
ce := connect.NewError(code, errors.New("open /var/lib/wrenn/secret: boom"))
got := From(ce)
if got.Code != Internal.Code {
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
}
if got.Message != Internal.Message {
t.Errorf("raw cause leaked to client message: %q", got.Message)
}
if !errors.Is(got, error(ce)) {
t.Error("connect error must remain in chain for logging")
}
})
}
}
// A Connect error that genuinely wraps a context deadline still resolves to
// Timeout — matched on the cause chain, not guessed from the Connect code —
// so a real client-side timeout stays a retryable 504.
func TestFromContextDeadlineViaConnect(t *testing.T) {
ce := connect.NewError(connect.CodeDeadlineExceeded, context.DeadlineExceeded)
got := From(ce)
if got.Code != Timeout.Code {
t.Errorf("Code = %q, want %q", got.Code, Timeout.Code)
}
if !got.Retryable {
t.Error("Retryable = false, want true")
}
}
// ToConnect(nil) must not panic on the From(nil)==nil deref; a nil error
// reaching here is a caller bug that should degrade to a generic internal
// error, not crash the RPC handler.
func TestToConnectNil(t *testing.T) {
ce := ToConnect(nil)
if ce.Code() != connect.CodeInternal {
t.Errorf("code = %v, want Internal", ce.Code())
}
if got := From(ce); got.Code != Internal.Code {
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
}
}
func TestToConnectFromPlainError(t *testing.T) {
ce := ToConnect(errors.New("dmsetup create failed: device busy"))
if ce.Code() != connect.CodeInternal {
t.Errorf("code = %v, want Internal", ce.Code())
}
got := From(ce)
if got.Code != Internal.Code || got.Status != http.StatusInternalServerError {
t.Errorf("got %q/%d, want internal_error/500", got.Code, got.Status)
}
if got.Message != Internal.Message {
t.Errorf("internal cause leaked to client message: %q", got.Message)
}
}

14
pkg/apperr/context.go Normal file
View File

@ -0,0 +1,14 @@
package apperr
import (
"context"
"errors"
)
// isContextTimeout reports whether err is a context deadline or cancellation.
// Both resolve to Timeout: a cancelled request context reaches error mapping
// only when the work was cut short, which the client should treat the same
// as a timeout.
func isContextTimeout(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}

98
pkg/apperr/http.go Normal file
View File

@ -0,0 +1,98 @@
package apperr
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
)
type ctxKey struct{}
// RequestIDHeader is the response header carrying the request ID.
const RequestIDHeader = "X-Request-ID"
// Middleware assigns each request an ID (req_ + 16 hex), stores it in the
// context, and echoes it in the X-Request-ID response header. Error responses
// include it so users can quote an ID that log lines are keyed by.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := newRequestID()
w.Header().Set(RequestIDHeader, id)
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
})
}
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "req_" + hex.EncodeToString(b)
}
// WithRequestID returns a context carrying the request ID.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// RequestID returns the request ID from the context, or "" if none.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}
type wireError struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
Retryable bool `json:"retryable"`
Details map[string]any `json:"details,omitempty"`
}
type wireEnvelope struct {
Error wireError `json:"error"`
}
// WriteHTTP resolves err via From and writes the error envelope. The full
// cause chain is logged keyed by request ID — 5xx at ERROR, wrapped 4xx at
// WARN — and never serialized to the client.
func WriteHTTP(w http.ResponseWriter, r *http.Request, err error) {
e := From(err)
if e == nil {
// Defensive: a nil error reaching here is a caller bug. Never emit a
// bare 200 — surface a generic 500 so the client still gets an envelope.
e = Internal.New()
}
reqID := RequestID(r.Context())
if e.HTTPStatus() >= 500 {
slog.Error("request failed",
"request_id", reqID,
"code", e.Code,
"status", e.HTTPStatus(),
"method", r.Method,
"path", r.URL.Path,
"error", e.Error(),
)
} else if e.Cause() != nil {
slog.Warn("request rejected",
"request_id", reqID,
"code", e.Code,
"status", e.HTTPStatus(),
"method", r.Method,
"path", r.URL.Path,
"error", e.Error(),
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(e.HTTPStatus())
_ = json.NewEncoder(w).Encode(wireEnvelope{Error: wireError{
Code: e.Code,
Message: e.Message,
RequestID: reqID,
Retryable: e.Retryable,
Details: e.Details,
}})
}

View File

@ -308,15 +308,18 @@ func (l *AuditLogger) LogSandboxDestroyWithReason(ctx context.Context, ac auth.A
})
}
// LogSandboxCreateSystem records a system-derived create outcome (e.g. the
// LogSandboxCreateSystem records a system-derived create failure (e.g. the
// reconciler inferring a failed first-boot after the grace period expired).
// reason is added to metadata; err controls outcome.
// It writes an "error" action — not a "create" one — so the entry reads as a
// capsule error rather than a system-attributed creation; the user-attributed
// create row is written separately by the request handler. reason and the
// "create" phase are added to metadata; err controls outcome.
func (l *AuditLogger) LogSandboxCreateSystem(ctx context.Context, teamID, sandboxID pgtype.UUID, reason string, err error) {
meta := map[string]any{"reason": reason}
meta := map[string]any{"reason": reason, "phase": "create"}
l.Log(ctx, Entry{
TeamID: teamID, ActorType: "system",
ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID),
Action: "create", Scope: "team", Status: auditStatusFor(err, "info"),
Action: "error", Scope: "team", Status: auditStatusFor(err, "info"),
Metadata: mergeMeta(meta, err),
})
l.publish(ctx, events.Event{
@ -331,14 +334,16 @@ func (l *AuditLogger) LogSandboxCreateSystem(ctx context.Context, teamID, sandbo
})
}
// LogSandboxResumeSystem records a system-derived resume outcome (typically
// reconciler-inferred error after the grace period).
// LogSandboxResumeSystem records a system-derived resume failure (typically
// reconciler-inferred error after the grace period). Like LogSandboxCreateSystem
// it writes an "error" action so the entry reads as a capsule error rather than
// a system-attributed resume; reason and the "resume" phase go to metadata.
func (l *AuditLogger) LogSandboxResumeSystem(ctx context.Context, teamID, sandboxID pgtype.UUID, reason string, err error) {
meta := map[string]any{"reason": reason}
meta := map[string]any{"reason": reason, "phase": "resume"}
l.Log(ctx, Entry{
TeamID: teamID, ActorType: "system",
ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID),
Action: "resume", Scope: "team", Status: auditStatusFor(err, "info"),
Action: "error", Scope: "team", Status: auditStatusFor(err, "info"),
Metadata: mergeMeta(meta, err),
})
l.publish(ctx, events.Event{

View File

@ -7,19 +7,19 @@ package middleware
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
)
// Cookie + header names. Exported so extensions and frontends can reference
@ -30,21 +30,6 @@ const (
CSRFHeaderName = "X-CSRF-Token"
)
type errorBody struct {
Error errorDetail `json:"error"`
}
type errorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorBody{Error: errorDetail{Code: code, Message: message}})
}
// IsSecure reports whether the inbound request should produce Secure cookies.
// Honors X-Forwarded-Proto for deployments behind TLS-terminating proxies.
func IsSecure(r *http.Request) bool {
@ -174,7 +159,7 @@ func RequireSession(svc *session.Service, queries *db.Queries) func(http.Handler
sess, err := ResolveSession(r.Context(), queries, svc, r)
if err != nil {
ClearCookies(w, IsSecure(r))
writeError(w, http.StatusUnauthorized, "unauthorized", "valid session required")
apperr.WriteHTTP(w, r, apperr.AuthSessionRequired.WrapMsg(err, "A valid session is required."))
return
}
ctx := auth.WithAuthContext(r.Context(), AuthContextFromSession(sess))
@ -192,13 +177,13 @@ func RequireSessionOrAPIKey(svc *session.Service, queries *db.Queries) func(http
next.ServeHTTP(w, r.WithContext(ctx))
return
}
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key")
apperr.WriteHTTP(w, r, apperr.AuthInvalidAPIKey.New())
return
}
sess, err := ResolveSession(r.Context(), queries, svc, r)
if err != nil {
ClearCookies(w, IsSecure(r))
writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key header or session cookie required")
apperr.WriteHTTP(w, r, apperr.AuthSessionRequired.Wrap(err))
return
}
ctx := auth.WithAuthContext(r.Context(), AuthContextFromSession(sess))
@ -216,12 +201,12 @@ func RequireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
apperr.WriteHTTP(w, r, apperr.Unauthorized.New())
return
}
user, err := queries.GetUserByID(r.Context(), ac.UserID)
if err != nil || !user.IsAdmin {
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
apperr.WriteHTTP(w, r, apperr.Forbidden.WrapMsg(err, "Admin access required."))
return
}
next.ServeHTTP(w, r)
@ -248,7 +233,7 @@ func RequireCSRF() func(http.Handler) http.Handler {
header := r.Header.Get(CSRFHeaderName)
if err != nil || cookie.Value == "" || header == "" ||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 {
writeError(w, http.StatusForbidden, "csrf_failed", "missing or invalid CSRF token")
apperr.WriteHTTP(w, r, apperr.AuthCSRF.New())
return
}
next.ServeHTTP(w, r)
@ -279,7 +264,7 @@ func IssueSession(
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
sess, err := svc.Create(ctx, userID, teamID, user.Email, user.Name, role, user.IsAdmin, r.UserAgent(), clientIP(r))
sess, err := svc.Create(ctx, userID, teamID, user.Email, user.Name, role, user.IsAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
return nil, err
}
@ -287,12 +272,3 @@ func IssueSession(
return sess, nil
}
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}

View File

@ -12,14 +12,16 @@ import (
// Deliver sends a notification to a single provider with the given config.
// For webhooks it uses HMAC-signed HTTP POST; for all others it uses shoutrrr.
func Deliver(ctx context.Context, provider string, config map[string]string, e events.Event) error {
// allowPrivate relaxes the SSRF guard on the webhook client for self-hosted
// deployments that deliver to internal endpoints.
func Deliver(ctx context.Context, provider string, config map[string]string, e events.Event, allowPrivate bool) error {
payload, err := json.Marshal(e)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
if provider == "webhook" {
wh := NewWebhookDelivery()
wh := NewWebhookDelivery(allowPrivate)
return wh.Deliver(ctx, config["url"], config["secret"], payload)
}

View File

@ -18,22 +18,31 @@ const (
consumerName = "cp-0"
)
// maxConcurrentRetries bounds the number of in-flight delivery retries. Without
// it, an endpoint outage during an event burst would spawn one goroutine per
// failed delivery — each holding decrypted secrets in memory for up to the full
// retry window.
const maxConcurrentRetries = 64
// Dispatcher consumes events from the Redis stream and delivers them
// to matching notification channels.
type Dispatcher struct {
rdb *redis.Client
db *db.Queries
encKey [32]byte
webhook *WebhookDelivery
rdb *redis.Client
db *db.Queries
encKey [32]byte
allowPrivate bool
retrySem chan struct{}
}
// NewDispatcher constructs an event dispatcher.
func NewDispatcher(rdb *redis.Client, queries *db.Queries, encKey [32]byte) *Dispatcher {
// NewDispatcher constructs an event dispatcher. allowPrivate relaxes the SSRF
// guard on outbound webhook deliveries.
func NewDispatcher(rdb *redis.Client, queries *db.Queries, encKey [32]byte, allowPrivate bool) *Dispatcher {
return &Dispatcher{
rdb: rdb,
db: queries,
encKey: encKey,
webhook: NewWebhookDelivery(),
rdb: rdb,
db: queries,
encKey: encKey,
allowPrivate: allowPrivate,
retrySem: make(chan struct{}, maxConcurrentRetries),
}
}
@ -138,10 +147,21 @@ func (d *Dispatcher) dispatch(ctx context.Context, ch db.Channel, e events.Event
chID := id.FormatChannelID(ch.ID)
if err := Deliver(ctx, ch.Provider, config, e); err != nil {
if err := Deliver(ctx, ch.Provider, config, e, d.allowPrivate); err != nil {
slog.Warn("channels: delivery failed, scheduling retries",
"channel_id", chID, "provider", ch.Provider, "error", err)
go d.retryDeliver(ctx, ch.Provider, config, e, chID)
// Bound concurrent retries: acquire a slot or drop (with a log) rather
// than spawning unbounded goroutines during a sustained outage.
select {
case d.retrySem <- struct{}{}:
go func() {
defer func() { <-d.retrySem }()
d.retryDeliver(ctx, ch.Provider, config, e, chID)
}()
default:
slog.Warn("channels: retry queue saturated, dropping retry",
"channel_id", chID, "provider", ch.Provider)
}
}
}
@ -153,7 +173,7 @@ func (d *Dispatcher) retryDeliver(ctx context.Context, provider string, config m
case <-time.After(delay):
}
if err := Deliver(ctx, provider, config, e); err != nil {
if err := Deliver(ctx, provider, config, e, d.allowPrivate); err != nil {
slog.Warn("channels: retry delivery failed",
"channel_id", chID, "provider", provider,
"attempt", i+2, "error", err)

View File

@ -13,6 +13,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/events"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -55,6 +56,8 @@ func init() {
type Service struct {
DB *db.Queries
EncKey [32]byte
// AllowPrivateTargets disables the SSRF guard on channel destination URLs.
AllowPrivateTargets bool
}
// CreateParams holds the parameters for creating a channel.
@ -81,25 +84,30 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, err
p.Name = clean
if !validProviders[p.Provider] {
return CreateResult{}, fmt.Errorf("invalid: unsupported provider %q", p.Provider)
return CreateResult{}, apperr.ValidationFailed.Msgf("Unsupported provider %q.", p.Provider).With("field", "provider")
}
if len(p.Events) == 0 {
return CreateResult{}, fmt.Errorf("invalid: at least one event type is required")
return CreateResult{}, apperr.ValidationFailed.Msg("At least one event type is required.").With("field", "events")
}
for _, et := range p.Events {
if !validEvents[et] {
return CreateResult{}, fmt.Errorf("invalid: unknown event type %q", et)
return CreateResult{}, apperr.ValidationFailed.Msgf("Unknown event type %q.", et).With("field", "events")
}
}
// Validate required config fields.
for _, field := range requiredFields[p.Provider] {
if p.Config[field] == "" {
return CreateResult{}, fmt.Errorf("invalid: %s is required for %s", field, p.Provider)
return CreateResult{}, apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, p.Provider).With("field", field)
}
}
// Reject destination URLs pointing at internal/private addresses (SSRF).
if err := validateConfigURLs(p.Config, s.AllowPrivateTargets); err != nil {
return CreateResult{}, err
}
// For webhooks, auto-generate secret if not provided.
var plaintextSecret string
if p.Provider == "webhook" {
@ -138,7 +146,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, err
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return CreateResult{}, fmt.Errorf("conflict: channel name %q already exists", p.Name)
return CreateResult{}, apperr.Conflict.Msgf("A channel named %q already exists.", p.Name)
}
return CreateResult{}, fmt.Errorf("insert channel: %w", err)
}
@ -165,11 +173,11 @@ func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, nam
name = clean
if len(eventTypes) == 0 {
return db.Channel{}, fmt.Errorf("invalid: at least one event type is required")
return db.Channel{}, apperr.ValidationFailed.Msg("At least one event type is required.").With("field", "events")
}
for _, et := range eventTypes {
if !validEvents[et] {
return db.Channel{}, fmt.Errorf("invalid: unknown event type %q", et)
return db.Channel{}, apperr.ValidationFailed.Msgf("Unknown event type %q.", et).With("field", "events")
}
}
@ -181,11 +189,11 @@ func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, nam
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Channel{}, fmt.Errorf("channel not found")
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return db.Channel{}, fmt.Errorf("conflict: channel name %q already exists", name)
return db.Channel{}, apperr.Conflict.Msgf("A channel named %q already exists.", name)
}
return db.Channel{}, fmt.Errorf("update channel: %w", err)
}
@ -198,7 +206,7 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
ch, err := s.DB.GetChannelByTeam(ctx, db.GetChannelByTeamParams{ID: channelID, TeamID: teamID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Channel{}, fmt.Errorf("channel not found")
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
}
return db.Channel{}, fmt.Errorf("get channel: %w", err)
}
@ -206,10 +214,15 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
// Validate required config fields for this provider.
for _, field := range requiredFields[ch.Provider] {
if config[field] == "" {
return db.Channel{}, fmt.Errorf("invalid: %s is required for %s", field, ch.Provider)
return db.Channel{}, apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, ch.Provider).With("field", field)
}
}
// Reject destination URLs pointing at internal/private addresses (SSRF).
if err := validateConfigURLs(config, s.AllowPrivateTargets); err != nil {
return db.Channel{}, err
}
// For webhooks, auto-generate secret if not provided.
if ch.Provider == "webhook" && config["secret"] == "" {
config["secret"] = generateSecret()
@ -237,7 +250,7 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Channel{}, fmt.Errorf("channel not found")
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
}
return db.Channel{}, fmt.Errorf("update channel config: %w", err)
}
@ -247,15 +260,21 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
// Test validates config and sends a test notification without persisting anything.
func (s *Service) Test(ctx context.Context, provider string, config map[string]string) error {
if !validProviders[provider] {
return fmt.Errorf("invalid: unsupported provider %q", provider)
return apperr.ValidationFailed.Msgf("Unsupported provider %q.", provider).With("field", "provider")
}
for _, field := range requiredFields[provider] {
if config[field] == "" {
return fmt.Errorf("invalid: %s is required for %s", field, provider)
return apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, provider).With("field", field)
}
}
// Reject destination URLs pointing at internal/private addresses (SSRF).
// Test is the sharpest oracle: it returns the delivery result synchronously.
if err := validateConfigURLs(config, s.AllowPrivateTargets); err != nil {
return err
}
// For webhooks, auto-generate a temporary secret if not provided.
if provider == "webhook" && config["secret"] == "" {
config["secret"] = generateSecret()
@ -269,7 +288,7 @@ func (s *Service) Test(ctx context.Context, provider string, config map[string]s
Resource: events.Resource{ID: "test", Type: "channel"},
}
return Deliver(ctx, provider, config, testEvent)
return Deliver(ctx, provider, config, testEvent, s.AllowPrivateTargets)
}
// Delete removes a channel by ID, scoped to the given team.
@ -284,7 +303,7 @@ func cleanName(name string) (string, error) {
name = strings.ToLower(name)
name = strings.ReplaceAll(name, " ", "-")
if err := validate.SafeName(name); err != nil {
return "", fmt.Errorf("invalid: %w", err)
return "", apperr.ValidationFailed.WrapMsg(err, "Invalid channel name.").With("field", "name")
}
return name, nil
}

112
pkg/channels/ssrf.go Normal file
View File

@ -0,0 +1,112 @@
package channels
import (
"context"
"fmt"
"net"
"net/url"
"syscall"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
)
// urlConfigFields are the provider config keys that carry a caller-supplied
// destination URL. Only these can drive an outbound HTTP connection to an
// attacker-chosen host, so they are the SSRF-relevant fields to validate.
var urlConfigFields = []string{"url", "webhook_url", "homeserver_url"}
// validateConfigURLs rejects any destination URL whose host resolves to a
// non-public address. When allowPrivate is true (self-hosted deployments that
// legitimately deliver to internal endpoints) the check is skipped entirely.
func validateConfigURLs(config map[string]string, allowPrivate bool) error {
if allowPrivate {
return nil
}
for _, field := range urlConfigFields {
raw := config[field]
if raw == "" {
continue
}
if err := validateTargetURL(raw); err != nil {
return apperr.ValidationFailed.
Msgf("The %s field must be a reachable public URL: %v", field, err).
With("field", field)
}
}
return nil
}
// validateTargetURL parses raw as an http(s) URL and rejects it if the host
// resolves to any non-public address. Resolution happens here (parse time) for
// fast user feedback; the dial-time guard (dialControl) re-checks at connect
// time to defeat DNS rebinding.
func validateTargetURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme %q is not allowed", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("missing host")
}
// A literal IP needs no DNS lookup.
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("host is a non-public address")
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("could not resolve host")
}
for _, ip := range ips {
if isBlockedIP(ip) {
return fmt.Errorf("host resolves to a non-public address")
}
}
return nil
}
// isBlockedIP reports whether ip belongs to a range that must never be a
// notification target: loopback, RFC1918/RFC4193 private, link-local
// (which includes the 169.254.169.254 cloud metadata endpoint), multicast,
// and the unspecified address.
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// dialContext returns a DialContext that blocks connections to non-public
// addresses. The Control hook runs after DNS resolution with the concrete
// resolved IP, so a host that passed parse-time validation but rebinds to a
// private address is still rejected at connect time.
func dialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second}
if !allowPrivate {
dialer.Control = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip != nil && isBlockedIP(ip) {
return fmt.Errorf("dial to non-public address %s blocked", ip)
}
return nil
}
}
return dialer.DialContext
}

View File

@ -18,14 +18,18 @@ type WebhookDelivery struct {
client *http.Client
}
// NewWebhookDelivery constructs a webhook delivery client.
func NewWebhookDelivery() *WebhookDelivery {
// NewWebhookDelivery constructs a webhook delivery client. When allowPrivate is
// false the client refuses to connect to non-public addresses (SSRF guard).
func NewWebhookDelivery(allowPrivate bool) *WebhookDelivery {
return &WebhookDelivery{
client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DialContext: dialContext(allowPrivate),
},
},
}
}

View File

@ -30,6 +30,12 @@ type Config struct {
EncryptionKeyHex string // WRENN_ENCRYPTION_KEY raw hex string (for validation)
EncryptionKey [32]byte // parsed 32-byte key
// ChannelsAllowPrivateTargets permits notification channels (webhooks etc.)
// to point at private/loopback/link-local addresses. Off by default to
// prevent SSRF against internal services; enable only on self-hosted
// deployments that legitimately deliver to internal endpoints.
ChannelsAllowPrivateTargets bool // WRENN_CHANNELS_ALLOW_PRIVATE
// SMTP — transactional email. All fields optional; omitting SMTPHost disables email.
SMTPHost string // SMTP_HOST
SMTPPort int // SMTP_PORT (default 587)
@ -47,7 +53,7 @@ func Load() Config {
cfg := Config{
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"),
RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"),
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":8080"),
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":9725"),
JWTSecret: os.Getenv("JWT_SECRET"),
WrennDir: envOrDefault("WRENN_DIR", "/var/lib/wrenn"),
@ -61,6 +67,8 @@ func Load() Config {
EncryptionKeyHex: os.Getenv("WRENN_ENCRYPTION_KEY"),
ChannelsAllowPrivateTargets: envOrDefaultBool("WRENN_CHANNELS_ALLOW_PRIVATE", false),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: envOrDefaultInt("SMTP_PORT", 587),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
@ -96,3 +104,15 @@ func envOrDefaultInt(key string, def int) int {
}
return n
}
func envOrDefaultBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}

View File

@ -150,8 +150,8 @@ func Run(opts ...Option) {
os.Exit(1)
}
channelPub := channels.NewPublisher(rdb)
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey}
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey)
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey, AllowPrivateTargets: cfg.ChannelsAllowPrivateTargets}
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey, cfg.ChannelsAllowPrivateTargets)
// Shared audit logger with event publishing.
al := audit.NewWithPublisher(queries, channelPub)

View File

@ -103,7 +103,7 @@ func (r *LoopRegistry) ReleaseAll() {
// SnapshotDevice holds the state for a single dm-snapshot device.
type SnapshotDevice struct {
Name string // dm device name, e.g., "wrenn-sb-a1b2c3d4"
Name string // dm device name, e.g., "wrenn-cl-a1b2c3d4"
DevicePath string // /dev/mapper/<Name>
CowPath string // path to the sparse CoW file
CowLoopDev string // loop device for the CoW file

View File

@ -258,11 +258,12 @@ func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-
}
// WriteFile writes content to a file inside the sandbox via envd's REST endpoint.
// envd expects PUT /files?path=...&username=root with the raw file content as the body.
// envd expects PUT /files?path=... with the raw file content as the body. No
// username is sent, so envd resolves the sandbox default user — the same user
// process execution runs as.
func (c *Client) WriteFile(ctx context.Context, path string, content []byte) error {
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
"path": {path},
"username": {"root"},
"path": {path},
}.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(content))
@ -288,11 +289,11 @@ func (c *Client) WriteFile(ctx context.Context, path string, content []byte) err
}
// ReadFile reads a file from inside the sandbox via envd's REST endpoint.
// envd expects GET /files?path=...&username=root.
// envd expects GET /files?path=... — no username is sent, so envd resolves the
// sandbox default user, matching process execution.
func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) {
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
"path": {path},
"username": {"root"},
"path": {path},
}.Encode())
slog.Debug("envd read file", "url", u, "path", path)

View File

@ -1 +0,0 @@
package lifecycle

21
pkg/netutil/netutil.go Normal file
View File

@ -0,0 +1,21 @@
// Package netutil holds small HTTP/networking helpers shared across the
// control plane. It lives in pkg/ so both internal/api and the auth
// middleware (and cloud extensions) can import a single implementation.
package netutil
import (
"net/http"
"strings"
)
// ClientIP returns the request's apparent client IP, honoring
// X-Forwarded-For when behind a reverse proxy.
func ClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}

View File

@ -1,6 +1,7 @@
package network
import (
"errors"
"fmt"
"os"
"path/filepath"
@ -8,6 +9,10 @@ import (
"sync"
)
// ErrNoFreeSlots is returned when every network slot index is claimed —
// the host cannot take another sandbox until one is released.
var ErrNoFreeSlots = errors.New("no free network slots")
// SlotAllocator manages network slot indices for sandboxes.
// Each sandbox needs a unique slot index for its network addressing.
//
@ -58,7 +63,7 @@ func (a *SlotAllocator) Allocate() (int, error) {
a.inUse[i] = true
return i, nil
}
return 0, fmt.Errorf("no free network slots")
return 0, ErrNoFreeSlots
}
// Release frees a slot index for reuse.

View File

@ -38,6 +38,12 @@ var (
ErrNotPaused = errors.New("sandbox not paused")
// ErrInvalidRange is returned when a metrics range parameter is invalid.
ErrInvalidRange = errors.New("invalid range")
// ErrTemplateNotFound is returned when a template's rootfs image does not
// exist on this host.
ErrTemplateNotFound = errors.New("template rootfs not found")
// ErrEnvdNotReady is returned when the VM booted (or resumed) but envd did
// not become healthy within the readiness budget.
ErrEnvdNotReady = errors.New("envd not ready")
)
// MinTimeoutSec is the minimum inactivity TTL accepted by Create/Resume.
@ -386,7 +392,7 @@ func (m *Manager) Create(
// Resolve base rootfs image.
baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID)
if _, err := os.Stat(baseRootfs); err != nil {
return nil, 0, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
return nil, 0, fmt.Errorf("%w: base rootfs missing at %s: %w", ErrTemplateNotFound, baseRootfs, err)
}
// Acquire shared read-only loop device for the base image.
@ -475,7 +481,7 @@ func (m *Manager) Create(
if err := client.WaitUntilReady(waitCtx); err != nil {
res.rollback()
return nil, 0, fmt.Errorf("wait for envd: %w", err)
return nil, 0, fmt.Errorf("%w: %w", ErrEnvdNotReady, err)
}
// Fetch envd version (best-effort).

View File

@ -81,7 +81,7 @@ func (m *Manager) launchRestoredVM(ctx context.Context, vmCfg vm.VMConfig, hostI
defer waitCancel()
if err := client.WaitUntilReady(waitCtx); err != nil {
_ = m.vm.Destroy(context.Background(), vmCfg.SandboxID)
return nil, fmt.Errorf("wait envd: %w", err)
return nil, fmt.Errorf("%w after resume: %w", ErrEnvdNotReady, err)
}
// Best-effort balloon deflate. Free-page reporting drains pages while the

View File

@ -29,7 +29,7 @@ func TestRunningStateRoundtrip(t *testing.T) {
wrennDir := t.TempDir()
want := &runningState{
Version: runningStateVersion,
ID: "sb-cafe0123",
ID: "cl-cafe0123",
TeamID: "00000000-0000-0000-0000-000000000000",
TemplateID: "00000000-0000-0000-0000-000000000001",
VCPUs: 2,
@ -37,12 +37,12 @@ func TestRunningStateRoundtrip(t *testing.T) {
TimeoutSec: 300,
SlotIndex: 7,
BaseImagePath: "/var/lib/wrenn/images/teams/x/y/rootfs.ext4",
CowPath: "/var/lib/wrenn/sandboxes/sb-cafe0123/rootfs.cow",
DMName: "wrenn-sb-cafe0123",
SandboxDir: "/tmp/ch-vm-sb-cafe0123",
CowPath: "/var/lib/wrenn/sandboxes/cl-cafe0123/rootfs.cow",
DMName: "wrenn-cl-cafe0123",
SandboxDir: "/tmp/ch-vm-cl-cafe0123",
CHPID: 4242,
CHSocket: "/tmp/ch-sb-cafe0123.sock",
SandboxDirOverride: "/tmp/ch-vm-sb-original",
CHSocket: "/tmp/ch-cl-cafe0123.sock",
SandboxDirOverride: "/tmp/ch-vm-cl-original",
LazyRestore: true,
CreatedAt: time.Now().Truncate(0),
Metadata: map[string]string{"kernel_version": "6.1.102"},
@ -65,7 +65,7 @@ func TestRunningStateRoundtrip(t *testing.T) {
func TestRunningStateVersionMismatch(t *testing.T) {
wrennDir := t.TempDir()
st := &runningState{Version: runningStateVersion + 1, ID: "sb-deadbeef", SlotIndex: 1}
st := &runningState{Version: runningStateVersion + 1, ID: "cl-deadbeef", SlotIndex: 1}
writeTestRunningState(t, wrennDir, st)
if _, err := readRunningState(wrennDir, st.ID); err == nil {
@ -75,9 +75,9 @@ func TestRunningStateVersionMismatch(t *testing.T) {
func TestRunningStateIDMismatch(t *testing.T) {
wrennDir := t.TempDir()
st := &runningState{Version: runningStateVersion, ID: "sb-other", SlotIndex: 1}
st := &runningState{Version: runningStateVersion, ID: "cl-other", SlotIndex: 1}
// Write the file under a directory whose name does not match st.ID.
dir := layout.SandboxDir(wrennDir, "sb-dirname")
dir := layout.SandboxDir(wrennDir, "cl-dirname")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
@ -86,20 +86,20 @@ func TestRunningStateIDMismatch(t *testing.T) {
t.Fatal(err)
}
if _, err := readRunningState(wrennDir, "sb-dirname"); err == nil {
if _, err := readRunningState(wrennDir, "cl-dirname"); err == nil {
t.Fatal("readRunningState should reject id mismatch")
}
}
func TestRunningStateMissingFile(t *testing.T) {
if _, err := readRunningState(t.TempDir(), "sb-none"); !os.IsNotExist(err) {
if _, err := readRunningState(t.TempDir(), "cl-none"); !os.IsNotExist(err) {
t.Fatalf("want os.IsNotExist error, got %v", err)
}
}
func TestDeleteRunningState(t *testing.T) {
wrennDir := t.TempDir()
st := &runningState{Version: runningStateVersion, ID: "sb-gone", SlotIndex: 1}
st := &runningState{Version: runningStateVersion, ID: "cl-gone", SlotIndex: 1}
writeTestRunningState(t, wrennDir, st)
deleteRunningState(wrennDir, st.ID)

View File

@ -6,6 +6,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
)
@ -95,9 +96,9 @@ func (s *LeastLoadedScheduler) SelectHost(ctx context.Context, teamID pgtype.UUI
if len(candidates) == 0 {
if isByoc {
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
return db.Host{}, apperr.CapacityUnavailable.Msg("None of your team's hosts are online. Bring a host online and try again.")
}
return db.Host{}, fmt.Errorf("no online platform hosts available")
return db.Host{}, apperr.CapacityUnavailable.New()
}
// Phase 2: admission control + selection — pick the highest-scoring host
@ -119,7 +120,10 @@ func (s *LeastLoadedScheduler) SelectHost(ctx context.Context, teamID pgtype.UUI
}
if best == -1 {
return db.Host{}, fmt.Errorf("no host has sufficient resources: need %d MB memory, %d MB disk", memoryMb, diskSizeMb)
return db.Host{}, apperr.CapacityUnavailable.
Msg("No host has enough free resources for this sandbox. Try again shortly or request a smaller size.").
With("memory_mb", memoryMb).
With("disk_mb", diskSizeMb)
}
return candidates[best].host, nil

View File

@ -7,6 +7,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
)
@ -66,9 +67,9 @@ func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID pgtype.UUID
if len(eligible) == 0 {
if isByoc {
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
return db.Host{}, apperr.CapacityUnavailable.Msg("None of your team's hosts are online. Bring a host online and try again.")
}
return db.Host{}, fmt.Errorf("no online platform hosts available")
return db.Host{}, apperr.CapacityUnavailable.New()
}
idx := s.counter.Add(1) - 1

View File

@ -1 +0,0 @@
package scheduler

View File

@ -1 +0,0 @@
package scheduler

View File

@ -3,12 +3,15 @@ package service
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
// APIKeyService provides API key operations shared between the REST API and the dashboard.
@ -25,8 +28,13 @@ type APIKeyCreateResult struct {
// Create generates a new API key for the given team.
func (s *APIKeyService) Create(ctx context.Context, teamID, userID pgtype.UUID, name string) (APIKeyCreateResult, error) {
name = strings.TrimSpace(name)
if name == "" {
name = "Unnamed API Key"
} else if err := validate.DisplayName(name); err != nil {
return APIKeyCreateResult{}, apperr.ValidationFailed.
WrapMsg(err, "API key name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").
With("field", "name")
}
plaintext, hash, err := auth.GenerateAPIKey()

View File

@ -15,6 +15,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/recipe"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -188,11 +189,11 @@ func (s *BuildService) List(ctx context.Context) ([]db.TemplateBuild, error) {
func (s *BuildService) Cancel(ctx context.Context, buildID pgtype.UUID) error {
build, err := s.DB.GetTemplateBuild(ctx, buildID)
if err != nil {
return fmt.Errorf("get build: %w", err)
return apperr.BuildNotFound.Wrap(err)
}
switch build.Status {
case "success", "failed", "cancelled":
return fmt.Errorf("build is already %s", build.Status)
return apperr.Conflict.Msgf("The build is already %s.", build.Status)
}
// Mark cancelled in DB first. This handles both pending builds (which haven't
@ -441,13 +442,14 @@ func (s *BuildService) provisionBuildSandbox(
) (buildAgentClient, string, map[string]string, error) {
host, err := s.Scheduler.SelectHost(ctx, id.PlatformTeamID, false, build.MemoryMb, 5120)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("no host available: %v", err))
// Persist the client-safe message — this string surfaces in the build UI.
s.failBuild(ctx, buildID, apperr.From(err).Message)
return nil, "", nil, err
}
agent, err := s.Pool.GetForHost(host)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("agent client error: %v", err))
s.failBuild(ctx, buildID, apperr.From(err).Message)
return nil, "", nil, err
}
@ -459,7 +461,7 @@ func (s *BuildService) provisionBuildSandbox(
// platform-owned rows, so resolve the path from the DB record.
baseTmpl, err := s.DB.GetPlatformTemplateByName(ctx, build.BaseTemplate)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found: %v", build.BaseTemplate, err))
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found", build.BaseTemplate))
return nil, "", nil, err
}
baseTeamID := baseTmpl.TeamID
@ -476,7 +478,7 @@ func (s *BuildService) provisionBuildSandbox(
DiskSizeMb: 0,
}))
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("create sandbox failed: %v", err))
s.failBuild(ctx, buildID, apperr.From(err).Message)
return nil, "", nil, err
}
sandboxMetadata := resp.Msg.Metadata

View File

@ -10,6 +10,7 @@ import (
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -76,7 +77,7 @@ func clampTimeout(timeoutSec int32) int32 {
func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.UUID) (hostagentClient, db.Sandbox, error) {
sb, err := s.DB.GetSandbox(ctx, sandboxID)
if err != nil {
return nil, db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
return nil, db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
}
agent, err := s.agentForHost(ctx, sb.HostID)
if err != nil {
@ -90,7 +91,7 @@ func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.U
func (s *SandboxService) agentForHost(ctx context.Context, hostID pgtype.UUID) (hostagentClient, error) {
host, err := s.DB.GetHost(ctx, hostID)
if err != nil {
return nil, fmt.Errorf("host not found: %w", err)
return nil, apperr.HostNotFound.Wrap(err)
}
agent, err := s.Pool.GetForHost(host)
if err != nil {
@ -126,10 +127,10 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
p.Template = "minimal-ubuntu"
}
if err := validate.SafeName(p.Template); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
return db.Sandbox{}, apperr.ValidationFailed.Msgf("Invalid template name: %v.", err)
}
if err := validate.Metadata(p.Metadata); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
return db.Sandbox{}, apperr.ValidationFailed.Msgf("Invalid metadata: %v.", err)
}
if p.VCPUs <= 0 {
p.VCPUs = 2
@ -144,7 +145,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
// query also matches platform templates for any team).
tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("template %q not found: %w", p.Template, err)
return db.Sandbox{}, apperr.TemplateNotFound.WrapMsg(err, fmt.Sprintf("Template %q not found.", p.Template))
}
templateTeamID := tmpl.TeamID
templateID := tmpl.ID
@ -159,12 +160,12 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
}
if !p.TeamID.Valid {
return db.Sandbox{}, fmt.Errorf("invalid request: team_id is required")
return db.Sandbox{}, apperr.InvalidRequest.Msg("A team_id is required.")
}
team, err := s.DB.GetTeam(ctx, p.TeamID)
if err != nil {
return db.Sandbox{}, fmt.Errorf("team not found: %w", err)
return db.Sandbox{}, apperr.TeamNotFound.Wrap(err)
}
host, err := s.Scheduler.SelectHost(ctx, p.TeamID, team.IsByoc, p.MemoryMB, 0)
@ -324,7 +325,7 @@ func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID pgtype.UUID)
func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
return db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
}
if sb.Status == "paused" {
return sb, nil
@ -333,7 +334,7 @@ func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUI
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
ID: sandboxID, Status: "running", Status_2: "pausing",
}); err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not in running state (current: %s)", sb.Status)
return db.Sandbox{}, apperr.SandboxNotRunning.Wrap(err).With("status", sb.Status)
}
agent, err := s.agentForHost(ctx, sb.HostID)
@ -400,7 +401,7 @@ func (s *SandboxService) pauseInBackground(sandboxID pgtype.UUID, sandboxIDStr,
func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
return db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
}
if sb.Status == "running" {
return sb, nil
@ -409,7 +410,7 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UU
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
ID: sandboxID, Status: "paused", Status_2: "resuming",
}); err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not in paused state (current: %s)", sb.Status)
return db.Sandbox{}, apperr.SandboxNotPaused.Wrap(err).With("status", sb.Status)
}
agent, err := s.agentForHost(ctx, sb.HostID)
@ -509,10 +510,10 @@ func (s *SandboxService) resumeInBackground(
func (s *SandboxService) CreateSnapshot(ctx context.Context, sandboxID, teamID pgtype.UUID, name string) (db.Sandbox, string, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, "", fmt.Errorf("sandbox not found: %w", err)
return db.Sandbox{}, "", apperr.SandboxNotFound.Wrap(err)
}
if sb.Status != "running" && sb.Status != "paused" {
return db.Sandbox{}, "", fmt.Errorf("sandbox is not running or paused (status: %s)", sb.Status)
return db.Sandbox{}, "", apperr.Conflict.Msg("Sandbox must be running or paused to snapshot.").With("status", sb.Status)
}
origStatus := sb.Status
@ -520,18 +521,18 @@ func (s *SandboxService) CreateSnapshot(ctx context.Context, sandboxID, teamID p
name = id.NewSnapshotName()
}
if err := validate.SafeName(name); err != nil {
return db.Sandbox{}, "", fmt.Errorf("invalid name: %w", err)
return db.Sandbox{}, "", apperr.ValidationFailed.Msgf("Invalid snapshot name: %v.", err)
}
// Reject duplicate names up front so we don't pause the VM and dump memory
// only to fail on the template insert at the very end.
if _, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: teamID}); err == nil {
return db.Sandbox{}, "", fmt.Errorf("conflict: a snapshot named %q already exists", name)
return db.Sandbox{}, "", apperr.Conflict.Msgf("A snapshot named %q already exists.", name)
}
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
ID: sandboxID, Status: origStatus, Status_2: "snapshotting",
}); err != nil {
return db.Sandbox{}, "", fmt.Errorf("sandbox not in %s state (current: %s)", origStatus, sb.Status)
return db.Sandbox{}, "", apperr.Conflict.WrapMsg(err, fmt.Sprintf("Sandbox is no longer in %s state.", origStatus)).With("status", origStatus)
}
agent, err := s.agentForHost(ctx, sb.HostID)
@ -639,7 +640,7 @@ func (s *SandboxService) publishStateChanged(ctx context.Context, sandboxIDStr,
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return fmt.Errorf("sandbox not found: %w", err)
return apperr.SandboxNotFound.Wrap(err)
}
if sb.Status == "stopped" || sb.Status == "error" {
return nil
@ -745,7 +746,7 @@ func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID pgty
func (s *SandboxService) GetDiskUsage(ctx context.Context, sandboxID, teamID pgtype.UUID) (int64, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return 0, fmt.Errorf("sandbox not found: %w", err)
return 0, apperr.SandboxNotFound.Wrap(err)
}
// For running or paused sandboxes, try the agent for live disk usage.
@ -776,10 +777,10 @@ func (s *SandboxService) GetDiskUsage(ctx context.Context, sandboxID, teamID pgt
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return fmt.Errorf("sandbox not found: %w", err)
return apperr.SandboxNotFound.Wrap(err)
}
if sb.Status != "running" {
return fmt.Errorf("sandbox is not running (status: %s)", sb.Status)
return apperr.SandboxNotRunning.New().With("status", sb.Status)
}
agent, _, err := s.agentForSandbox(ctx, sandboxID)

View File

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -56,7 +57,7 @@ func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtyp
})
if err != nil {
if err == pgx.ErrNoRows {
return "", fmt.Errorf("forbidden: not a member of this team")
return "", apperr.Forbidden.Msg("You are not a member of this team.")
}
return "", fmt.Errorf("get membership: %w", err)
}
@ -66,7 +67,7 @@ func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtyp
// requireAdmin returns an error if the caller is not an admin or owner.
func requireAdmin(role string) error {
if role != "owner" && role != "admin" {
return fmt.Errorf("forbidden: admin or owner role required")
return apperr.Forbidden.Msg("Admin or owner role required.")
}
return nil
}
@ -76,12 +77,12 @@ func (s *TeamService) GetTeam(ctx context.Context, teamID pgtype.UUID) (db.Team,
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
if err == pgx.ErrNoRows {
return db.Team{}, fmt.Errorf("team not found")
return db.Team{}, apperr.TeamNotFound.New()
}
return db.Team{}, fmt.Errorf("get team: %w", err)
}
if team.DeletedAt.Valid {
return db.Team{}, fmt.Errorf("team not found")
return db.Team{}, apperr.TeamNotFound.New()
}
return team, nil
}
@ -105,7 +106,7 @@ func (s *TeamService) ListTeamsForUser(ctx context.Context, userID pgtype.UUID)
// CreateTeam creates a new team owned by the given user.
func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, name string) (TeamWithRole, error) {
if !teamNameRE.MatchString(name) {
return TeamWithRole{}, fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
return TeamWithRole{}, apperr.ValidationFailed.Msg("Team name must be 1128 characters: letters, numbers, spaces, and underscores.").With("field", "name")
}
tx, err := s.Pool.Begin(ctx)
@ -145,7 +146,7 @@ func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, n
// RenameTeam updates the team name. Caller must be admin or owner (verified from DB).
func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID pgtype.UUID, newName string) error {
if !teamNameRE.MatchString(newName) {
return fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
return apperr.ValidationFailed.Msg("Team name must be 1128 characters: letters, numbers, spaces, and underscores.").With("field", "name")
}
role, err := s.callerRole(ctx, teamID, callerUserID)
@ -171,7 +172,7 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
return err
}
if role != "owner" {
return fmt.Errorf("forbidden: only the owner can delete a team")
return apperr.Forbidden.Msg("Only the owner can delete a team.")
}
return s.deleteTeamCore(ctx, teamID)
@ -323,7 +324,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype
target, err := s.DB.GetUserByEmail(ctx, email)
if err != nil {
if err == pgx.ErrNoRows {
return MemberInfo{}, fmt.Errorf("user not found: no account with that email")
return MemberInfo{}, apperr.UserNotFound.Msg("No account exists with that email.")
}
return MemberInfo{}, fmt.Errorf("look up user: %w", err)
}
@ -334,7 +335,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype
TeamID: teamID,
})
if memberCheckErr == nil {
return MemberInfo{}, fmt.Errorf("invalid: user is already a member of this team")
return MemberInfo{}, apperr.Conflict.Msg("This user is already a member of the team.")
} else if memberCheckErr != pgx.ErrNoRows {
return MemberInfo{}, fmt.Errorf("check membership: %w", memberCheckErr)
}
@ -368,13 +369,13 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
})
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("not found: user is not a member of this team")
return apperr.NotFound.Msg("This user is not a member of the team.")
}
return fmt.Errorf("get target membership: %w", err)
}
if targetMembership.Role == "owner" {
return fmt.Errorf("forbidden: the owner cannot be removed from the team")
return apperr.Forbidden.Msg("The owner cannot be removed from the team.")
}
if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{
@ -411,7 +412,7 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
// Valid target roles: "admin", "member".
func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID, targetUserID pgtype.UUID, newRole string) error {
if newRole != "admin" && newRole != "member" {
return fmt.Errorf("invalid: role must be admin or member")
return apperr.ValidationFailed.Msg("Role must be either admin or member.").With("field", "role")
}
callerRole, err := s.callerRole(ctx, teamID, callerUserID)
@ -428,13 +429,13 @@ func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID
})
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("not found: user is not a member of this team")
return apperr.NotFound.Msg("This user is not a member of the team.")
}
return fmt.Errorf("get target membership: %w", err)
}
if targetMembership.Role == "owner" {
return fmt.Errorf("forbidden: the owner's role cannot be changed")
return apperr.Forbidden.Msg("The owner's role cannot be changed.")
}
if err := s.DB.UpdateMemberRole(ctx, db.UpdateMemberRoleParams{
@ -455,7 +456,7 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
return err
}
if role == "owner" {
return fmt.Errorf("forbidden: the owner cannot leave the team; delete the team instead")
return apperr.Forbidden.Msg("The owner cannot leave the team; delete the team instead.")
}
if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{
@ -473,13 +474,13 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
func (s *TeamService) SetBYOC(ctx context.Context, teamID pgtype.UUID, enabled bool) error {
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
return fmt.Errorf("team not found: %w", err)
return apperr.TeamNotFound.Wrap(err)
}
if team.DeletedAt.Valid {
return fmt.Errorf("team not found")
return apperr.TeamNotFound.New()
}
if !enabled {
return fmt.Errorf("invalid request: BYOC cannot be disabled once enabled")
return apperr.Conflict.Msg("BYOC cannot be disabled once enabled.")
}
if team.IsByoc {
// Already enabled — idempotent, no-op.
@ -563,10 +564,10 @@ func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID
func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) error {
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
return fmt.Errorf("team not found: %w", err)
return apperr.TeamNotFound.Wrap(err)
}
if team.DeletedAt.Valid {
return fmt.Errorf("team not found")
return apperr.TeamNotFound.New()
}
return s.deleteTeamCore(ctx, teamID)

View File

@ -0,0 +1,78 @@
package validate
import "testing"
func TestDisplayName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"simple", "John", false},
{"with-space", "John Doe", false},
{"with-dot", "John D.", false},
{"with-dash", "Anne-Marie", false},
{"with-underscore", "cool_name", false},
{"numbers", "user123", false},
{"max-length", repeat("a", 100), false},
{"empty", "", true},
{"too-long", repeat("a", 101), true},
{"angle-open", "John<", true},
{"angle-close", "John>", true},
{"ampersand", "A&B", true},
{"double-quote", "he\"llo", true},
{"single-quote", "O'Brien", true},
{"xss-img", "<img src=x onerror=alert(1)>", true},
{"xss-script", "<script>alert(1)</script>", true},
{"at-sign", "a@b", true},
{"unicode-letter", "José", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := DisplayName(tt.input); (err != nil) != tt.wantErr {
t.Errorf("DisplayName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestSanitizeDisplayName(t *testing.T) {
tests := []struct {
name string
input string
fallback string
want string
}{
{"clean", "John Doe", "user", "John Doe"},
{"strip-html", "<img src=x>Bob", "user", "img srcxBob"},
{"strip-angles-only", "a<b>c", "user", "abc"},
{"strip-unicode", "José", "user", "Jos"},
{"all-stripped", "<>&\"'", "user", "user"},
{"empty", "", "fallback", "fallback"},
{"trims", " spaced ", "user", "spaced"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SanitizeDisplayName(tt.input, tt.fallback); got != tt.want {
t.Errorf("SanitizeDisplayName(%q, %q) = %q, want %q", tt.input, tt.fallback, got, tt.want)
}
// A sanitized non-fallback result must itself be a valid DisplayName.
if got := SanitizeDisplayName(tt.input, tt.fallback); got != tt.fallback {
if err := DisplayName(got); err != nil {
t.Errorf("SanitizeDisplayName(%q) = %q is not a valid DisplayName: %v", tt.input, got, err)
}
}
})
}
}
func repeat(s string, n int) string {
out := make([]byte, 0, len(s)*n)
for range n {
out = append(out, s...)
}
return string(out)
}

27
pkg/validate/email.go Normal file
View File

@ -0,0 +1,27 @@
package validate
import (
"fmt"
"regexp"
)
// emailRe is a pragmatic email check: a local part of common address
// characters, an @, a dotted domain, and a 2+ character TLD. It excludes
// spaces and the HTML-significant characters < > & " ' so a stored email is
// safe to render without escaping.
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Email validates that email is a well-formed address within length limits.
// Callers should TrimSpace (and typically ToLower) first.
func Email(email string) error {
if email == "" {
return fmt.Errorf("email must not be empty")
}
if len(email) > 254 {
return fmt.Errorf("email is too long (max 254 characters)")
}
if !emailRe.MatchString(email) {
return fmt.Errorf("email is not a valid address")
}
return nil
}

View File

@ -0,0 +1,35 @@
package validate
import "testing"
func TestEmail(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"simple", "user@example.com", false},
{"subdomain", "user@mail.example.com", false},
{"plus-tag", "user+tag@example.com", false},
{"dots", "first.last@example.co.uk", false},
{"digits", "user123@ex4mple.io", false},
{"empty", "", true},
{"no-at", "userexample.com", true},
{"no-domain", "user@", true},
{"no-tld", "user@example", true},
{"space", "user @example.com", true},
{"angle-bracket", "user@<script>.com", true},
{"xss-payload", "a@b.com<img src=x onerror=alert(1)>", true},
{"quote", "us\"er@example.com", true},
{"leading-space", " user@example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := Email(tt.input); (err != nil) != tt.wantErr {
t.Errorf("Email(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

View File

@ -3,12 +3,50 @@ package validate
import (
"fmt"
"regexp"
"strings"
)
// nameRe matches safe path component names: alphanumeric start, then
// alphanumeric, dash, underscore, or dot. Max 64 characters.
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// displayNameRe matches user-facing display names (user names, API key names):
// letters, digits, spaces, dots, underscores, hyphens. It deliberately
// excludes the HTML-significant characters < > & " ' so these values are safe
// to render without escaping. Max 100 characters.
var displayNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,100}$`)
// disallowedNameChars matches any character not permitted in a display name.
var disallowedNameChars = regexp.MustCompile(`[^A-Za-z0-9 ._-]`)
// DisplayName validates a user-supplied display name. Callers should TrimSpace
// first. It rejects empty names, names over 100 characters, and any character
// outside the alphanumeric + space + . _ - allowlist.
func DisplayName(name string) error {
if name == "" {
return fmt.Errorf("name must not be empty")
}
if !displayNameRe.MatchString(name) {
return fmt.Errorf("name may only contain letters, numbers, spaces, and . _ - (max 100 characters)")
}
return nil
}
// SanitizeDisplayName coerces an externally-sourced name (e.g. from an OAuth
// provider) into the DisplayName allowlist by dropping disallowed characters.
// Such names cannot be rejected interactively, so they are cleaned instead.
// Returns fallback if nothing usable remains.
func SanitizeDisplayName(name, fallback string) string {
cleaned := strings.TrimSpace(disallowedNameChars.ReplaceAllString(name, ""))
if len(cleaned) > 100 {
cleaned = strings.TrimSpace(cleaned[:100])
}
if cleaned == "" {
return fallback
}
return cleaned
}
// SafeName checks that name is safe for use as a single filesystem path
// component. It rejects empty strings, path separators, ".." sequences,
// leading dots, and anything outside the alphanumeric+dash+underscore+dot

View File

@ -27,7 +27,7 @@ type VMConfig struct {
KernelPath string
// RootfsPath is the path to the rootfs block device for this sandbox.
// Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-sb-a1b2c3d4).
// Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-cl-a1b2c3d4).
RootfsPath string
// VCPUs is the number of virtual CPUs to allocate (default: 1).

View File

@ -4256,6 +4256,85 @@ func (*ConnectProcessResponse_Data) isConnectProcessResponse_Event() {}
func (*ConnectProcessResponse_End) isConnectProcessResponse_Event() {}
// ErrorInfo travels as a Connect error detail so the control plane can
// surface precise, client-safe errors instead of guessing from Connect
// codes. Fields mirror pkg/apperr.Error; details values are stringified.
type ErrorInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // apperr catalog code, e.g. "sandbox_not_running"
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // client-safe message
Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"`
HttpStatus int32 `protobuf:"varint,4,opt,name=http_status,json=httpStatus,proto3" json:"http_status,omitempty"`
Details map[string]string `protobuf:"bytes,5,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ErrorInfo) Reset() {
*x = ErrorInfo{}
mi := &file_hostagent_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ErrorInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ErrorInfo) ProtoMessage() {}
func (x *ErrorInfo) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ErrorInfo.ProtoReflect.Descriptor instead.
func (*ErrorInfo) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{71}
}
func (x *ErrorInfo) GetCode() string {
if x != nil {
return x.Code
}
return ""
}
func (x *ErrorInfo) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *ErrorInfo) GetRetryable() bool {
if x != nil {
return x.Retryable
}
return false
}
func (x *ErrorInfo) GetHttpStatus() int32 {
if x != nil {
return x.HttpStatus
}
return 0
}
func (x *ErrorInfo) GetDetails() map[string]string {
if x != nil {
return x.Details
}
return nil
}
var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" +
@ -4594,7 +4673,17 @@ const file_hostagent_proto_rawDesc = "" +
"\x05start\x18\x01 \x01(\v2\x1d.hostagent.v1.ExecStreamStartH\x00R\x05start\x122\n" +
"\x04data\x18\x02 \x01(\v2\x1c.hostagent.v1.ExecStreamDataH\x00R\x04data\x12/\n" +
"\x03end\x18\x03 \x01(\v2\x1b.hostagent.v1.ExecStreamEndH\x00R\x03endB\a\n" +
"\x05event2\xb3\x14\n" +
"\x05event\"\xf4\x01\n" +
"\tErrorInfo\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" +
"\tretryable\x18\x03 \x01(\bR\tretryable\x12\x1f\n" +
"\vhttp_status\x18\x04 \x01(\x05R\n" +
"httpStatus\x12>\n" +
"\adetails\x18\x05 \x03(\v2$.hostagent.v1.ErrorInfo.DetailsEntryR\adetails\x1a:\n" +
"\fDetailsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x012\xb3\x14\n" +
"\x10HostAgentService\x12X\n" +
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
@ -4642,7 +4731,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
return file_hostagent_proto_rawDescData
}
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 79)
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 81)
var file_hostagent_proto_goTypes = []any{
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
@ -4715,23 +4804,25 @@ var file_hostagent_proto_goTypes = []any{
(*KillProcessResponse)(nil), // 68: hostagent.v1.KillProcessResponse
(*ConnectProcessRequest)(nil), // 69: hostagent.v1.ConnectProcessRequest
(*ConnectProcessResponse)(nil), // 70: hostagent.v1.ConnectProcessResponse
nil, // 71: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
nil, // 72: hostagent.v1.CreateSandboxResponse.MetadataEntry
nil, // 73: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
nil, // 74: hostagent.v1.ResumeSandboxResponse.MetadataEntry
nil, // 75: hostagent.v1.ExecRequest.EnvsEntry
nil, // 76: hostagent.v1.SandboxInfo.MetadataEntry
nil, // 77: hostagent.v1.PtyAttachRequest.EnvsEntry
nil, // 78: hostagent.v1.StartBackgroundRequest.EnvsEntry
(*ErrorInfo)(nil), // 71: hostagent.v1.ErrorInfo
nil, // 72: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
nil, // 73: hostagent.v1.CreateSandboxResponse.MetadataEntry
nil, // 74: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
nil, // 75: hostagent.v1.ResumeSandboxResponse.MetadataEntry
nil, // 76: hostagent.v1.ExecRequest.EnvsEntry
nil, // 77: hostagent.v1.SandboxInfo.MetadataEntry
nil, // 78: hostagent.v1.PtyAttachRequest.EnvsEntry
nil, // 79: hostagent.v1.StartBackgroundRequest.EnvsEntry
nil, // 80: hostagent.v1.ErrorInfo.DetailsEntry
}
var file_hostagent_proto_depIdxs = []int32{
71, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
72, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry
73, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
74, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry
75, // 4: hostagent.v1.ExecRequest.envs:type_name -> hostagent.v1.ExecRequest.EnvsEntry
72, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
73, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry
74, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
75, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry
76, // 4: hostagent.v1.ExecRequest.envs:type_name -> hostagent.v1.ExecRequest.EnvsEntry
16, // 5: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
76, // 6: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry
77, // 6: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry
23, // 7: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart
24, // 8: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData
25, // 9: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd
@ -4742,80 +4833,81 @@ var file_hostagent_proto_depIdxs = []int32{
42, // 14: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint
42, // 15: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint
42, // 16: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint
77, // 17: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
78, // 17: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
53, // 18: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted
54, // 19: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput
55, // 20: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited
78, // 21: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry
79, // 21: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry
65, // 22: hostagent.v1.ListProcessesResponse.processes:type_name -> hostagent.v1.ProcessEntry
23, // 23: hostagent.v1.ConnectProcessResponse.start:type_name -> hostagent.v1.ExecStreamStart
24, // 24: hostagent.v1.ConnectProcessResponse.data:type_name -> hostagent.v1.ExecStreamData
25, // 25: hostagent.v1.ConnectProcessResponse.end:type_name -> hostagent.v1.ExecStreamEnd
0, // 26: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 27: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 28: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 29: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
12, // 30: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
14, // 31: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
17, // 32: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
19, // 33: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
31, // 34: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
34, // 35: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
36, // 36: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
8, // 37: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
10, // 38: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
21, // 39: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
26, // 40: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 41: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
38, // 42: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
40, // 43: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
43, // 44: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
45, // 45: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
47, // 46: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
51, // 47: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
56, // 48: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
58, // 49: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
60, // 50: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
62, // 51: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest
64, // 52: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest
67, // 53: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest
69, // 54: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest
49, // 55: hostagent.v1.HostAgentService.GetTemplateSize:input_type -> hostagent.v1.GetTemplateSizeRequest
1, // 56: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 57: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 58: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 59: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 60: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 61: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 62: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 63: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
32, // 64: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
35, // 65: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
37, // 66: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
9, // 67: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 68: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 69: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 70: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 71: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
39, // 72: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
41, // 73: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
44, // 74: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
46, // 75: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
48, // 76: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
52, // 77: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
57, // 78: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
59, // 79: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
61, // 80: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
63, // 81: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse
66, // 82: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse
68, // 83: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse
70, // 84: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse
50, // 85: hostagent.v1.HostAgentService.GetTemplateSize:output_type -> hostagent.v1.GetTemplateSizeResponse
56, // [56:86] is the sub-list for method output_type
26, // [26:56] is the sub-list for method input_type
26, // [26:26] is the sub-list for extension type_name
26, // [26:26] is the sub-list for extension extendee
0, // [0:26] is the sub-list for field type_name
80, // 26: hostagent.v1.ErrorInfo.details:type_name -> hostagent.v1.ErrorInfo.DetailsEntry
0, // 27: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 28: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 29: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 30: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
12, // 31: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
14, // 32: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
17, // 33: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
19, // 34: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
31, // 35: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
34, // 36: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
36, // 37: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
8, // 38: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
10, // 39: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
21, // 40: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
26, // 41: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 42: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
38, // 43: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
40, // 44: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
43, // 45: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
45, // 46: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
47, // 47: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
51, // 48: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
56, // 49: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
58, // 50: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
60, // 51: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
62, // 52: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest
64, // 53: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest
67, // 54: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest
69, // 55: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest
49, // 56: hostagent.v1.HostAgentService.GetTemplateSize:input_type -> hostagent.v1.GetTemplateSizeRequest
1, // 57: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 58: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 59: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 60: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 61: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 62: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 63: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 64: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
32, // 65: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
35, // 66: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
37, // 67: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
9, // 68: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 69: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 70: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 71: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 72: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
39, // 73: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
41, // 74: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
44, // 75: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
46, // 76: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
48, // 77: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
52, // 78: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
57, // 79: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
59, // 80: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
61, // 81: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
63, // 82: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse
66, // 83: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse
68, // 84: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse
70, // 85: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse
50, // 86: hostagent.v1.HostAgentService.GetTemplateSize:output_type -> hostagent.v1.GetTemplateSizeResponse
57, // [57:87] is the sub-list for method output_type
27, // [27:57] is the sub-list for method input_type
27, // [27:27] is the sub-list for extension type_name
27, // [27:27] is the sub-list for extension extendee
0, // [0:27] is the sub-list for field type_name
}
func init() { file_hostagent_proto_init() }
@ -4861,7 +4953,7 @@ func file_hostagent_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 79,
NumMessages: 81,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -595,3 +595,14 @@ message ConnectProcessResponse {
ExecStreamEnd end = 3;
}
}
// ErrorInfo travels as a Connect error detail so the control plane can
// surface precise, client-safe errors instead of guessing from Connect
// codes. Fields mirror pkg/apperr.Error; details values are stringified.
message ErrorInfo {
string code = 1; // apperr catalog code, e.g. "sandbox_not_running"
string message = 2; // client-safe message
bool retryable = 3;
int32 http_status = 4;
map<string, string> details = 5;
}

View File

@ -152,7 +152,7 @@ echo ""
echo "==> Checking required container packages..."
MISSING_PKGS=""
for bin in socat chronyd chronyc curl git; do
if ! find "${MOUNT_DIR}" -name "${bin}" -type f 2>/dev/null | head -1 | grep -q .; then
if ! find "${MOUNT_DIR}" -name "${bin}" \( -type f -o -type l \) 2>/dev/null | head -1 | grep -q .; then
MISSING_PKGS="${MISSING_PKGS} ${bin}"
fi
done