troxy-cli 1.29.1 → 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 +6 -0
- package/package.json +1 -1
- package/src/auth.js +81 -33
- package/src/hook-report.js +16 -3
- package/src/tests/auth.test.js +12 -1
- package/src/tests/hook-report.test.js +16 -1
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.
|
|
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.
|
|
122
|
-
// one bullet per char so the terminal doesn't look
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
process.stdout.write('\
|
|
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
|
-
|
|
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');
|
package/src/hook-report.js
CHANGED
|
@@ -42,6 +42,11 @@ export function extractUsage(entry) {
|
|
|
42
42
|
// dollar math server-side from the model id; this file only sums what
|
|
43
43
|
// the transcript says was used.
|
|
44
44
|
tokens: inputTokens + outputTokens + cacheCreation + cacheRead,
|
|
45
|
+
// Reply-only portion of the total above - sent alongside actual_tokens,
|
|
46
|
+
// never in place of it (server still bills off the combined total; this
|
|
47
|
+
// is purely so the activity log can show "X tokens (Y reply)" instead
|
|
48
|
+
// of one opaque combined number - Gilad, 2026-09-04).
|
|
49
|
+
outputTokens,
|
|
45
50
|
// Lives on the transcript ENTRY itself, not inside message.usage -
|
|
46
51
|
// confirmed live 2026-09-03 against a real session file (values seen:
|
|
47
52
|
// 'high', 'max'). Whatever string Claude Code actually used, passed
|
|
@@ -128,7 +133,11 @@ export function usageForCurrentTurn(transcriptPath) {
|
|
|
128
133
|
if (seen.size === 0) return null;
|
|
129
134
|
|
|
130
135
|
let totalTokens = 0;
|
|
131
|
-
|
|
136
|
+
let totalOutputTokens = 0;
|
|
137
|
+
for (const u of seen.values()) {
|
|
138
|
+
totalTokens += u.tokens;
|
|
139
|
+
totalOutputTokens += u.outputTokens || 0;
|
|
140
|
+
}
|
|
132
141
|
|
|
133
142
|
// turn_key: dedups a hook that fires twice for the same turn (a retry,
|
|
134
143
|
// a Claude Code internal replay) against a repeat POST for the same
|
|
@@ -151,8 +160,8 @@ export function usageForCurrentTurn(transcriptPath) {
|
|
|
151
160
|
const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
|
|
152
161
|
|
|
153
162
|
return {
|
|
154
|
-
tokens: totalTokens,
|
|
155
|
-
turnKey, contentExcerpt,
|
|
163
|
+
tokens: totalTokens, outputTokens: totalOutputTokens, model: lastModel,
|
|
164
|
+
effort: lastEffort, toolUseBlocksSoFar, turnKey, contentExcerpt,
|
|
156
165
|
};
|
|
157
166
|
}
|
|
158
167
|
|
|
@@ -202,6 +211,10 @@ export async function runHookReport() {
|
|
|
202
211
|
const turnKey = sessionId ? `${sessionId}:${usage.turnKey}` : null;
|
|
203
212
|
|
|
204
213
|
const body = { model: usage.model, actual_tokens: usage.tokens, turn_key: turnKey };
|
|
214
|
+
// Additive only - the backend already treats a missing/invalid value as
|
|
215
|
+
// "no breakdown available" and falls back to the plain total, so it's
|
|
216
|
+
// safe to just omit this rather than validate it client-side too.
|
|
217
|
+
if (usage.outputTokens > 0) body.actual_output_tokens = usage.outputTokens;
|
|
205
218
|
// content_excerpt is optional and additive - the backend classifies it
|
|
206
219
|
// if present, samples/rate-limits on its own, and silently skips when
|
|
207
220
|
// it isn't. Sending it is not required for the usage report itself to
|
package/src/tests/auth.test.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -59,8 +59,9 @@ describe('extractUsage', () => {
|
|
|
59
59
|
},
|
|
60
60
|
};
|
|
61
61
|
const result = extractUsage(entry);
|
|
62
|
-
assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'tokens']);
|
|
62
|
+
assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'outputTokens', 'tokens']);
|
|
63
63
|
assert.equal(result.effort, 'high');
|
|
64
|
+
assert.equal(result.outputTokens, 5);
|
|
64
65
|
assert.ok(!JSON.stringify(result).includes('actual conversation'));
|
|
65
66
|
});
|
|
66
67
|
|
|
@@ -108,6 +109,20 @@ describe('usageForCurrentTurn', () => {
|
|
|
108
109
|
assert.equal(result.tokens, 10 + 20 + 5 + 8);
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
it('sums outputTokens (reply-only) alongside tokens, same dedup-by-message-id rule', () => {
|
|
113
|
+
// Same fixture as the two-real-model-calls test above - outputTokens must
|
|
114
|
+
// dedup identically (20 + 8, not 20+20+8) since it comes off the same
|
|
115
|
+
// per-message.id `seen` map as the combined total.
|
|
116
|
+
writeLines([
|
|
117
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
|
|
118
|
+
assistantLine('msg_1', { input_tokens: 10, output_tokens: 20 }),
|
|
119
|
+
assistantLine('msg_1', { input_tokens: 10, output_tokens: 20 }),
|
|
120
|
+
assistantLine('msg_2', { input_tokens: 5, output_tokens: 8 }),
|
|
121
|
+
]);
|
|
122
|
+
const result = usageForCurrentTurn(transcriptPath());
|
|
123
|
+
assert.equal(result.outputTokens, 20 + 8, 'reply-only total did not dedup by message.id like tokens does');
|
|
124
|
+
});
|
|
125
|
+
|
|
111
126
|
it('only looks back to the last user-authored line, not the whole session', () => {
|
|
112
127
|
writeLines([
|
|
113
128
|
{ type: 'user', message: { content: [{ type: 'text', text: 'first turn' }] } },
|