feat(worker): attach server-defined credentials to terminal sessions #350

Merged
rwachters merged 13 commits from feat/terminal-session-credential-attach into master 2026-10-24 22:31:56 +00:00
rwachters commented 2026-10-24 22:30:09 +00:00 (Migrated from github.com)

Lets an LLM attach an operator-defined credential profile to an existing terminal_session, so an interactive ssh, git or password-prompting command can authenticate without the model ever seeing the secret. A profile fixes the command, its arguments and how the secret reaches the process; the model can only name a profile. The worker runs the profile as a second, dedicated operating-system account (vault), decrypts the secret only for that process, and sweeps the material when the session returns to its shell. The branch also carries two follow-ups required for this to be safe: the worker records the numeric worker id the server assigned at registration, and the wildcard environment passthrough into isolated children was removed.

Changes

  • common: the credential profile, secret and write-result DTOs with their channel rules and limits, the two new resources, six protocol command types with their payloads and mappings, and the attach/detach actions on the terminal_session tool schema.
  • server: migration V37 with profile, secret and per-device signature tables; DAOs, entities and mappers; an encryption service that wraps a random data key to the worker certificate; CRUD services behind user JWT; a dispatch service that pushes each write to the worker best-effort after commit; the worker bootstrap endpoint; and a generated credential_profiles instruction that advertises the profiles of the workers a role's terminal_session tools target.
  • app: a Credentials settings tab (profiles, secrets, worker picker, masked secret input with a show/hide toggle), its repository, signing API client and view model, with a visible warning when a save could not be pushed to a worker; and rendering of the new instruction kind.
  • worker: an in-memory credential cache filled at bootstrap and kept current over the protocol; a signature-verifying profile validator; lazy decryption bound to the worker certificate; a helper-mediated per-attach secret tree; askpass, env and file delivery; the vault account, its helper and the shared privilege-drop header; the attach/detach state machine in the terminal session; the worker-id pin written by setup; and attach state reported in the tool output so the model can see the transition and the profile exit code.
  • deploy/worker: run-as-vault.c, privilege_drop.h, the askpass helper, the vault account and secret root in the image, entrypoint remapping and collision checks, tmpfs and stop-grace in the compose examples, and README documentation.

Implementation notes

  • Secrets are encrypted on the server to the worker's certificate (RSA-OAEP-256 wrapping an AES-256-GCM data key) and only that worker can decrypt them; the server keeps ciphertext it cannot read and never logs a value.
  • The vault account is the isolation boundary. It must differ from both the worker account and the agent account, is checked at startup by what the helper reports rather than by name, and the helpers are root:torvian 04750 so an agent-driven session cannot execute them. The vault child never receives the worker private key, the worker environment or the workspace as its working directory.
  • A profile's command and arguments are server-defined and signature-verified per user device; a secret payload is deliberately unsigned, because the server is the only party that can produce the ciphertext and the worker has no second source to compare it against. A compromised server could therefore substitute a chosen secret into an approved profile — accepted, and recorded as future work.
  • Credential writes use persist-then-dispatch, not MCP's compensating delete: a failed or rejected push returns the persisted write with a classified failure so a secret is never lost to a transient disconnect, which also makes a server-first rollout possible.
  • The wildcard "*" entry is gone from commandIsolation.environment.allowPassthrough, and a name carrying the worker's own private key or setup password is refused in both passthrough fields. This is a breaking configuration change: an operator who relied on "*" will now get a startup error and must list the names explicitly. It needs a release note.
  • Migration V37 only adds tables, so a downgrade leaves them unused; worker_credential_profile.secret_ref has no foreign key on purpose, and the SQL says why.
  • A pure PemPrivateKeyLoader extraction travels with the decryption change, because the challenge signer and the decryptor are its two consumers.

Validation

  • ./gradlew :common:desktopTest — 231 tests, 0 failures.
  • ./gradlew :worker:test — 792 tests, 4 skipped, 0 failures.
  • ./gradlew :server:test — 2420 tests, 1 skipped, 0 failures.
  • ./gradlew :app:desktopTest — 1120 tests, 2 skipped, 0 failures.
  • The worker image builds successfully, including gcc -O2 -Wall for run-as-agent and run-as-vault, so the C helpers compile and link.
  • Manual end-to-end verification against a built image was performed by the maintainer: a credential profile was attached to a live terminal session, ran as the vault account, received its secret through the helper-mediated secret tree, exited with a non-zero code, and the session returned to its shell with the material released.

What is still not covered:

  • The three real-helper unit tests are gated on a Linux host with the helpers installed and skip in CI, so the helper code paths have no executed automated coverage in this branch.
  • The manual Linux matrix in deploy/worker/README.md has not been run in full: it still covers run-as-vault id -un versus run-as-agent id -un, an agent session being denied execution of the helpers, tmpfs behaviour across a restart, and shutdown cleanup under a stopped worker.
  • The askpass and file channels were not separately exercised end-to-end in a manual run; only their unit-level behaviour is covered.
  • The wrong-pin crash loop and the 409 re-setup path are untested outside unit tests.

Risks and follow-up

  • An operator who relied on "*" in allowPassthrough will now get a startup error; this is intentional and needs a release note.
  • A profile edit may not reach a live worker until it reconnects; the app says so rather than claiming delivery.
  • A worker that does not yet understand the new command types rejects the push; the server tolerates that and reports it, so a server-first rollout is safe. A worker upgraded before its server will not open a session at all, so the server must be rolled out first.
  • Rolling a worker back to a pre-v2 build leaves stored ciphertext unreadable until it is upgraded again; re-entering secrets is not required because the ciphertext is bound to the certificate, which a rollback does not change.
  • The attach state is reported in the tool's output text because the platform drops a built-in tool's structured details before they reach the model; persisting details is separate future work.
