Skip to main content

crewship admin

cmd/crewship/cmd_admin.go exposes the operator-on-the-host recovery surface. Every subcommand under admin opens the local SQLite database directly (no HTTP, no auth token) — the server doesn’t even need to be running. Use these when a user is locked out of the UI. The “credential” for these commands is shell access to the host. That matches what GitLab (gitlab-rake gitlab:password:reset), Gitea (gitea admin user change-password), Nextcloud (occ user:resetpassword), and Mattermost (mmctl user change-password) all do — if you can SSH to the box, you ARE the admin.
The credential for these commands is shell access to the host — there is no crewship login token. Requirements:
  • Read+write access to the data directory (default ~/.crewship). The binary opens ~/.crewship/crewship.db directly via openAdminDB.
  • The crewship binary must run on the same host as the data directory.
  • The host’s filesystem permissions are the authentication.

Subcommands

crewship admin reencrypt

Re-encrypt stored secret envelopes to the server’s current master-key version. This is the server-side step after configuring a new key version; the command requires authentication, an active workspace, and a running server.
The operation is idempotent: envelopes already at the current version are skipped. Human output reports the target key_version, totals for Re-encrypted, Skipped, and Failed, followed by per-table/per-column counts when present. JSON output contains key_version, reencrypted, skipped, failed, and columns. A non-zero exit means at least one value could not be decrypted with the configured keys; do not retire the old key.

crewship admin reset-password

Rotate a user’s password.
The hashed value is written into users.hashed_password, the brute-force lockout state is cleared (failed_login_count=0, locked_until=NULL), and every active session row for that user is revoked as a side effect (the audit trail records revoked_reason='password_change').
Omit both --password and --password-stdin to get an interactive prompt with hidden input — the right default for ad-hoc recovery from a host shell, and the only form that never leaks to shell history or ps.

crewship admin list-users

Dump every row in the users table, ordered by created_at ASC. Output columns: EMAIL, NAME, CREATED (created_at), LOCKED (derived from locked_until), FAILS (failed_login_count), ROLES (role@workspace-slug, comma-joined across workspaces).
The lockout column reflects the users.locked_until timestamp set by the failed-login throttle — list-users --locked-only followed by reset-password is the canonical “unlock this user” pair.

crewship admin promote

Grant a workspace role to an existing user. Roles ascend VIEWER < MEMBER < MANAGER < ADMIN < OWNER; the command updates the user’s existing row in workspace_members (the user must already be a member — a non-member errors with user is not a member of workspace).
This is the post-init step for bootstrapping a second admin: the first user from crewship init is OWNER automatically; everyone else starts as a regular member and needs admin promote to gain elevated access without going through the UI.

crewship admin invalidate-sessions

Force-logout every active session for one user without changing their password. The user can still sign in normally afterwards — they just have to re-authenticate on every device they were already on.
Each revoked row is stamped revoked_reason='admin_invalidate' so the audit trail distinguishes this from the side-effect revokes that fire during reset-password (those carry revoked_reason='password_change').
  • Laptop stolen / recovered, the password is believed safe but a cached cookie might still be on the device.
  • Suspected token leak via Slack screenshot, browser history dump, etc.
  • Periodic compliance sweep — “log everyone out of yesterday’s sessions”.

crewship admin sessions list

Forensic read of user_sessions for one user — mirrors crewship session list but for arbitrary users (the user-side command is self-only). Admin-only via direct DB access.
Output columns: ID, STATUS (derived from revoked_at + expires_at; revoked rows show revoked:<reason>), CREATED, LAST USED (last_used_at), EXPIRES, IP, UA (user_agent). Sorted created_at DESC. A typo’d email reports no user with email <addr> rather than silently returning zero rows. Pair with admin invalidate-sessions to act on what sessions list surfaces.

crewship admin prune-legacy

