troxy-cli 1.29.2 → 1.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,12 @@ prediction, and the dashboard marks that agent's figures as unverified.
94
94
  - Paste the code into the terminal
95
95
  - Stores the JWT locally for 12 hours
96
96
 
97
+ Without a real terminal attached (a script, CI, an agent's own shell), the
98
+ masked prompt is skipped in favor of a plain line read from stdin - it never
99
+ crashes for lack of a TTY. There's no flag to pass the code directly: the
100
+ code the browser shows is only valid against this run's own session, so it
101
+ has to come back through this same process, e.g. `troxy login < code.txt`.
102
+
97
103
  ## Stack
98
104
 
99
105
  - Node.js 18+ (ESM)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.29.2",
3
+ "version": "1.29.3",
4
4
  "description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
package/src/auth.js CHANGED
@@ -95,6 +95,44 @@ function _openBrowser(url) {
95
95
  }
96
96
  }
97
97
 
98
+ // Which strategy resolves the login code, given whether stdin is a TTY.
99
+ // Exported so this decision is unit-testable without a real terminal: the
100
+ // raw-mode branch itself needs a real TTY and can't be, but which branch
101
+ // gets picked is exactly the logic that used to crash with no TTY at all
102
+ // (setRawMode is not a function on a pipe/non-interactive stdin - found live
103
+ // by another Claude instance running in a non-interactive shell). No --code
104
+ // flag: the code the browser shows is minted for, and only valid against,
105
+ // this specific run's own session_id (auth.py's handle_cli_authorize binds
106
+ // cli_code to session_id server-side, 5-minute TTL) - a flag read before a
107
+ // fresh cliStart() would always redeem a stale session and 401.
108
+ export function _codeInputStrategy(isTTY) {
109
+ return isTTY ? 'raw-tty' : 'line-read';
110
+ }
111
+
112
+ // A plain, non-masked line read for the no-TTY fallback - deliberately not
113
+ // init.js's shared prompt(), which leaves its Promise permanently unresolved
114
+ // if the input stream closes (EOF) before an answer, a real Node readline
115
+ // quirk: init.js's own callers are all isTTY-guarded already, so a live human
116
+ // terminal never hits it, but a no-TTY `troxy login` (a pipe with nothing
117
+ // written to it, or none at all) hits EOF immediately and is exactly this
118
+ // fallback's whole reason to exist - it must resolve either way, so the
119
+ // existing "No code entered" handling below still runs instead of the
120
+ // process just going quiet with no explanation and exit code 0.
121
+ function _readCodeLine(question) {
122
+ return new Promise(resolve => {
123
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
124
+ let answered = false;
125
+ rl.question(question, ans => {
126
+ answered = true;
127
+ rl.close();
128
+ resolve(ans.trim());
129
+ });
130
+ rl.on('close', () => {
131
+ if (!answered) resolve('');
132
+ });
133
+ });
134
+ }
135
+
98
136
  /** Device-code login flow — opens browser, user copies code back to CLI. */
