# 3pm-backend route `nodeShell`

Paths are relative to the backend's origin (https://fleet.edgecr4ft.net, or your own 3pm-backend), not to this site. Index: https://3pm.edgecr4ft.net/docs/backend/api.md

---

tokens:
  claude: 6369
  o200k: 4065
# POST /api/buckets/{clusterId}/nodes/{nodeId}/shell (nodeShell)

Open an interactive login shell on the node (`3pm fleet ssh`), or run one command there (`command`, `3pm fleet ssh <node> -- <cmd>`): the answer is the call, whose `input.blob` you `PUT` keystrokes (or stdin) to and `output.blob` you `GET` the screen (or stdout and stderr) from; it replies `exited` when the shell or the command ends. Never from a page on another site.

- auth: an account — Authorization: Bearer 3pmu_<token>, or the dashboard's session cookie
- node: also open to a node's own bearer (3pma_…) when its row is granted fleet:invoke; that node's bucket only, and for the calls it placed itself
- body: NodeShellBody (JSON)
- answer: CallView (JSON)

## Details

The body: `term` (the caller's `$TERM`, 1 to 64 characters of `[A-Za-z0-9._+-]`, else 422 `ERR_UNPROCESSABLE`), `cols` and `rows` (clamped to 2..1000), and optionally `command` and `pty`. The shell is the login shell of the unix user the node's agent runs as, on a real pty of that size. Who may: the bucket's account always; a node's own bearer with `fleet:invoke` (else 403 `ERR_FORBIDDEN`) when the target's `callers` (`setNodeAccess`) is empty or holds a rule naming the action `3pm:shell` with `app` `-` for it — a wildcard action never reaches it — else 403 `ERR_SHELL_NOT_ALLOWED`. A `Bearer` from a page on another site is refused (403), unlike `nodeAction`. The answer is the `CallView` in `sent` state (409 `ERR_NODE_OFFLINE` when the node has no live session or this backend is stopping, 503 `ERR_NODE_BUSY` when its session is saturated, 429 `ERR_QUOTA` past the blob quotas): `input.blob` (`live: true`, no `bytes`) is the keyboard leg the caller `PUT`s as one streamed body, `output.blob` the screen leg it `GET`s; both are `pipe` blobs — lossless, one reader each, framed `live` messages carrying `attach` payloads (input, resize, ping one way; output, pong, exited the other) — that only the two ends of the call may touch, never through a `?k=`. While it runs, `live` on the call counts the bytes each way. The call stays `sent`/`acked` as long as the shell runs (a day at most) and survives the node's link reconnecting; it settles `replied` with `result.res` `exited` (`code` or `signal`; `ok` when it exited 0, else `err.code` `failed`), or with the agent's refusal (`err`: `forbidden` — no `node:shell` capability on the node, the caller's `cli.key` not in the node's own `authorized_keys` (the agent's code `key_not_authorized`, its message naming the refused fingerprint and the `3pm link authorize` to type on the node), or an agent too old to know the command —, `busy`). A session quiet for sixty seconds on either leg (the command line pings every 20 s), one whose reader went away, or a backend that stops, ends both legs: the shell is hung up. Audit, in the events store (`eventsQuery`, `kind: shell`, `call` = the call id): this backend writes `open_requested` when the call is placed (`refused`, with why and no `call`, for an attempt it turns away) and `ended` when it settles, however it settles; the node's agent adds `open`, `line` (each command line as the screen showed it, `[hidden]` when not echoed), `screen` (a full-screen program came or went) and `close`. Behind a reverse proxy the two legs are long streamed requests: buffering off and timeouts over a minute, or a quiet shell is cut. With `command` (its words: 1 to 256 of them, 16 KiB in all, no NUL, the first not empty, else 422 `ERR_UNPROCESSABLE`) the node runs that one command instead of a shell, through the same login shell (`sh -c`, the words quoted, one word taken as the whole line) — `exec` on the link, which an agent too old to know it refuses (`forbidden`). With `pty: true` it gets a pty of `term`, `cols` × `rows` and the legs carry what a shell's do; without, three pipes: its stdout comes back as `output` messages, its stderr as `stderr` (`0x1d`), and an `eof` (`0x05`) on the keyboard leg closes its stdin. The same permission, legs, quotas and end; `open_requested` (and `refused`) carry `command`, and the agent writes `exec` (the line the shell was given) after `open` — never the command's stdin.

## Types