Lets an LLM attach an operator-defined credential profile to an existing `terminal_session`, so an interactive `ssh`, `git` or password-prompting command can authenticate without the model ever seeing the secret. A profile fixes the command, its arguments and how the secret reaches the process; the model can only name a profile. The worker runs the profile as a second, dedicated operating-system account (`vault`), decrypts the secret only for that process, and sweeps the material when the session returns to its shell. The branch also carries two follow-ups required for this to be safe: the worker records the numeric worker id the server assigned at registration, and the wildcard environment passthrough into isolated children was removed. ## Changes - `common`: the credential profile, secret and write-result DTOs with their channel rules and limits, the two new resources, six protocol command types with their payloads and mappings, and the `attach`/`detach` actions on the `terminal_session` tool schema. - `server`: migration `V37` with profile, secret and per-device signature tables; DAOs, entities and mappers; an encryption service that wraps a random data key to the worker certificate; CRUD services behind user JWT; a dispatch service that pushes each write to the worker best-effort after commit; the worker bootstrap endpoint; and a generated `credential_profiles` instruction that advertises the profiles of the workers a role's `terminal_session` tools target. - `app`: a Credentials settings tab (profiles, secrets, worker picker, masked secret input with a show/hide toggle), its repository, signing API client and view model, with a visible warning when a save could not be pushed to a worker; and rendering of the new instruction kind. - `worker`: an in-memory credential cache filled at bootstrap and kept current over the protocol; a signature-verifying profile validator; lazy decryption bound to the worker certificate; a helper-mediated per-attach secret tree; askpass, env and file delivery; the vault account, its helper and the shared privilege-drop header; the attach/detach state machine in the terminal session; the worker-id pin written by setup; and attach state reported in the tool output so the model can see the transition and the profile exit code. - `deploy/worker`: `run-as-vault.c`, `privilege_drop.h`, the askpass helper, the `vault` account and secret root in the image, entrypoint remapping and collision checks, tmpfs and stop-grace in the compose examples, and README documentation. ## Implementation notes - Secrets are encrypted on the server to the worker's certificate (RSA-OAEP-256 wrapping an AES-256-GCM data key) and only that worker can decrypt them; the server keeps ciphertext it cannot read and never logs a value. - The `vault` account is the isolation boundary. It must differ from both the worker account and the `agent` account, is checked at startup by what the helper reports rather than by name, and the helpers are `root:torvian 04750` so an `agent`-driven session cannot execute them. The vault child never receives the worker private key, the worker environment or the workspace as its working directory. - A profile's command and arguments are server-defined and signature-verified per user device; a secret payload is deliberately **unsigned**, because the server is the only party that can produce the ciphertext and the worker has no second source to compare it against. A compromised server could therefore substitute a chosen secret into an approved profile — accepted, and recorded as future work. - Credential writes use persist-then-dispatch, not MCP's compensating delete: a failed or rejected push returns the persisted write with a classified failure so a secret is never lost to a transient disconnect, which also makes a server-first rollout possible. - The wildcard `"*"` entry is gone from `commandIsolation.environment.allowPassthrough`, and a name carrying the worker's own private key or setup password is refused in both passthrough fields. **This is a breaking configuration change**: an operator who relied on `"*"` will now get a startup error and must list the names explicitly. It needs a release note. - Migration `V37` only adds tables, so a downgrade leaves them unused; `worker_credential_profile.secret_ref` has no foreign key on purpose, and the SQL says why. - A pure `PemPrivateKeyLoader` extraction travels with the decryption change, because the challenge signer and the decryptor are its two consumers. ## Validation - `./gradlew :common:desktopTest` — 231 tests, 0 failures. - `./gradlew :worker:test` — 792 tests, 4 skipped, 0 failures. - `./gradlew :server:test` — 2420 tests, 1 skipped, 0 failures. - `./gradlew :app:desktopTest` — 1120 tests, 2 skipped, 0 failures. - The worker image builds successfully, including `gcc -O2 -Wall` for `run-as-agent` and `run-as-vault`, so the C helpers compile and link. - Manual end-to-end verification against a built image was performed by the maintainer: a credential profile was attached to a live terminal session, ran as the `vault` account, received its secret through the helper-mediated secret tree, exited with a non-zero code, and the session returned to its shell with the material released. What is still not covered: - The three real-helper unit tests are gated on a Linux host with the helpers installed and skip in CI, so the helper code paths have no executed *automated* coverage in this branch. - The manual Linux matrix in `deploy/worker/README.md` has not been run in full: it still covers `run-as-vault id -un` versus `run-as-agent id -un`, an `agent` session being denied execution of the helpers, tmpfs behaviour across a restart, and shutdown cleanup under a stopped worker. - The askpass and file channels were not separately exercised end-to-end in a manual run; only their unit-level behaviour is covered. - The wrong-pin crash loop and the `409` re-setup path are untested outside unit tests. ## Risks and follow-up - An operator who relied on `"*"` in `allowPassthrough` will now get a startup error; this is intentional and needs a release note. - A profile edit may not reach a live worker until it reconnects; the app says so rather than claiming delivery. - A worker that does not yet understand the new command types rejects the push; the server tolerates that and reports it, so a server-first rollout is safe. A worker upgraded before its server will not open a session at all, so the server must be rolled out first. - Rolling a worker back to a pre-v2 build leaves stored ciphertext unreadable until it is upgraded again; re-entering secrets is not required because the ciphertext is bound to the certificate, which a rollback does not change. - The attach state is reported in the tool's output text because the platform drops a built-in tool's structured `details` before they reach the model; persisting `details` is separate future work.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Torvian.eu/chatbot!350
No description provided.