99
137
  export async function runLogin() {
100
138
  // 1. Start a CLI auth session
@@ -118,41 +156,51 @@ export async function runLogin() {
118
156
  _openBrowser(session.url);
119
157
  }
120
158
 
121
- // 3. Prompt for the code shown in the browser (masked like a password,
122
- // one bullet per char so the terminal doesn't look frozen)
123
- const code = await new Promise(resolve => {
124
- const rl = readline.createInterface({ input: process.stdin, output: null });
125
- process.stdout.write(' Paste the code from your browser: ');
126
- let buf = '';
127
- process.stdin.setRawMode(true);
128
- process.stdin.resume();
129
- process.stdin.setEncoding('utf8');
130
- const onData = chunk => {
131
- for (const ch of chunk) {
132
- if (ch === '\r' || ch === '\n') {
133
- process.stdin.setRawMode(false);
134
- process.stdin.pause();
135
- process.stdin.removeListener('data', onData);
136
- rl.close();
137
- process.stdout.write('\n');
138
- resolve(buf.trim());
139
- return;
140
- } else if (ch === '\u0003') { // Ctrl-C
141
- process.stdout.write('\n');
142
- process.exit(0);
143
- } else if (ch === '\u007f' || ch === '\b') { // backspace
144
- if (buf.length > 0) {
145
- buf = buf.slice(0, -1);
146
- process.stdout.write('\b \b');
159
+ // 3. Resolve the code shown in the browser. A real TTY gets the masked
160
+ // raw-mode prompt (one bullet per char so the terminal doesn't look
161
+ // frozen); anything else (a script, CI, an agent's own shell) falls
162
+ // back to a plain line read (_readCodeLine below) - process.stdin.
163
+ // setRawMode is not a function at all without a real TTY, so it must
164
+ // never run unguarded (it used to, and crashed uncaught).
165
+ const strategy = _codeInputStrategy(process.stdin.isTTY);
166
+ let code;
167
+ if (strategy === 'line-read') {
168
+ code = await _readCodeLine(' Paste the code from your browser: ');
169
+ } else {
170
+ code = await new Promise(resolve => {
171
+ const rl = readline.createInterface({ input: process.stdin, output: null });
172
+ process.stdout.write(' Paste the code from your browser: ');
173
+ let buf = '';
174
+ process.stdin.setRawMode(true);
175
+ process.stdin.resume();
176
+ process.stdin.setEncoding('utf8');
177
+ const onData = chunk => {
178
+ for (const ch of chunk) {
179
+ if (ch === '\r' || ch === '\n') {
180
+ process.stdin.setRawMode(false);
181
+ process.stdin.pause();
182
+ process.stdin.removeListener('data', onData);
183
+ rl.close();
184
+ process.stdout.write('\n');
185
+ resolve(buf.trim());
186
+ return;
187
+ } else if (ch === '\u0003') { // Ctrl-C
188
+ process.stdout.write('\n');
189
+ process.exit(0);
190
+ } else if (ch === '\u007f' || ch === '\b') { // backspace
191
+ if (buf.length > 0) {
192
+ buf = buf.slice(0, -1);
193
+ process.stdout.write('\b \b');
194
+ }
195
+ } else if (ch >= ' ') {
196
+ buf += ch;
197
+ process.stdout.write('•');
147
198
  }
148
- } else if (ch >= ' ') {
149
- buf += ch;
150
- process.stdout.write('•');
151
199
  }
152
- }
153
- };
154
- process.stdin.on('data', onData);
155
- });
200
+ };
201
+ process.stdin.on('data', onData);
202
+ });
203
+ }
156
204
 
157
205
  if (!code) {
158
206
  console.error('\n No code entered. Run troxy login to try again.\n');
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
 
4
- import { isOpenableUrl } from '../auth.js';
4
+ import { isOpenableUrl, _codeInputStrategy } from '../auth.js';
5
5
 
6
6
  test('isOpenableUrl accepts plain https and rejects everything else (H4)', () => {
7
7
  assert.equal(isOpenableUrl('https://dash.troxy.io/cli?code=abc'), true);
@@ -25,3 +25,14 @@ test('isOpenableUrl accepts plain https and rejects everything else (H4)', () =>
25
25
  assert.equal(isOpenableUrl(bad), false, `should reject: ${String(bad)}`);
26
26
  }
27
27
  });
28
+
29
+ test('_codeInputStrategy: a real TTY gets the masked raw-mode prompt', () => {
30
+ assert.equal(_codeInputStrategy(true), 'raw-tty');
31
+ });
32
+
33
+ test('_codeInputStrategy: no TTY falls back to a plain line read, never raw mode ' +
34
+ '(regression: process.stdin.setRawMode is not a function without a real TTY - ' +
35
+ 'used to crash `troxy login` outright for a script, CI, or an agent\'s own shell)', () => {
36
+ assert.equal(_codeInputStrategy(false), 'line-read');
37
+ assert.equal(_codeInputStrategy(undefined), 'line-read');
38
+ });