Removes orphaned pre-C1 (slug-only) crew docker resources — volumes and containers named <prefix>-{home,tools,team}-<slug> (e.g. crewship-3-tools-engineering) left over from before the C1 naming change (2026-06 audit, which re-keyed crew resources to include the crew id).
Unlike the rest of crewship admin, this command is HTTP-backed. The docker daemon lives behind the running server, not the local SQLite DB, so this command needs a reachable server and an authenticated session (crewship login) — it is not a host-only recovery command. It is filed here because it is an operator/admin maintenance action.

Why this exists

A legacy slug-only volume survives crewship seed --nuke: the nuke clears the database, and crew teardown removes the id-scoped volumes — but never the orphaned slug-only ones, because no crew row references them. While they exist, the runtime’s legacy-resource guard refuses to start the crew’s container, and the failure reaches users only as a generic “failed to start agent container”. Every agent in the affected crew fails; a seed --smoke-test reports N/N agents failed. crewship doctor surfaces this proactively: the legacy crew resources check calls the authenticated GET /api/v1/admin/legacy-resources endpoint and WARNs with this command as the remediation when orphaned resources are present. (Detection runs on this admin endpoint, not on the unauthenticated /healthz hot path, so a slow docker daemon can never stall health probes.) The prune is instance-wide: legacy docker names carry no workspace or crew id, so detection (what crewship doctor surfaces) and prune enumerate the same full crew set — otherwise the doctor could WARN on a slug the prune can never reach. It removes only the orphaned legacy names; the id-scoped resources the live runtime uses are excluded (slug/id-collision safe) and never touched. It reports each resource removed:
Requires the OWNER or ADMIN role. Returns 503 when the server’s container provider is not docker (nothing to prune).

crewship admin reap-orphan-containers

Finds — and, with --apply, reaps — crew containers orphaned by an internal-token master rotation. When crewshipd restarts with a different internal-token master while a per-crew agent container survives, that container keeps the crew-bound token (crwv1.<ws>.<crew>.<mac>) it was minted with, and the new process rejects it forever:
The container’s credential sync is silently and permanently broken while docker ps still shows it “healthy”, and it re-emits the warning every reap interval (log-noise that drowns real signal). This command clears those orphans.
HTTP-backed, like prune-legacy: the docker daemon lives behind the running server, so this needs a reachable server and an authenticated session (crewship login). Dry-run by default — nothing is removed unless you pass --apply.

Reading the result: “none found” vs “could not tell”