```ts
/** `nodeShell`'s body: the terminal the shell is opened on, or the command run instead. */
interface NodeShellBody {
  /** `$TERM`: 1 to 64 characters of `[A-Za-z0-9._+-]`. */
  term: string;
  /** Columns and rows, clamped to what a pty takes (2 to 1000). */
  cols: number;
  rows: number;
  /** One command instead of a shell, its words (`3pm fleet ssh <node> -- <cmd>`): at most 256 words, 16 KiB in all, no NUL, the first not empty. */
  command?: string[];
  /** With `command`: run it on a pty of `term`, `cols` × `rows`; without, on pipes (stdout as `output`, stderr as `stderr`, stdin closed by `eof`). */
  pty?: boolean;
  /** The terminal of a `tty = true` app instead of a shell (`3pm fleet attach <node> <app>`); never with `command`. */
  attach?: NodeShellAttach;
  /** The unix account the shell or the command runs as (`--user`): `[a-z_][a-z0-9_-]{0,31}`; an agent that is not root takes only its own name. Never with `attach`. */
  user?: string;
  /** The caller takes its legs through its own agent's `data.sock`: when both nodes are LAN neighbours set to `direct: all` and the bucket does not record shells, the answer names the target (`peer`) and the legs may go agent to agent. */
  direct?: boolean;
  /** End-to-end encrypted (`3pm fleet ssh`, the default): the legs carry records sealed between the caller and the node, relayed and never read nor recorded here. 409 `ERR_NODE_NO_E2E` when the node's agent does not announce it. */
  e2e?: boolean;
  /** `true` only with `--record` (a current 3pm always says it): `false` leaves the session unrecorded even when the bucket records shells; absent (an older 3pm) is `true`. Only ever lowers `record_shells`. */
  record?: boolean;
}

/** One remote command, from `sent` to its end. */
interface CallView {
  call_id: string;
  node_id: NodeId;
  /** The `AgentRequest` sent. */
  request: CallRequest;
  state: CallState;
  /** Who placed it: `user:<id>`, or `node:<3pmn_…>/<app>` (`-` when the application was not named). */
  placed_by: string;
  /**
   * Once `replied`: for an action, `{ ok: true, res: "triggered", results: ActionOutcomeView[] }`;
   * for the rest, the agent's `AgentResponse` as is (`{ res: "pong", … }`, `{ res: "ok", affected }`, `{ res: "err", code, message }`).
   * `ok` says the action ran (`res === "triggered"`), never whether every instance succeeded — each outcome carries its own `ok`;
   * for a shell (`res: "exited"`, `code?`, `signal?`, `recording?` — the artifact the node uploaded its recording as, for an `e2e` session), that it exited 0.
   * A refusal (`res: "err"`) keeps the agent's own `code`, finer than `err.code`: `e2e_required` (the node serves no session in clear; `forbidden` in `err`), `key_not_authorized` (the caller's `cli.key` is not in the node's own `authorized_keys`, the message names the fingerprint; `forbidden` in `err`) and `e2e_broken` (the encrypted channel failed; `failed` in `err`).
   * `err` is set when the agent refused the call as a whole — `busy` (it already runs its most commands: retry), `timeout`, `forbidden`, `failed` — the same error the placing node's `call_result` carries.
   */
  result?:
    | { ok: boolean; res: "triggered"; results: ActionOutcomeView[] }
    | { ok: boolean; res: string; err?: { code: ActionErrCode; message: string }; [k: string]: unknown };
  sent_at: EpochMs;
  deadline_at?: EpochMs;
  acked_at?: EpochMs;
  replied_at?: EpochMs;
  /**
   * The blob to `PUT` the call's bytes to, when it carries any — the same thing `request.input` holds.
   * `bytes` is absent on a stream (`live: true`): a session has no length to declare.
   */
  input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean };
  /**
   * The blob the target writes its answer to, when one was asked for; `GET` it once the call is replied.
   * `bytes`, `sha256` and `ext` are absent until an outcome has claimed it: check the bytes you pull against `sha256`.
   */
  output?: { blob: string; bytes?: number; sha256?: string; ext?: string };
  /**
   * A stream's counters while it is relayed, and frozen at their last value once it has settled;
   * absent for a call that carries no stream, and gone once the relay has forgotten its blobs.
   */
  live?: LiveView;
  /** The files the call's actions left, once replied. */
  artifacts: ArtifactView[];
  /** The other end, when the two nodes are LAN neighbours: in the answer that placed the call only, for the caller's agent to move the bytes itself. */
  peer?: Peer;
  /** Which way the call's blob went, once its writer said: `direct` (agent to agent) or `relay`; absent, the relay. */
  path?: "direct" | "relay" | (string & {});
}

/** The app `nodeShell` attaches to, and how: `3pm attach`'s flags. */
interface NodeShellAttach {
  /** The app's name: 1 to 256 bytes, no control character. */
  app: string;
  /** Which instance of the app (0 when absent). */
  instance?: number;
  /** `rendered` (the daemon's screen, the default) or `raw` (the app's bytes). */
  mode?: 'rendered' | 'raw';
  /** Neither input nor resize. */
  read_only?: boolean;
  /** One `text` message of the screen, then `detached`. */
  peek?: boolean;
}

/** `3pmn_…` — a node. */
type NodeId = string;

/** The `AgentRequest` a call sent: a generic command, or the action `nodeAction` built. */
type CallRequest =
  | NodeCallBody
  | {
      op: "action"; target: NodeTarget; instance?: number; source?: string; action: string;
      params?: unknown; timeout_ms?: number;
      /** The input blob and what it holds, as the target's agent reads it. */
      input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean };
      /** The output blob the target writes its answer to. */
      output?: string;
      /** A session: `input` and `output` are its two `live` legs. */
      stream?: boolean;
    }
  | {
      /** `nodeShell`: `input` is the keyboard leg, `output` the screen leg (both `pipe`). */
      op: "shell"; term: string; cols: number; rows: number; input: string; output: string;
      /** Who placed it (`placed_by`), and that node's hostname. */
      from: string; from_hostname?: string;
      /** The legs are sealed end to end between the caller and the node: the backend relays bytes it cannot read. */
      e2e?: boolean;
      /** The node records the session's screen itself (an `e2e` session; `record_shells` on). */
      record?: boolean;
    }
  | {
      /** `nodeForward`: `host:port`, or (`accepted`) a connection a listen accepted, `host` empty and `port` 0. */
      op: "forward"; host: string; port: number; input: string; output: string;
      from: string; from_hostname?: string; e2e?: boolean; accepted?: ForwardAccepted;
    }
  | {
      /** `nodeListen`: `bind` empty is the node's loopback, `port` 0 one it picks. */
      op: "listen"; bind: string; port: number; input: string; output: string;
      from: string; from_hostname?: string; e2e?: boolean;
    };

type CallState = "sent" | "acked" | "replied" | "unacked" | "refused" | "lost" | (string & {});

/** What one instance answered to an action. */
interface ActionOutcomeView {
  proc_id: number;
  name: string;
  instance: number;
  source: string;
  action: string;
  duration_ms: number;
  ok: boolean;
  err?: { code: ActionErrCode; message: string };
  /** The handler's JSON (≤ 256 KiB); bigger, `data_artifact_id` names an artifact holding it. */
  data?: unknown;
  data_artifact_id?: string;
  /** The file the handler left, as an artifact. */
  artifact_id?: string;
  /** The output the handler pushed to the call's blob: where it is, how long, and its digest (the target's claim, never checked here: check it when you pull the blob). */
  output?: { blob: string; bytes: number; ext: string; sha256: string };
}

type ActionErrCode = "failed" | "timeout" | "busy" | "gone" | "refused" | "too_large" | (string & {});

/** Milliseconds since the Unix epoch. */
type EpochMs = number;

/** What a `live` session has relayed, counted in memory — never stored. */
interface LiveView {
  /** Bytes the caller has sent into the session (the input blob). */
  bytes_in: number;
  /** Bytes the target has sent back (the output blob). */
  bytes_out: number;
  /** Whole messages the window threw away with a reader still behind them, both ways together. */
  dropped_out: number;
  /** When the session's blobs were opened. */
  since: EpochMs;
  /** The answer leg goes agent to agent between LAN neighbours (both nodes `direct: "all"`): there is no preview to watch. A leg gone direct counts what its writer reports every 5 s. */
  direct?: boolean;
}

/** A file an action left: what `listArtifacts` lists and a call carries. */
interface ArtifactView {
  artifact_id: string;
  node_id: NodeId;
  call_id: string;
  app: string;
  instance: number;
  source: string;
  action: string;
  /** `cpuprofile`, `heapprofile`, `json`, …: what the file is; `artifactFile` serves gzip of it. */
  ext: string;
  bytes: number;
  /** The size of the stored file; equal to `bytes` when `encoding` is `identity`. */
  gz_bytes: number;
  sha256: string;
  /** `gzip` (what the agent sent) or `identity` (a call's output kept raw, by a backend that still copied them). */
  encoding: "gzip" | "identity" | (string & {});
  /** Who placed the call it came out of; empty once that call's row is past retention. */
  placed_by: string;
  /** A profile's hot frames, when it parsed. */
  summary?: ProfileSummary;
  created_at: EpochMs;
}

/**
 * The other end of a call whose two nodes are LAN neighbours: its node id, its Ed25519 public key
 * (base64url, the only key its TLS handshake may present) and the private addresses of its listener.
 */
interface Peer {
  node_id: NodeId;
  pubkey: string;
  addrs: string[];
}

/** `nodeCall`'s body: one of the link's generic `AgentRequest`s (an action goes through `nodeAction`). */
type NodeCallBody =
  | { op: "ping" }
  | { op: "restart"; target: NodeTarget }
  | { op: "stop"; target: NodeTarget };

type NodeTarget = { id: number } | { name: string };

/** A connection a listen accepted, as its `accepted` message numbered it. */
interface ForwardAccepted {
  /** The listen's call id. */
  listen: string;
  conn: number;
}

/** The hot frames of a sampling profile (`threepm_core::profile_summary`). */
interface ProfileSummary {
  kind: "cpu" | "heap";
  /** Microseconds (cpu) or bytes (heap) over every frame. */
  total: number;
  samples: number;
  duration_ms?: number;
  top: { function: string; location: string; self_value: number; percent: number }[];
}
```
