Skip to content

astrid capsule install silently skips local-egress prompt on non-TTY stdin — surfaces later as AirlockRejected #1207

Description

@jvsteiner

Environment

  • Astrid daemon + CLI 0.9.4
  • macOS

Symptom

astrid capsule install <path> for a capsule whose [env] config carries a URL pointing at a private-IP endpoint (e.g. http://127.0.0.1:1234) should trigger the operator local-egress consent prompt so the endpoint gets added to [security.capsule_local_egress] in ~/.astrid/config.toml. The prompt is present and works correctly on a TTY.

On non-TTY stdin (piped install, astrid capsule install invoked from a script or CI runner, headless install from a coding agent) the prompt is silently skipped with no output. The capsule installs "successfully" from every visible signal, but its first HTTP call to that private-IP endpoint later fails with ErrorCode::AirlockRejected — hundreds of lines of guest-log noise before someone traces it back to the missing operator config.

Repro

# 1. Any capsule whose Capsule.toml declares capabilities that resolve to
#    an IP-literal net_connect entry (e.g. 127.0.0.1:1234), or whose env
#    schema has a URL pointing at a private IP. Ours is a routing capsule
#    talking to LM Studio at 127.0.0.1:1234.

# 2. Install non-interactively (no TTY):
echo "" | astrid capsule install ./my-capsule
# or from a script/CI/an agent — no operator sitting at a prompt.

# 3. Install reports success:
# Live: the running daemon loaded 'my-capsule' — no restart needed.

# 4. Send the capsule any request that triggers its outbound HTTP call.
#    Result: silent failure, or (with tracing) ErrorCode::AirlockRejected.

Where the silence comes from

astrid-cli/src/commands/capsule/local_egress.rs in maybe_prompt_local_egress:

pub(crate) fn maybe_prompt_local_egress(capsule_id: &str, value: &str, config_path: &Path) {
    use std::io::IsTerminal;

    // Only a TTY is a real operator at a prompt. A non-interactive stdin
    // (script / pipe / CI) must not be read — reading would block waiting for
    // input or steal the caller's piped data — and must not auto-write the
    // exemption. Decline before touching stdin or the config.
    if !std::io::stdin().is_terminal() {
        return;
    }
    // …
}

The TTY guard is right — silently blocking on stdin or auto-blessing a private IP without operator consent would both be worse than skipping the prompt. What's missing is any signal to the operator that a bless step was skipped.

Suggested fix

Where the TTY guard bails, emit a one-line stderr notice:

warning: skipping local-egress bless for '<capsule_id>' → '<value>' (stdin is not a terminal).
         add manually to ~/.astrid/config.toml:
           [security.capsule_local_egress]
           <capsule_id> = ["<host:port>"]

Bonus: include the same reminder in the install-summary output, so operators piping installs into build scripts see the missing step in the same place they see the install-success message.

Why this matters

The AirlockRejected symptom presents dozens of layers away from the actual cause — a capsule's HTTP call to a permitted endpoint fails with an error whose text ("Host function call failed") gives no hint that operator-side config is the culprit. Cost me an evening of debugging (patched daemon + several extra rounds of tracing) before I found the guard in maybe_prompt_local_egress. A one-line warning on the install path would collapse that debugging to a Ctrl-F.

Related

The manifest-side declaration (net_connect = ["127.0.0.1:1234"] in Capsule.toml) is necessary but not sufficient — I had that set from day one but the airlock still rejected because [security.capsule_local_egress] was empty. That's the correct design (operator has the final say on which capsules can reach loopback), but the install UX doesn't surface the operator-side requirement.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions