Helper IPC protocol
K9k.app starts Contents/Resources/k9k-core as its child process. The app communicates on stdin/stdout with one UTF-8 JSON object per line. Stderr is reserved for diagnostics and never parsed as protocol data.
Every request and envelope has version: 1. Requests contain a caller-generated id, operation name, optional streamID, and structured parameters. Responses echo id; asynchronous events echo streamID. Errors use a stable code, human-readable message, and optional structured details.
Implemented v1 operations are health.ping, policy.readOnly, config.summary, config.document, config.write, context.list, context.inspect, context.select, context.update, context.rename, context.copy, context.delete, namespace.list, discovery.list, resource.list, resource.listPage, resource.get, rollout.history, helm.history, helm.inspect, helm.repository.inspect, helm.rollback, helm.uninstall, helm.upgrade.plan, helm.upgrade, relationships.get, manifest.get, manifest.diff, manifest.diffImported, manifest.apply, manifest.applyBatch, manifest.applyMixed, manifest.deleteBatch, resource.watch, stream.cancel, resource.patch, resource.scale, resource.delete, resource.events, metrics.list, node.drain, node.shell.resolve, pod.debug, cronjob.trigger, deployment.rollback, logs.open, exec.open, attach.open, exec.stdin, exec.resize, portforward.open, rbac.check, and rbac.effective. Generic resource operations carry group/version/resource plus explicit scope, namespace, name, label selector, field selector, and raw Kubernetes object information. Watches emit incremental resource.added, resource.modified, resource.deleted, and resource.watch.closed events.
resource.listPage is the bounded browser read path. It accepts normal resource scope and selector parameters plus limit (1–500, default 250), an opaque continue token, and at most 32 simple dot-path columns. Its { items, resourceVersion, continue?, remainingItemCount? } response deliberately omits each item's full raw object. Pass its resourceVersion to resource.watch; when compact: true, watch events use the same lean projection and the server emits bookmark revisions as resource.watch.bookmark. Retrieve the selected object's complete document with resource.get only when an inspector needs it.
rollout.history accepts an exact selected {group:"apps", version:"v1", resource:"deployments"|"statefulsets"|"daemonsets"|"replicasets", namespace, name, expectedUID}. The helper re-gets and UID-verifies that workload, then scans at most 500 namespace revision objects and returns at most 128 metadata-only entries. Deployment (and a Deployment-owned ReplicaSet) history consists of owned ReplicaSets with revision, activity, and rollback-candidate state. StatefulSet and DaemonSet history consists of owned ControllerRevisions and is read-only. No historical PodTemplate leaves the helper. A rollback candidate still requires the normal deployment.rollback confirmation, fresh deployments.patch access review, and helper-side ReplicaSet UID/inactive/owner validation.
Long-lived resource.watch, logs.open, exec.open, attach.open, and portforward.open streams share one helper-process budget of 64 active stream IDs. A duplicate ID remains stream_exists; a full budget returns the stable stream_limit error with { "limit": 64 } details and cancels the rejected context. stream.cancel, normal completion, and helper shutdown immediately release a slot.
logs.open requires streamID, namespace, and pod; container, previous, follow, and timestamps retain Kubernetes PodLogOptions semantics. History is bounded even for direct protocol callers: tailLines defaults to 500 and must be 1–10,000, sinceSeconds is optional and capped at 31 days, and limitBytes defaults to 8 MiB and cannot exceed 16 MiB. The server emits logs.data, then exactly one logs.closed; scanner failures also emit logs.error. This leaves the client free to follow or inspect a finite snapshot without accumulating an unbounded log buffer.
context.update requires {name, namespace, confirm:true} and persists only that kubeconfig context's default namespace through clientcmd.ModifyConfig. The helper never returns credential material and this operation cannot alter a cluster endpoint or AuthInfo reference.
context.inspect is read-only and accepts an optional context name (the active context is inspected when omitted). It returns the context's cluster and AuthInfo names, whether those named entries exist, and sorted context names sharing each reference, alongside structural diagnostics for missing references or malformed default namespaces. It never serializes the cluster server URL, TLS/certificate data, tokens, auth-provider or exec configuration, passwords, or any other kubeconfig entry content.
context.copy requires {source, newName, namespace, confirm:true}. It copies only the selected context's cluster/AuthInfo references and default namespace to a new context key through clientcmd.ModifyConfig; it never displays or changes endpoint, credential, or certificate data.
helm.history takes {namespace, release} and reads the active context's v1/secrets list with the standard owner=helm selector. It returns only the matching release's storage metadata: release name, namespace, newest-first revision number, Helm lifecycle status, Secret name, timestamp, and age. Results are capped at 128 revisions and flag truncated when additional matching history exists.
helm.inspect reads one already-discovered standard Helm v3 Secret revision without the Helm CLI. It requires {namespace, release, storageName, revision}. The helper performs a direct client-go Secret get, verifies its owner=helm, release name, storage name, and numeric version labels before it decodes the double-base64 Helm storage envelope; payload identity is then checked again. The normal response returns only projected Chart.yaml metadata. To return potentially sensitive rendered manifest, info.notes, and release config values, clients must send both includeSensitive:true and acknowledgeSensitive:true; otherwise the sensitive fields are absent. Decoded storage is capped at 4 MiB and each sensitive output field at 1 MiB with explicit truncation flags. It never returns raw Secret data, templates, hooks, or chart archive files, and does not support non-Secret Helm storage drivers.
helm.repository.inspect accepts only a user-selected repositories.yaml document (1 MiB maximum) and makes no network request. It returns sorted {name, url} entries only when their URLs are credential-free http/https endpoints, ignores every Helm credential/auth/TLS field, and rejects embedded URL userinfo or other schemes with warnings. It never reads registry.json, a Helm config directory, Keychain material, or credentials from the process environment. The native source inspector is a discovery aid: choosing a source does not authorize an unauthenticated remote fetch; use an explicitly obtained packaged chart for upgrade.
helm.rollback is a genuine Helm SDK action, never a handwritten Secret/storage edit or helm subprocess. It requires {namespace, release, targetStorageName, targetRevision, expectedStorageName, expectedRevision, confirm:true}. The target must be an earlier release revision. Before starting, K9k reloads Helm history and requires the exact reviewed current storage revision, verifies both opaque Secret identities, and performs SelfSubjectAccessReview preflights for the Helm Secret driver (get, list, create, update). A changed, pending, uninstalling, or uninstalled release returns stale_release. The Helm action retains hooks, creates Helm's normal next revision, bounds individual Kubernetes API calls to 45 seconds, does not wait for workload readiness, uses cleanup-on-failure, and retains at most 128 revisions. Chart-resource/hook authorization remains enforced by Kubernetes and is returned as an API error.
helm.uninstall is a genuine Helm SDK uninstall and requires {namespace, release, expectedStorageName, expectedRevision, confirmationText, confirm:true} where confirmationText exactly equals release. It performs the same exact-current identity and get/list/update Secret-driver RBAC preflight, then keeps hooks enabled, uses background deletion propagation, and does not wait for workload readiness. KeepHistory:true is fixed: K9k deletes Helm-managed resources but retains Helm's release history rather than purging storage Secrets. Both lifecycle actions are blocked by policy.readOnly before Kubernetes is contacted.
helm.upgrade.plan and helm.upgrade support explicitly selected local packaged .tgz charts only; K9k does not fetch HTTP/OCI repositories, run helm, or inherit a Helm repository credential store. Both require the exact reviewed current release storage {namespace, release, expectedStorageName, expectedRevision}, a base64 chartArchiveBase64 (8 MiB compressed maximum), optional bounded valuesYAML (1 MiB), and valuesMode (reset, reuse, or reset-then-reuse). The helper accepts only bounded gzip-tar regular files (2,048 files, 32 MiB total extracted, 4 MiB each), rejecting links, special files, unsafe paths, and archive bombs. Planning also requires acknowledgeSensitive:true, runs Helm's server-side dry-run, and returns bounded sensitive rendered output with an exact planDigest. A real upgrade requires that unchanged digest, confirm:true, and acknowledgement; it uses Helm SDK hooks, non-forced/nonatomic cleanup-on-failure, 45-second per-API-call bounds, and does not wait for rollout readiness. Both recheck release identity and Secret-driver get/list/create/update RBAC immediately before use and are rejected by read-only mode. Chart resource/hook RBAC remains enforced by Kubernetes. Non-Secret release storage and remote chart sources remain unsupported.
relationships.get takes the same resource identity as resource.get and returns one bounded read-only graph: its focal rootID, normalized nodes, directed owner/owns/selects/routes/uses edges, truncated, maxDepth, and non-fatal partial-snapshot warnings. It breadth-first resolves owner/reference and common workload/routing links for at most three hops in the focal namespace; it never lists arbitrary CRDs cluster-wide. It recognizes explicit ConfigMap/Secret/PVC/ServiceAccount, Service backend, HPA target, generic ObjectReference, and RBAC Role/ServiceAccount declarations in addition to owner refs. Candidate reads are paged and capped at 160 objects per supported kind (640 total); direct resolutions, nodes, and edges are capped at 48, 96, and 240 respectively. The native XRay sheet offers an opt-in 20-second bounded re-read only while it is visible; any cap is explicitly reported rather than silently represented as a complete topology.
config.summary is a read-only K9s compatibility operation. Its optional directory parameter selects a K9s configuration directory; without it the helper resolves K9S_CONFIG_DIR, then XDG_CONFIG_HOME/k9s, then ~/.config/k9s. Its response contains sorted aliases, hotkeys, plugins, views, and jumps, plus a files map with each expected YAML file's path, presence, and parse/read diagnostic. Missing optional files are successful empty configuration, while an invalid file does not prevent valid sibling files from being returned. The operation only parses plugin declarations; it never executes plugin commands.
policy.readOnly takes {enabled: Bool} and returns the effective {enabled} value. Enforcement is process-local, so the native client owns one app-wide desired policy generation and synchronizes it before every non-policy request on every primary or secondary helper. A fresh or relaunched helper therefore receives the current policy before its first ordinary operation. The helper rejects configuration/context writes, all Kubernetes mutations, and remote exec/attach/port-forward opens with read_only before reaching the API server. Listing, inspection, logs, cancellation, and disabling the policy remain available.
rbac.check is a non-mutating Kubernetes SelfSubjectAccessReview for the active kubeconfig identity. Parameters require verb and resource; group, version (default v1), subresource, namespace, and name are optional. A gvr string (v1/pods or apps/v1/deployments) is accepted in place of group/version/resource. The response is { "allowed", "denied", "reason", "evaluationError" }. It cannot inspect arbitrary users, groups, or service accounts, and a denied response is a result rather than a protocol error.
rbac.effective is a bounded, read-only static explanation for an explicitly selected User, Group, or { ServiceAccount, subjectNamespace }. It lists readable direct RoleBindings (all namespaces for User/Group unless bindingNamespace limits the read; exactly the ServiceAccount namespace otherwise) and ClusterRoleBindings which name that exact subject, then resolves their current Role/ClusterRole rules. It caps each binding inventory at 512 and distinct role references at 128. The response contains {subject, bindings, warnings, truncated}; inaccessible lists or roles become visible warnings instead of a false complete answer. It never impersonates, runs SubjectAccessReview for another identity, or mutates Kubernetes. It is not an authorization decision: external authorizers, admission policy, and User/Group membership are outside this declaration-only snapshot.
manifest.get is the safe read endpoint for the native YAML editor. It accepts the normal named resource parameters (gvr or group/version/resource, namespaced, namespace, and name) and returns { "identity", "yaml" }. yaml is canonical YAML for the selected object with status and API-server-managed metadata (uid, resourceVersion, managedFields, timestamps, and generation) removed. identity includes the immutable group/version/resource/scope/name/kind plus the selected object UID.
manifest.apply updates an existing object when supplied its selected resource identity, expectedUID, kind, and manifest YAML. Its YAML must preserve apiVersion, kind, name, and namespace and must omit status and server-managed metadata. A separate explicit creation mode accepts create: true; it derives name and namespace solely from the supplied YAML and rejects expectedUID, so importing a file can never accidentally weaken the selected-object guard. K9k always performs a direct server-side-apply dry run first (field manager k9k, never force). With confirm: false it returns { "validated": true, "applied": false, "manifest" } without mutation. With confirm: true it validates first, then performs the same server-side apply for real and returns applied: true. Before each existing-object phase the helper fetches the object and verifies the UID, so a deleted/recreated object cannot be modified by a stale editor. SSA ownership conflicts return manifest_conflict; stale identity returns identity_mismatch; admission/schema failures return manifest_validation_failed. No kubectl process is involved.
manifest.diff accepts the same selected-object identity and YAML fields as an update, but never writes. It fresh-reads the object and rejects a changed UID, then runs that exact YAML through non-forced server-side apply with DryRun=All. Its result contains canonical live and preview documents, a copyable unified diff, and deterministic structured { path, operation, live, preview } changes. Status and server-managed metadata are removed from both sides; lists are presented as a whole-field replacement because Kubernetes list merge behavior is schema-specific. It returns identity_mismatch, manifest_conflict, or manifest_validation_failed under the same rules as manifest.apply.
manifest.diffImported is the Manifest Workspace’s read-only single-document comparison path. It accepts only { "manifest" }, strict-parses exactly one document, resolves its exact GVR and scope through current discovery, and fetches the live object before extracting its UID. It then calls the same non-forced, UID-pinned SSA diff as manifest.diff. Consequently a local workspace neither guesses resource plurals nor supplies a stale identity; an unserved type, absent live object, or changed object produces a clear error and never writes.
manifest.applyBatch is the bounded (100-document) multi-document import path for one already-discovered GVR. It accepts kind, a YAML manifest stream, and confirm; each non-empty YAML document is strict-parsed, must match that GVR/kind/scope, and must have a unique namespace/name target. K9k dry-runs every document before any real apply. confirm: false returns the complete preview as { "validated": true, "applied": false, "items": [...] }; confirm: true then applies each exact document with non-forced SSA and returns items. Kubernetes cannot transact arbitrary resources: a later confirmed write may fail after an earlier write succeeds, so callers must report the partial outcome rather than implying an atomic directory apply. Existing selected-object edits still use manifest.apply and its UID guard.
manifest.applyMixed is the bounded (100-document) recursive-directory and pasted-stream import path. It accepts only { "manifest", "confirm" }: unlike the single-GVR endpoint, it does not trust caller-provided plural names, versions, or scope. The helper strict-parses every document, resolves its exact apiVersion/kind against the active cluster's discovery result, rejects unserved or ambiguous types and duplicate GVR/namespace/name targets, then dry-runs every resolved object before any confirmed server-side apply. It never forces ownership. As with manifest.applyBatch, confirmed mixed writes are explicitly non-transactional and a later API failure may leave earlier documents applied.
manifest.deleteBatch removes only the concrete {group, version, resource, namespaced, namespace, name, uid, kind} identities returned by a confirmed import, never targets inferred from a directory path or YAML. It is bounded to 100 unique targets. Before either a preview (confirm:false) or a confirmed delete, K9k performs a SelfSubjectAccessReview for every exact delete and fresh-reads every object to verify the complete GVR/scope/name/kind/UID identity set; no delete starts until that whole preflight passes. Each actual delete has a Kubernetes UID precondition, closing the delete/recreate race after preflight. A confirmed batch remains non-transactional: if a later delete fails, earlier objects may already have been removed and the error says so.
metrics.list reads Kubernetes' optional metrics.k8s.io/v1beta1 API directly through client-go—never through kubectl top. Parameters require resource (pods or nodes); version defaults to and currently only accepts v1beta1. For pods, namespace is optional (empty means all namespaces) and name optionally narrows the result to one pod. For nodes, namespace is invalid and name optionally narrows to one node. A successful response is { "apiVersion": "metrics.k8s.io/v1beta1", "resource", "items" }. Each item has apiVersion, resource, name, optional namespace, RFC 3339 timestamp, sample window, canonical Kubernetes-string usage quantities, and containers; pod usage is the aggregate of its container usage while containers is empty for nodes. When Metrics Server or its APIService is not installed/available, the helper responds with metrics_unavailable, rather than emitting zero or stale utilization. Authorization and other API failures remain kubernetes_error.
node.drain requires {node, ignoreDaemonSets:true, confirm:true} and accepts deleteEmptyDirData only as a deliberate opt-in. It requires the node to have already been cordoned, lists its scheduled Pods directly, leaves mirror and DaemonSet Pods in place, blocks emptyDir Pods until opted in, and requests policy/v1 evictions for every eligible Pod. It never force deletes; PodDisruptionBudget, grace-period, and RBAC failures are returned per-Pod in {evicted, skipped, blocked, failures}.
node.shell.resolve verifies a native Node Shell target without creating anything. It requires {node, namespace, daemonSet, container} and returns one exact {node, namespace, daemonSet, pod, container} only when the configured DaemonSet owns exactly one non-terminating Running Pod on that node and that Pod has the configured regular container. The helper rejects zero or multiple matches rather than guessing. The app re-resolves immediately before opening a normal SelfSubjectAccessReview-gated pods/exec SPDY transport, so a DaemonSet rollout cannot silently reuse the displayed target; K9k never infers an image or selector, mounts host paths, creates a debug shell Pod, or invokes kubectl.
cronjob.trigger requires {namespace, cronJob, confirm:true}. It reads the live batch/v1 CronJob and creates exactly one Job from its current job template, retaining template labels/annotations and a controller owner reference to the CronJob. The job name uses K9s-compatible -manual- suffixing and the helper accepts no caller-supplied job spec or name.
pod.debug requires {namespace, pod, image, confirm:true} and optionally takes targetContainer and command (default /bin/sh). It fetches the live Pod and updates only its ephemeralcontainers subresource with a newly named interactive container. The helper never force-deletes, changes the Pod template, or applies privileged/host settings; Kubernetes admission, image policy, Pod security, and pods/ephemeralcontainers authorization remain authoritative.
portforward.open creates one cancellable, direct client-go SPDY tunnel to a pod. It requires streamID, namespace, pod, and remotePort (1–65535); localPort defaults to 0, which asks the OS to choose an available port. localAddress defaults to 127.0.0.1; it may only be a loopback IP, so a forward is never exposed to the LAN. The success response is delayed until the tunnel and listener are ready and returns { "streamID", "status": "ready", "namespace", "pod", "localAddress", "localPort", "remotePort" }, with the actual selected local port. The stream also emits portforward.ready, then exactly one portforward.closed (completed, cancelled, or error); unexpected post-ready failures additionally emit portforward.error. Use the existing stream.cancel operation with the same stream ID to stop the listener and API-server connection.
exec.open starts a direct client-go pods/exec SPDY stream for an existing pod. It requires streamID, namespace, pod, and a nonempty command argv array; optional container selects a container. The command is sent as Kubernetes argv, never interpolated into a local shell or kubectl subprocess. tty defaults to true, as does stdin for TTY sessions; non-TTY sessions default stdin to false. TTY sessions accept optional initialColumns/initialRows (default 80×24). The initial response and exec.started event report the resolved request metadata. The helper emits zero or more exec.stdout and exec.stderr byte chunks as { "encoding": "base64", "dataBase64": "…" }; decode and append them directly to a terminal emulator. Kubernetes combines stderr into stdout when tty: true, so no stderr chunks are expected in that mode. Exactly one exec.closed event follows with reason (completed, cancelled, or error), successful commands include exitCode: 0, and failed remote commands also emit exec.error with the Kubernetes exit code when available.
deployment.rollback mirrors K9s' ReplicaSet rollback guard. It requires {namespace, replicaSet, expectedRSUID, confirm:true}. The helper reads the named ReplicaSet, rejects a UID mismatch or any ReplicaSet with live replicas, requires its controller owner to be an apps/v1 Deployment, then replaces that Deployment's complete PodTemplateSpec using typed client-go Update. This is deliberately not a merge patch: annotations or fields introduced after the selected revision are removed when absent from its template.
attach.open starts the same ordered VT-byte stream against Kubernetes pods/attach, attaching to a container's primary process without running a command. It requires streamID, namespace, and pod, with optional container, tty, stdin, and terminal dimensions. Its lifecycle and exec.stdin/exec.resize/stream.cancel semantics are identical to exec.open; authorization is evaluated against pods/attach rather than pods/exec.
While an exec stream is active, exec.stdin accepts one ordered input frame. Put its streamID in either the request or parameters, then supply exactly one of UTF-8 data or binary-safe dataBase64; frames are capped at 64 KiB and the bounded queue reports input_backpressure instead of growing memory without limit. exec.resize accepts streamID, positive columns, and positive rows; it is valid only for a TTY stream and coalesces rapid resize events to the latest size. stream.cancel closes the Kubernetes connection, stdin, and resize queue. Missing/finished IDs return stream_not_found or stream_closed rather than accidentally writing to another session.
The helper owns client-go's kubeconfig merge/auth behavior and Kubernetes network I/O. Swift owns process supervision, request correlation, main-actor state, and presentation. Closing the app terminates the child; changing context or resource scope cancels the preceding watch.