Skip to main content

crewship admin

cmd/crewship/cmd_admin.go exposes the operator-on-the-host recovery surface. The write subcommands under admin open the SQLite database file on this machine 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 those 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.

Which database am I talking to?

admin list-users reads the server the CLI targets. Everything else in the local-only group reads or writes the database file on this host. Those are two different things, and until #2086 the CLI conflated them: it resolved ~/.crewship/crewship.db and ignored --server / CREWSHIP_SERVER / --profile entirely, so pointing the CLI at a populated server and running admin list-users printed
and exited 0. The rule now:
  • list-users uses GET /api/v1/admin/users on the targeted server, scoped to the current workspace. It needs a login — always, not only when something names a server. It is the one verb here that is not gated: it branches on --local alone, so on a host with no CLI config and no CREWSHIP_SERVER it still goes to the server and still fails with “not logged in”, where the pre-#2086 build printed the table. Deliberate — a fallback to the local file on a failed login would answer the server’s question from a file that may belong to another instance. --local is the old behaviour, asked for explicitly, and both failure paths say so.
  • Every other local-only subcommand refuses to run when a server is named, unless you pass --local. The refusal names the server, names the file it would otherwise have touched, and names the flag. With nothing naming a server they run unchanged.
  • A server on localhost is not an exemption. crewshipd routinely runs with its own DATABASE_URL — every dev clone uses file:./crewship.db — so “same host” tells you nothing about “same database”.
The credential for the local-only commands is shell access to the host — there is no crewship login token. Requirements:
  • Read+write access to the database file: DATABASE_URL when set, otherwise <data dir>/crewship.db (default ~/.crewship).
  • The crewship binary must run on the same host as that file.
  • The host’s filesystem permissions are the authentication.
--local is what tells the CLI you mean that file rather than the server it is configured for. Every command prints the path it resolved.

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

Two different answers, deliberately: Default — the server. GET /api/v1/admin/users on the server the CLI targets, scoped to the current workspace (ADMIN+). Columns: EMAIL, NAME, CREATED, ROLE (role@workspace-slug). Requires a login. --local — the database file on this host. Every row in users ordered by created_at ASC, across every workspace. Columns: EMAIL, NAME, CREATED, LOCKED (derived from locked_until), FAILS (failed_login_count), ROLES (role@workspace-slug, comma-joined across workspaces).
Lockout state lives only in the database file: locked_until and failed_login_count are not fields on GET /api/v1/admin/users. So the canonical “unlock this user” pair is run on the database host — list-users --locked-only --local followed by reset-password --local. The server-backed listing prints a note saying as much, so a clean table is never mistaken for “nobody is locked out”. That note goes to stderr, along with the “which database” note on the --local path. Stdout carries the table and nothing else, so crewship admin list-users | awk 'NR>1 {print $1}' yields addresses. That holds for an empty workspace too: stdout is the header row alone and (no users in this workspace) is printed on stderr, so the same pipe yields nothing rather than a line of prose. (The --local listing keeps its own (no users — run 'crewship seed' …) on stdout — it is the example output shown in the admin CLI guide.)

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 not a duplicate of crewship workspace member role, which is the server-side way to do the same thing. That path enforces the role ladder against your role and needs a session — so it cannot mint the first OWNER, and it is unavailable to the operator who is locked out. When the CLI names a server, admin promote refuses and points at both options. 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 workspaces

GET /api/v1/admin/workspaces, scoped server-side to the workspace the CLI is currently authenticated against. Despite the plural name and the JSON array shape, this is not an instance-wide tenant listing — the handler filters WHERE w.id = <caller's workspace>, so it returns at most one row: the caller’s own workspace, with member/agent/crew counts. An empty array means the workspace context could not be resolved, not “no workspaces exist”. Requires OWNER or ADMIN (canRole "manage").

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 memory-stats

Companion read to memory-config: memory-config adjusts the retention window, memory-stats shows what is actually being retained under it.
GET /api/v1/admin/memory/stats, scoped to the current workspace. Three figures:
  • Totals — version count, byte sum, and distinct blob count (a stale row and a fresh row at the same content share one blob), plus the oldest/newest written_at in the window.
  • By tier — one row per memory tier (agent, crew, workspace, …); tiers with zero rows are omitted.
  • By agent — one row per agent slug that has written memory. Crew- and workspace-tier rows have no agent slug and are collapsed under (shared: crew/workspace tier).
Requires OWNER or ADMIN (canRole "manage").

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.

crewship admin seed-inbox

Fill a workspace’s inbox with one row of every kind — waitpoints, escalations, a failed run, messages, a memory proposal, schedule alerts — plus approval-queue rows both pending and decided. It exists because the inbox has no create endpoint: every row is written by a producer (a pipeline reaching a waitpoint, an agent escalating, the keeper sweeping), so a fresh workspace shows an empty inbox and there is no way to see how the views, facets and the reading pane behave against real variety. Rows go in through the same writers the real producers use, so what lands is shaped exactly like production data rather than hand-built SQL that agrees with whatever the UI happens to expect.
Fixture, not a runtime test. The run behind a seeded approval gate is a row, not an execution: no executor is parked on the waitpoint, so approving a seeded gate flips the token and cascades the inbox row and then nothing resumes.Use it to remove “is it seeded?” preconditions from a test, and to prove a button reaches its endpoint — never as evidence that a decision took effect. That is the approval-gate-demo routine’s job — crewship routine run approval-gate-demo parks a real run on a real gate, and approving it resumes the run.Seed while the server is running, too: a restart sweeps gates whose pipeline run is missing or terminal (CancelOrphanedWaitpoints), which settles the seeded ones out from under you.