The detector compares the token fingerprint a container’s sidecar advertises on /health against the one the server would mint for that crew today. It fails safe: a container that advertises no fingerprint is never classified as an orphan, so a reap only ever removes something positively proven stale. That safety has a corollary — a sidecar too old to advertise token_fp at all makes the detector structurally unable to find anything, while still reporting zero orphans. So the sweep also reports its own coverage:
The third line is a third state, not a variant of the first. It is what you get on a workspace whose crews are all idle (and on a CI runner that has a docker daemon but never started a crew container): the sweep is clean in the sense that nothing was reaped, but it says nothing about whether the classifier would have got the answer right, because it never classified anything. An inert detector is a deploy defect, not a workspace condition: the slot’s sidecar binary was never re-pointed to the freshly built artifact on reconcile (#1390). Fix the slot’s sidecar, then re-run. Partial coverage (some sidecars silent) is reported as a count rather than rounded away. --format json carries inspected, identified and detector_inert. A server predating these fields omits them, and the CLI then falls back to the older, non-committal wording rather than inferring a coverage figure it wasn’t given.

One-time step after upgrading to the stable-master build

The stable internal-token master (#1385) stops future restarts from ever orphaning a container. But the deploy that first introduces it rotates the master once — from the old per-boot random to the value derived from ENCRYPTION_KEY. Any container that outlived that one deploy is orphaned exactly once and does not self-heal. After rolling out the new build, do one of:
  • Recreate the surviving containers — a normal redeploy / crew re-dispatch starts a fresh container that re-mints a valid token; or
  • Run crewship admin reap-orphan-containers --apply to stop+remove just the orphaned ones (the next dispatch to each crew recreates them fresh).
From then on the master is stable and no further action is needed unless you deliberately rotate CREWSHIP_INTERNAL_TOKEN or the ENCRYPTION_KEY it derives from.

How detection stays safe

For each of the workspace’s crews the command probes the running container’s sidecar /health for the token fingerprint it advertises (token_fp, #1385) and compares it against the fingerprint of the token the server would mint today. A container is listed only on a definite, non-empty mismatch — an unreachable sidecar, a pre-#1385 sidecar that reports no fingerprint, or an unconfigured master are all treated as unknown and are never reaped. A healthy container is never touched. Any agent inside an orphaned container is already broken (its credentials can’t sync), so recreation restores it rather than interrupting healthy work. Dry-run prints the orphans it found; --apply reports what it reaped:
Requires the OWNER or ADMIN role. Returns 503 when the server’s container provider is not docker.

See also

  • crewship init — first-user bootstrap on a fresh database. After init, admin promote is how you add a second OWNER.
  • crewship doctor — the legacy crew resources check WARNs when a prune is needed.
  • crewship session — user-scoped self-service of the same user_sessions table.
  • crewship doctor — adjacent host-side diagnostics (data dir, schema version, container runtime).

crewship admin health

Disk is the data directory’s volume — the one that fills in practice, since the database, agent outputs and logs all live under it. Where the platform cannot measure it, the row says so rather than printing a zero. Encryption key reports where ENCRYPTION_KEY came from. generated means Crewship auto-created it into <dataDir>/secrets.env, next to the database it protects — so a copied disk, a restored snapshot or a stray backup carries the ciphertext and what opens it. external means the operator injected it. Log level shows the live level, and says (temporary) when a timed override is in effect — a level that reverts on its own is a different fact from one someone set for good.

crewship admin stats

Counts are read against the ceiling the licence imposes on each: a bare “8 agents” is not something anyone acts on, “3 of 15 crews” is. Where the edition imposes no ceiling, the bare count is shown. If the licence cannot be read the counts still print — losing the ceilings must not cost you the numbers.

crewship admin gdpr

Answers the two requests a person can make about their own data.
Either a user id or an email is accepted; the email is resolved against the workspace roster, because an operator holding a request from a person has their address, not their cuid. --reason is not paperwork. It is the audit trail, and it is what answers “why was this person’s data removed” a year later — so delete refuses without it, and refuses again without --yes. Both commands write an append-only row and return an action id: that id is the receipt for the request you were answering, so record it against the request. export writes to stdout by default (pipe it, redirect it); --out writes a file with 0600, since the file is every row about a person.

crewship admin security-posture

Report how this instance is postured: encryption at rest, the private-egress ceiling, signup policy, rate limiting, and whether email/OAuth are configured.
Read-only by design. These are env-driven deploy decisions and are deliberately not settable from the app or from this command — the gap it closes is that their state was invisible without shell access to the box, which the person triaging an incident often doesn’t have.
No secret value is ever returned — only whether something is configured. That is the whole contract, and it’s what lets this be a plain admin GET with nothing worth stealing in the response. A regression test asserts no secret-shaped env value reaches the wire, including via a warning message.

What it reports

Each value is read through the same accessor the runtime enforces with, not re-parsed locally. A posture that parses a flag its own way eventually disagrees with the code that acts on it, and a security report that confidently states the wrong thing is worse than no report.

Intent vs. effect

The rate limiter is reported twice on purpose. In production the limiter runs regardless of the disable flag (MustNotDisableRateLimitInProd), so:
Reporting that as “disabled” would send you chasing an exposure that doesn’t exist; reporting it as plain “enabled” would hide that your config says otherwise and will bite the first time this config lands somewhere the guard doesn’t apply.

Warnings

Beyond the raw state, the report derives warnings that name the consequence:
  • plaintext_secrets_allowed (high) — anyone with the database file can read every stored secret.
  • encryption_key_missing (medium, high when combined with the above) — the combination means new credentials are being written in the clear.
  • rate_limit_disabled (medium) — credential-stuffing and the /credentials/test oracle are unthrottled.
  • rate_limit_disabled_ignored_in_prod (info) — a disable flag is set but the production guard keeps the limiter running. Not an exposure; it means your config and the running state disagree, which matters the first time that config lands somewhere the guard doesn’t apply.
  • signup_open (medium, high in production) — anyone who can reach the instance can create an account.
  • private_endpoints_ceiling_open (info) — crews that also opt in can reach RFC1918/loopback. Link-local and cloud-metadata stay blocked regardless.
The keys above read the environment. The rest read what the instance has actually become, which is the half no config file can tell you:
  • encryption_key_generated (medium) — Crewship bootstrapped the master key itself instead of an operator supplying one, so the key lives with the data it protects.
  • privileged_credentials_enabled (high) — at least one workspace has allow_privileged_credentials on, which removes the fail-closed boundary between privileged crews and stored secrets.
  • private_endpoints_in_use (medium) — crews have opted into private-endpoint egress and the instance ceiling is open, so the reach is live rather than merely permitted.
  • seed_account_default_password (high) — the crewship seed demo account still authenticates with the password printed in these docs.
  • no_backup_recorded (medium) — no backup.* action appears in the audit trail. Reported only when that query succeeds: a failed probe is unknown state, not a missing backup.
The same data backs the Admin → Runtime → Security posture card in the dashboard.

crewship admin memory-config

Read or adjust workspaces.memory_config — currently the retention window for memory_versions rows, consumed by the per-workspace retention sweep. The command also accepts the alias crewship admin memcfg.
The endpoint (GET/PATCH /api/v1/admin/memory/config) exists so retention can be changed without editing SQLite by hand. Until this command there was no client for it, so the hand-edit stayed the only route — which is what the endpoint was built to avoid.

memory-config get

Prints the resolved value and, importantly, whether it was chosen:
30 days on its own doesn’t tell you whether anyone picked it. That matters before changing it: overriding a built-in default is routine, overriding somebody’s deliberate policy is not. When the stored document carries keys this CLI version doesn’t model, they are printed verbatim underneath rather than hidden — otherwise get would look authoritative when it isn’t.

memory-config set --retention-days N

Writes are manage-tier and emit memory.config_updated to the journal, so a compliance audit can trace when retention policy changed and who changed it.
Rows already older than the new window become eligible for deletion on the retention sweep’s next pass. Shortening the window is not a preview — it schedules data loss.
Sent as a partial PATCH carrying only the key being changed. The server merges it into the stored document and preserves settings this CLI doesn’t know about, so an older binary can’t silently drop a newer knob by writing a whole document back. The same data backs the Admin → Runtime → Memory configuration card in the dashboard.

crewship admin ratelimits

Inspect and tune the instance’s rate limiters at runtime — the same surface as the dashboard’s Infrastructure → Rate Limiters tab. Every limiter that was once a hardcoded constant (the per-IP HTTP buckets, the login lockout, the notification anti-storm bucket, crew provisioning, agent webhooks) is listed and overridable without a redeploy. Aliases: crewship admin rate-limits and crewship admin ratelimit.
list shows each limiter’s group, key, current value, shipped default, unit, and whether the value is a default or an override:
set <key> <value> overrides a limiter; reset <key> reverts it to the shipped default. Overrides persist in the instance-global rate_limit_overrides table and take effect immediately — the per-IP HTTP buckets are retuned on the live limiter, the rest read their value on next use. Writes are manage-tier (OWNER/ADMIN) and logged at WARN with the actor. A value outside a limiter’s allowed range is rejected (400); an unknown key is 404.
Raising http.auth_per_min is the fix for an instance whose users get logged out under heavy refresh behind a shared NAT. On current builds the read-only session polls that caused that no longer touch this bucket, so most instances never need to.