Skip to content

fix(cli): os login --json refuses in a non-interactive shell instead of prompting on stdout (#6728) - #6985

Draft
os-project-manager wants to merge 1 commit into
mainfrom
claude/issue-6728-login-json-noninteractive
Draft

fix(cli): os login --json refuses in a non-interactive shell instead of prompting on stdout (#6728)#6985
os-project-manager wants to merge 1 commit into
mainfrom
claude/issue-6728-login-json-noninteractive

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6728

What was broken

Below the device flow that #6531 fixed sat a second path with the same
stdout-purity harm and a different cause. With no TTY and one or both of
--email/--password missing — what a CI runner produces when a secret fails
to interpolate — login.ts fell through to readline, whose prompt goes to
the output stream the interface was built on: process.stdout,
unconditionally, --json or not.

Reproduced on unmodified origin/main @ 73bff86 (tsx bin/run-dev.js, temp
HOME, piped stdin — no pty needed on this path, unlike the device-flow
sibling):

$ os login --json --url http://127.0.0.1:1 < /dev/null > out.txt 2> err.txt
exit=13
$ cat -A out.txt
Email:

cat -A prints no trailing $, i.e. no trailing newline: the string Email:
was the entire stdout of a run under a declared machine-readable flag — not
a JSON document, not NDJSON, no payload. stderr carried only
Warning: Detected unsettled top-level await. The two partial-flag
combinations reproduce the same way:

run exit whole stdout
--json 13 Email:
--json --password secret 13 Email:
--json --email a@b.c 13 Password:
no --json (human path) 13 banner, then Email:

The fourth row is the second half of the defect on its own: exit 13 is Node's
Unsettled Top-Level Await teardown, not a code this CLI chose.
CliExitCode (packages/cli/src/utils/format.ts) still admits 0 and 1
only — verified, PM assumption 2 holds.

What this changes

Per the maintainer ruling of 2026-08-09 (issue comment 5229992975) — shape 1,
refuse
— plus the exit-code half the ruling attached to the same PR.

1. --json refuses instead of prompting. A --json run that would
otherwise have to ask emits one record through emitRecord() — the file's
single --json writer introduced by #6727, no second write path added — and
exits 1:

$ os login --json --url http://127.0.0.1:1 < /dev/null
{"success":false,"error":"email and password are required in a non-interactive shell"}
$ echo $?
1

The refusal returns rather than calling this.exit(1) inside the command's own
try: this.exit throws, and login.ts's catch does not filter
isExitSignal, so an exit from inside the try would emit a second record —
the exact double-document hazard format.ts documents. emitRecord(payload, 1)
sets process.exitCode and lets the command return cleanly.

One judgement call, flagged rather than buried. The ruling's condition
reads "with no --email/--password and no TTY". The check here is on the
flag alone, because reaching that line under --json already means the only
way forward was a prompt, and the ruling's contract sentence is
"--json means non-interactive by definition" — a TTY that happens to be
attached does not un-declare the run. Concretely this also covers
os login --json --email me@x.com typed in a terminal, which used to prompt
for the password on stdout. If the maintainer wants that one case to keep
prompting, the change is one added && !process.stdin.isTTY.

2. EOF on stdin produces a defined CliExitCode. PM assumption 3 is
confirmed: the mechanism is not an error to catch, it is a promise that never
settles. readline's question() resolves only when a line arrives; at EOF
the interface emits 'close' and the promise is abandoned, so nothing
throws, the outer catch never runs, and the top-level await in bin/run.js
is torn down as unsettled. askOrFailAtEof() binds each question to an
AbortController aborted by that 'close', which rejects it and puts the
failure back on the path that ends in this.exit(1).

Without --json the prompts stay — the ruling changed the machine-readable
contract, not the human one — but the ending is now:

$ os login --url http://127.0.0.1:1 < /dev/null; echo $?

Email:
  ✗ stdin reached end of input before the credentials were entered. Pass --email and --password to log in non-interactively.
1

3. The two non-TTY prompts share one readline interface. promptPassword
used to open a private one for its non-TTY branch — the second interface the
unsettleable question lived in. That branch is deleted; the function is now
TTY-only and the caller guards it, so a future prompt cannot reintroduce the
hang by opening its own interface.

Verification

Reverse verification, direction predicted before running. Predicted: the
--json cases go red on the payload and on the exit code, the EOF cases go
red on the exit code only, and the two "must keep working" controls stay green
because the defect never broke them. Reverting login.ts to origin/main with
the new test file in place gives exactly that — 17 red, 2 green:

× … neither credential supplied > emits the ruled refusal record and nothing else
  → Unexpected token 'E', "Email: " is not valid JSON
× … neither credential supplied > exits with a code this CLI defines
  → exit 13 is not a CliExitCode. 13 is Node's unsettled-top-level-await teardown,
    which is exactly what this path used to produce.: expected [ +0, 1 ] to include 13
× … only --email supplied > emits the ruled refusal record and nothing else
  → Unexpected token 'P', "Password: " is not valid JSON
✓ … the paths that must keep working > logs in with both flags under --json
✓ … the paths that must keep working > still prompts, and still succeeds, for a consumer that answers
 Tests  17 failed | 2 passed (19)

The exit code is asserted as a set member, not as "non-zero". A
not.toBe(0) assertion stays green against the defect itself, since 13 is as
non-zero as 1 is. Each case asserts membership in [0, 1] and the specific
value, and it is the membership assertion whose message names 13.

New testspackages/cli/test/login-json-noninteractive.e2e.test.ts, 20
cases through a real child process with piped stdin:

  • all three --json combinations (neither / --password only / --email
    only): the parsed record equals {success:false, error:"…"} verbatim, exit
    is 1 and a member of the defined set, stdout is exactly one line, contains
    no Email: / Password: / banner / carriage return / escape, and ends in a
    newline (the defect's stdout had none);
  • both EOF cases without --json: exit 1 and a member of the set, the
    message names the cause and the remedy, and stderr no longer carries
    unsettled top-level await;
  • two controls that must keep working, against a fake sign-in/email
    endpoint: --json --email --password yields one {success:true,…} record
    and exit 0, and a consumer that answers the prompts still logs in (which is
    also what proves the single shared interface hands over both answers);
  • source and doc pins: every rl.question( in login.ts carries the abort
    signal, askOrFailAtEof exists, the --json help declares the implication,
    and the CLI reference documents the refusal string.

Suites and gates (all under the shared verification lock):

  • pnpm --filter @objectstack/cli testTest Files 101 passed (101),
    Tests 1064 passed (1064)
  • pnpm --filter @objectstack/cli typecheck — pass
  • every check:* step enumerated from .github/workflows/lint.yml, run one by
    one: 34/34 in the ESLint job (lint, check:nul-bytes, check:route-envelope,
    check:error-code-casing, check:engine-double-contract, … ) and the
    TypeScript Type Check job's steps including
    check:type-check-coverage, check:type-check-debt, check:i18n,
    check:i18n-coverage, the spec check:* family, the full
    turbo run build/typecheck sweeps and the examples typecheck — all pass.
    check:i18n, check:i18n-coverage and check:type-check-debt first
    reported PREREQUISITE NOT MET and went green once the closure was built, as
    lint.yml does before those steps.

Out of scope

Filed as #6984 (observation-class, finding, unassigned):
pnpm check:nul-bytes enumerates git ls-files, so a brand-new file is not
scanned until it is staged. Hit live here — a raw 0x1b was materialized into
the new test file while writing about control characters, the gate exited 0,
and AGENTS.md's grep -naP self-scan is what found it. CI is unaffected (a
PR's tree is fully tracked); the harm is the pre-push run being green for a
reason unrelated to the bytes. The byte is gone: the test builds its control
class from String.fromCharCode so the source cannot carry what it asserts
against.


Generated by Claude Code

…d of prompting on stdout (#6728)

The non-TTY fallback below the device flow wrote `readline`'s prompt to the
`output` stream it was built on — `process.stdout`, unconditionally, `--json`
or not. Measured on origin/main @ 73bff86, `os login --json --url … <
/dev/null` produced exit 13 and a stdout consisting entirely of `Email: `,
with no trailing newline. `--password` alone gave the same; `--email` alone
gave `Password: `.

Per the maintainer ruling of 2026-08-09 (shape 1), a `--json` run that would
otherwise have to prompt now emits one record through the existing
`emitRecord()` NDJSON emitter and exits 1:

  {"success":false,"error":"email and password are required in a non-interactive shell"}

Separately and in the same PR: EOF on stdin now produces a defined
`CliExitCode`. Exit 13 was Node's unsettled-top-level-await teardown —
`readline`'s question promise is abandoned rather than rejected at EOF, so
nothing threw and the command never decided anything. Every prompt is now
bound to an abort that fires on the interface's `close`, which puts the
failure back on the path that ends in `this.exit(1)`. The non-`--json` path
keeps its prompts and gains a named error instead of a teardown.

The two non-TTY prompts share one readline interface now; `promptPassword`
loses its non-TTY branch, which was the second interface the unsettleable
question lived in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 7:45am

Request Review

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/plugins/index.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/releases/v17.mdx (via @objectstack/cli)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os login --json in a non-TTY shell writes the bare prompt Email: to stdout, then exits 13 with no payload

2 participants