jackin-exec: Design Rationale and Implementation Record
Status: Implemented — this is the design record for the shipped jackin-exec on-demand credential mechanism; see jackin-exec: Secure Execution Wrapper for current roadmap status and remaining work.
What This Is
jackin-exec is a subcommand of jackin-capsule. The agent calls jackin-exec ssh sentry; under the hood this is jackin-capsule exec ssh sentry. Before the command runs, the operator sees a picker dialog in the TUI listing every on_demand = true env var configured for that workspace. The operator selects which credentials to inject, presses Enter, the host resolves them via op read, and the command runs with those values injected ephemerally. Raw credential values never appear in the agent's process environment.
jackin-exec is a symlink to jackin-capsule — no separate binary, no separate release artifact, no separate build job.
Works on top of any jackin❯ backend. Does not require the Apple Container backend to land first. See Agent isolation architecture for the complete picture.
The Problem
Current jackin❯ model injects all credentials as container env vars at docker run time. Every process inside the container can call printenv and see raw API keys. The operator wants credentials available to commands without being permanently visible to the agent. See Container Credential Exposure — Beyond Env Injection for the full exposure-surface analysis this item is one mitigation for.
Two Tiers — Same env Map, One New Flag
The on_demand flag extends the existing EnvValue enum. No new sections, no migration required for existing configs. The flag simply changes when and whether a value is injected.
Tier 1 — always-available (default, unchanged)
[env]
PUBLIC_ENDPOINT = "https://api.example.com" # plain literal
BUILD_ENV = "${BUILD_ENV}" # host env expansion
ADMIN_KEY = { op = "op://abc.../def.../fld...", path = "Work/Admin/key" } # 1PasswordInjected into the container at docker run time. Agent sees via printenv. Current behavior.
Tier 2 — on-demand
Add on_demand = true to any env value. The env var is not injected at launch, does not appear in printenv, and is only materialized during a jackin-capsule exec call when the operator explicitly selects it in the picker.
[env]
# Always-available (unchanged):
PUBLIC_ENDPOINT = "https://api.example.com"
ADMIN_KEY = { op = "op://abc.../def.../fld...", path = "Work/Admin/key" }
# On-demand — not in container env, only via picker:
API_KEY = { op = "op://abc.../def.../fld...", path = "Work/Anthropic/api key", on_demand = true }
SSH_KEY = { op = "op://abc.../def.../fld...", path = "Personal/SSH/key", on_demand = true }
GH_TOKEN = { value = "$GH_TOKEN", on_demand = true }
STG_URL = { value = "https://staging.internal", on_demand = true }Toggle behavior: when the operator sets on_demand = false (or removes it) via the TUI config editor, jackin❯ serializes back to the compact plain form:
{ value = "https://staging.internal", on_demand = false }→"https://staging.internal"{ op = "...", path = "...", on_demand = false }→{ op = "...", path = "..." }
When toggled to on_demand = true, jackin❯ serializes to the table form. This conversion happens in the config editor only — no data migration required.
Rust Type Changes
All type changes live in crates/jackin-core/src/env_value.rs.
1. New Extended variant in EnvValue
#[serde(untagged)]
pub enum EnvValue {
OpRef(OpRef), // discriminated by `op` field — must stay first
Extended(Extended), // NEW — discriminated by `value` field
Plain(String), // scalar fallback — must stay last
}#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Extended {
pub value: String,
#[serde(default)]
pub on_demand: bool,
}fn default_on_demand() -> bool { false }Serde untagged discrimination order:
OpRefhas#[serde(deny_unknown_fields)]— rejects{ value = ... }(unknown field) → falls throughExtendedhas#[serde(deny_unknown_fields)]— rejects anything withoutvaluefield → falls throughPlain(String)— scalar fallback
{ value = "..." } without on_demand defaults to false; on-demand values must opt in explicitly.
Exhaustive match methods — must add Extended arm to all of them:
// as_persisted_str(): returns the unexpanded source string
EnvValue::Extended(e) => e.value.as_str(),
// as_display_str(): same — display the value string
EnvValue::Extended(e) => e.value.as_str(),resolve_env_value launch-time behavior — new arm:
EnvValue::Extended(e) => {
if e.on_demand {
// on_demand=true: never resolved at launch; filtered out before
// container env injection (see is_on_demand() below).
// This arm should not be reached during normal launch.
Err(anyhow::anyhow!("{layer_label} env var {var_name:?}: on_demand var reached resolve_env_value — this is a bug"))
} else {
// on_demand=false: treat as Plain
dispatch_plain(layer_label, var_name, &e.value, host_env)
}
}Method on EnvValue — used by launch to skip on-demand vars:
impl EnvValue {
/// Returns true if this env var should NOT be injected at container launch.
/// on_demand vars are resolved at exec time via jackin-capsule exec instead.
pub fn is_on_demand(&self) -> bool {
match self {
Self::OpRef(r) => r.on_demand,
Self::Extended(e) => e.on_demand,
Self::Plain(_) => false,
}
}
}In crates/jackin-env/src/resolve.rs, launch-time env resolution skips on_demand vars before container env injection:
// Before building -e flags, filter out on_demand vars:
let launch_env = resolved_env.vars.iter()
.filter(|(_, v)| !v.is_on_demand())
// ... existing rest of loopOn-demand vars are still read from the workspace config (to build JACKIN_EXEC_BINDINGS), but they are not passed as -e flags to docker run.
Extended { on_demand: false } runtime behavior: treated as always-available, resolves via dispatch_plain at launch. Config editor prevents this state by converting back to Plain(String) when toggling off, but the runtime arm above handles it defensively.
2. Add on_demand to OpRef
pub struct OpRef {
pub op: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
// NEW:
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub on_demand: bool, // false = always-available (current behavior), true = on-demand
}skip_serializing_if = "std::ops::Not::not" means: omit from TOML when false. Existing OpRef tables without on_demand parse with on_demand = false — no change to behavior. Only when on_demand = true does the field appear in the serialized TOML.
The Picker Dialog
Every jackin-exec call triggers the picker. No pre-selection, no patterns. All on_demand = true vars appear. Operator picks what they need, presses Enter.
┌─ Select credentials to attach ────────────────────────────────────┐
│ Command: ssh sentry │
│ │
│ [ ] API_KEY Work/Anthropic/api key (1Password) │
│ [ ] SSH_KEY Personal/SSH/key (1Password) │
│ [ ] GH_TOKEN host env: $GH_TOKEN │
│ [ ] STG_URL https://staging.internal (literal) │
│ │
│ Space: toggle ↑↓: navigate Enter: execute Esc: cancel │
└────────────────────────────────────────────────────────────────────┘OpRef values show path (human-readable display). Extended values show their value string. No credential values are shown — display is always the label or source, not the resolved secret.
After Enter: capsule sends the selected refs to the host process → host resolves via op read for each selected 1Password ref, or reads host env for $VAR refs → values sent back to capsule → injected as env vars on the spawned command's CommandBuilder → command runs → output scanned for leaked values → process exits (env vars die with it, no explicit cleanup needed).
Credential injection mechanism (Phase 1): All resolved values are injected as environment variables on the CommandBuilder for the spawned command — using the binding name as the key (e.g., GH_TOKEN=resolved_value, AWS_ACCESS_KEY_ID=resolved_value). The command inherits only these specific injected vars plus its standard env. This works for token-style credentials (GitHub, AWS, API keys). Known Phase 1 limitation: raw private key strings (SSH private keys from op read) cannot be consumed directly by the ssh binary via an env var. Phase 1 documents this: if an operator selects an SSH key credential, it is injected as SSH_KEY=<pem_content>. The agent must write it to a temp file itself or use GIT_SSH_COMMAND. Temp-file materialization for key-type credentials (write to /jackin/run/secrets/<uuid>, set SSH_KEY_FILE=/jackin/run/secrets/<uuid>, delete after command exits) remains open — see the roadmap item's remaining work.
Edge cases:
- No on-demand vars configured (
JACKIN_EXEC_BINDINGSis empty):jackin-execruns the command immediately without showing the picker. No dialog, no delay. - host.sock unavailable (host process died or listener failed): capsule returns
ExecDenied { reason: "host credential resolver unavailable" }. Agent sees the error message. No silent failure. op readfails (1Password auth expired, item not found): host.sock returns an error for that specific ref. Capsule returnsExecDenied { reason: "credential resolution failed: <op error message>" }.opTouch ID / biometric prompt: blocks the host.sock listener task until the OS prompt completes. This is expected and intentional — the user explicitly wanted the 1Password window to appear on their macOS host.
Host Process Callback (Phase 1 Design)
On-demand credentials are resolved at exec time, not at container launch. Capsule (inside the container) calls back to the host to resolve credentials.
host.sock Protocol
Length-prefixed JSON (same framing as the control channel: [4-byte BE length][JSON body]). One request/response exchange per jackin-exec invocation.
Request (capsule → host):
{
"refs": [
{ "name": "GH_TOKEN", "kind": "op", "source": "op://uuid/uuid/uuid" },
{ "name": "SSH_KEY", "kind": "op", "source": "op://uuid/uuid/uuid" },
{ "name": "BUILD_ENV", "kind": "env", "source": "$BUILD_ENV" },
{ "name": "STG_URL", "kind": "literal", "source": "https://staging.internal" }
]
}kind: "op" → call op read <source>; "env" → read host env var from source; "literal" → return source verbatim (no host call needed).
Response (host → capsule):
{
"values": {
"GH_TOKEN": "ghp_xxxx",
"SSH_KEY": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
"BUILD_ENV": "production",
"STG_URL": "https://staging.internal"
}
}Error response:
{ "error": "op read failed for GH_TOKEN: biometric auth cancelled" }On error, capsule sends ExecDenied to the agent — no command is executed.
Phase 1 mechanism: jackin load host process stays running for the lifetime of the session. After the role container starts, the host immediately attaches to it through the selected backend, which blocks for the interactive session. The host.sock listener runs as a separate task alongside that attach call. The socket path ~/.jackin/sockets/<container>/host.sock is bind-mounted into the container at /jackin/run/host.sock for Docker and passed through the apple-container launch path when that backend is selected. When capsule sends a resolution request, the host task reads it, runs op read for each 1Password ref, reads host env vars, writes values back. Capsule injects them ephemerally and executes.
This is the Phase 1 ad-hoc design. The host process listening on a socket is not a daemon — it is the jackin load process staying alive. Future migration: the jackin❯ daemon (Host bridge, jackin❯ daemon) will handle all credential resolution requests from all running containers. The per-process host.sock approach will be retired when the daemon ships.
Boundary caveat. The picker is the operator-facing approval, enforced daemon-side. The host resolver on host.sock enforces an allow-list — it resolves only the operator-configured on-demand bindings, never arbitrary refs — but it does not yet enforce per-use approval: /jackin/run/host.sock is reachable by any in-container process, so a compromised agent could connect directly and resolve the configured set without a picker. Binding the socket to the daemon (SO_PEERCRED / a one-time per-confirm token) is remaining hardening tracked on the roadmap item; the future jackin❯ daemon that replaces this ad-hoc listener subsumes it.
Binary: Symlink + Subcommand
jackin-exec is handled by jackin-capsule argv dispatch. The symlink/install surface is part of the capsule runtime setup rather than a separate binary or release artifact.
When capsule is invoked as jackin-exec (argv[0] check) or with exec as first arg, it routes to the exec subcommand. The dispatch in main.rs (after the PID-1 and prepare-commit-msg checks):
Some("exec") | None if invoked_as_jackin_exec(&args) => exec::run(&args).await,invoked_as_jackin_exec function body (follows the pattern of invoked_as_prepare_commit_msg_hook):
fn invoked_as_jackin_exec(args: &[String]) -> bool {
args.first()
.and_then(|a| std::path::Path::new(a).file_name())
.is_some_and(|n| n == "jackin-exec")
}The None branch (no subcommand, but invoked as jackin-exec) makes jackin-exec <command> [args...] work — args[0] is jackin-exec, remaining args are the command.
Control protocol variants in crates/jackin-protocol/src/control.rs:
ClientMsg and ServerMsg both have an #[serde(other)] Unknown sink variant that must remain last. New variants were inserted before Unknown:
// In ClientMsg — added before Unknown:
ExecCommand { command: String, args: Vec<String> }
// In ServerMsg — added before Unknown:
ExecResult { exit_code: i32, stdout: String, stderr: String, redacted_count: u32 }
ExecDenied { reason: String }stdout/stderr are lossy UTF-8 strings. Capsule caps each stream and redacts selected secret values before returning the result.
Output Filtering
All resolved credential values (returned from op read or host env) are added to the redaction set for that command's output. Capsule scans stdout + stderr before returning to the agent:
- Exact value match →
[redacted by jackin❯] - PEM blocks →
[key material redacted by jackin❯]
Full unredacted output logged to diagnostics run file under JACKIN_DEBUG.
How the Agent Learns to Use jackin-exec
JACKIN_EXEC_BINDINGS is injected as an always-available container env var at launch — listing the names of all on-demand vars (not their values):
JACKIN_EXEC_BINDINGS=API_KEY,SSH_KEY,GH_TOKEN,STG_URLThe entrypoint (docker/runtime/entrypoint.sh) uses this to append a system prompt block for every agent runtime. Also exposed as a jackin_exec MCP tool for Claude Code / Codex, registered only when JACKIN_EXEC_BINDINGS is non-empty.
Schema Migration Footprint
| Surface | File kind | Type touched | Action |
|---|---|---|---|
EnvValue::Extended (new variant) + OpRef.on_demand (new field) | ~/.config/jackin/workspaces/<name>.toml | WorkspaceConfig → EnvValue | carried by the current workspace migration chain, landing at v1alpha8 with fixtures through crates/jackin/tests/fixtures/migrations/workspace/from-v1alpha7/meta.toml |
Both changes are additive with serde defaults. Existing TOML files parse identically.
Implementation Phases (historical record)
Phase 1 — exec subcommand + picker + host.sock resolution
Source files:
-
crates/jackin-capsule/src/exec.rs— exec client: argv[0] detection, connect to control channel, send ExecCommand, receive result -
crates/jackin-runtime/src/exec_host.rs— host.sock listener: stays alive for session duration, runsop read, responds to capsule credential requests -
crates/jackin-core/src/env_value.rs—Extendedvariant onEnvValue;on_demand: boolonOpRef -
crates/jackin-capsule/src/main.rs—execsubcommand dispatch + argv[0] detection -
crates/jackin-protocol/src/control.rs—ExecCommand,ExecResult,ExecDenied -
crates/jackin-capsule/src/daemon.rs— dispatchesExecCommand; pushesDialog::ExecPicker(ExecPickerState)onto the daemon'sdialog_stack.ExecPickerStateshape:pub struct ExecPickerState { pub command: String, pub args: Vec<String>, pub items: Vec<ExecPickerItem>, pub cursor: usize, } pub struct ExecPickerItem { pub name: String, // env var name e.g. "GH_TOKEN" pub display: String, // OpRef.path or Extended.value — human label pub kind: ExecItemKind, pub source: String, // op:// URI, $VAR name, or literal pub selected: bool, } pub enum ExecItemKind { Op, Env, Literal }When
ExecCommandarrives: buildExecPickerStatefrom workspace on-demand vars (read from the capsule's loaded config), push onto dialog_stack. Dialog handles Space=toggle, ↑↓=cursor, Enter=confirm (read selected items → connect to/jackin/run/host.sock→ resolve → inject → execute), Esc=cancel (sendExecDenied). -
crates/jackin-capsule/src/socket.rs— routesExecCommandin the control dispatcher -
crates/jackin-config/src/versions.rsandcrates/jackin-config/src/migrations.rs— schema version and migration chain for workspace-file restamps -
crates/jackin-runtime/src/runtime/launch.rs+crates/jackin-runtime/src/runtime/launch/launch_pipeline.rs— starts the host.sock listener for Docker launches and injectsJACKIN_EXEC_BINDINGS=name1,name2,...as an always-available env var. Theis_on_demand()filtering happens upstream incrates/jackin-env/src/resolve.rs(resolve_operator_env_with_matchingdrops on-demand vars before resolution, so they are neverop readat launch), withcollect_on_demand_bindingscollecting the names beside it -
crates/jackin-runtime/src/runtime/apple_container.rs— passes the host.sock path andJACKIN_EXEC_BINDINGSthrough the apple-container launch path -
docker/runtime/entrypoint.sh— usesJACKIN_EXEC_BINDINGSfor the system prompt block -
crates/jackin/tests/fixtures/migrations/workspace/from-v1alpha6/meta.tomlplus matchingbefore.tomlandafter.toml
Phase 2 — MCP tool
- Exposes
jackin_execMCP tool from jackin-capsule using the same exec subcommand handler. Lives incrates/jackin-capsule/src/mcp_server.rsand is registered fromcrates/jackin-capsule/src/runtime_setup.rs.
Related Files
crates/jackin-core/src/env_value.rs—EnvValueenum +OpRefstruct; all type changes herecrates/jackin-capsule/src/main.rs—execsubcommandcrates/jackin-capsule/src/daemon.rs— ExecCommand dispatch, picker overlay, host.sock callback, temp env injection, output filteringcrates/jackin-protocol/src/control.rs— ExecCommand + ExecResult + ExecDeniedcrates/jackin-config/src/migrations.rs—CURRENT_WORKSPACE_VERSIONbumpcrates/jackin-runtime/src/runtime/launch.rs— host.sock listener +JACKIN_EXEC_BINDINGScrates/jackin-runtime/src/exec_host.rs— host-side credential resolverdocker/runtime/entrypoint.sh— system prompt injection forJACKIN_EXEC_BINDINGS
Related work
- Roadmap: jackin-exec: Secure Execution Wrapper
- Agent isolation architecture — master architecture doc; jackin-exec is Layer 3
- Container credential exposure — the problem this item solves
- jackin❯ daemon — future: daemon replaces the Phase 1 ad-hoc host.sock listener
- Host bridge — secrets and approved host actions — future daemon-based credential resolution that retires host.sock