@cordfuse/crosstalk 7.0.0-alpha.7 → 7.0.0-alpha.8
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/bin/crosstalk.js +0 -1
- package/commands/chat.js +23 -219
- package/commands/init.js +1 -8
- package/commands/status.js +2 -2
- package/commands/up.js +1 -3
- package/lib/resolve.js +0 -1
- package/package.json +1 -1
- package/commands/auth.js +0 -218
package/bin/crosstalk.js
CHANGED
package/commands/chat.js
CHANGED
|
@@ -1,110 +1,33 @@
|
|
|
1
|
-
// crosstalk chat —
|
|
1
|
+
// crosstalk chat — interactive entry into the container.
|
|
2
2
|
//
|
|
3
|
-
// Modes:
|
|
4
|
-
// crosstalk chat --agent <name>
|
|
5
|
-
// crosstalk chat --
|
|
6
|
-
// crosstalk chat --shell # bash escape hatch (sysadmin / debug)
|
|
3
|
+
// Modes (alpha.8):
|
|
4
|
+
// crosstalk chat --agent <name> # interactive session with an agent CLI
|
|
5
|
+
// crosstalk chat --shell # bash escape hatch (debug, install CLIs, run setup commands)
|
|
7
6
|
//
|
|
8
|
-
// --agent is required for non-shell
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// --agent is required for non-shell mode. There's no implicit default;
|
|
8
|
+
// containers may have multiple agent CLIs installed, the operator
|
|
9
|
+
// picks one explicitly.
|
|
11
10
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// browser launcher (`open` / `xdg-open` / `wslview` / `start`). Operator
|
|
18
|
-
// never has to read or copy-paste the URL.
|
|
19
|
-
//
|
|
20
|
-
// COLUMNS=1000 is still set under --login as a contingency: if browser
|
|
21
|
-
// open fails (headless SSH session, missing xdg-open, etc.), the URL
|
|
22
|
-
// still renders unwrapped in the terminal for manual copy. That's the
|
|
23
|
-
// fallback, not the primary mechanism.
|
|
11
|
+
// `--login` was removed in alpha.8. Per-agent auth is now handled via
|
|
12
|
+
// `data/crosstalk.yaml`'s per-provider `env_file:` (env-injected at
|
|
13
|
+
// agent spawn time inside the engine) plus operator-run setup commands
|
|
14
|
+
// for agents whose auth is a stateful CLI act (e.g. codex). Recipes are
|
|
15
|
+
// documented in transport/auth/README.md.
|
|
24
16
|
|
|
25
|
-
import {
|
|
17
|
+
import { spawnSync } from 'child_process';
|
|
26
18
|
import { requireInitialized } from '../lib/resolve.js';
|
|
27
19
|
import { apiFor, ConnectError } from '../lib/api-client.js';
|
|
28
20
|
import { flag, has } from '../lib/argv.js';
|
|
29
21
|
|
|
30
22
|
const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
|
|
31
23
|
|
|
32
|
-
// URL extraction that survives agent TUI rendering. Naive regex against
|
|
33
|
-
// raw stdout fails because agent CLIs (Claude in particular) draw a box
|
|
34
|
-
// around the URL with line wraps every ~N chars; the captured URL is
|
|
35
|
-
// truncated at the first wrap, with ANSI control chars leaking in at
|
|
36
|
-
// the cut point. Real example from alpha.3 macOS test: extracted URL
|
|
37
|
-
// ended with `%18%6b…` (CAN char + box-drawing artifact) instead of
|
|
38
|
-
// the `redirect_uri=…` param, breaking OAuth.
|
|
39
|
-
//
|
|
40
|
-
// Approach:
|
|
41
|
-
// 1. ANSI-strip everything (CSI / OSC / SS3 escapes).
|
|
42
|
-
// 2. Strip control chars (0x00-0x1F except LF, plus DEL).
|
|
43
|
-
// 3. Join line wraps that occur INSIDE a URL-safe character run —
|
|
44
|
-
// `<url-char>\n<url-char>` → `<url-char><url-char>`. Preserves real
|
|
45
|
-
// `<url-char>\n<non-url-char>` boundaries.
|
|
46
|
-
// 4. Match `https?://<url-chars>+` against the joined buffer.
|
|
47
|
-
// 5. Only fire when the character AFTER the matched URL is a true
|
|
48
|
-
// terminator (whitespace, end-of-buffer) — guards against acting
|
|
49
|
-
// on a partial URL still being written.
|
|
50
|
-
|
|
51
|
-
const URL_SAFE_CLASS = "a-zA-Z0-9%/?#&=+\\-._~:@!$()*,;";
|
|
52
|
-
const URL_RE = new RegExp(`https?://[${URL_SAFE_CLASS}]+`);
|
|
53
|
-
|
|
54
|
-
function extractCompleteUrl(rawBuffer) {
|
|
55
|
-
// Strip ANSI CSI escapes (\x1B[ ... terminator).
|
|
56
|
-
let clean = rawBuffer.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, '');
|
|
57
|
-
// Strip ANSI OSC escapes (\x1B] ... BEL or ST).
|
|
58
|
-
clean = clean.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
|
|
59
|
-
// Strip SS3 escapes (\x1BO + one char).
|
|
60
|
-
clean = clean.replace(/\x1BO./g, '');
|
|
61
|
-
// Strip remaining single-char escapes that slipped through.
|
|
62
|
-
clean = clean.replace(/\x1B[^a-zA-Z0-9]/g, '');
|
|
63
|
-
// Strip control chars except LF (0x0A); we use LF for line-wrap rejoin.
|
|
64
|
-
clean = clean.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
|
|
65
|
-
// Rejoin URL-character wrap points: `<url-char>\n<url-char>` becomes
|
|
66
|
-
// `<url-char><url-char>`. Doesn't merge `<url-char>\n<space>` etc.
|
|
67
|
-
const safeRe = new RegExp(`[${URL_SAFE_CLASS}]\\n[${URL_SAFE_CLASS}]`, 'g');
|
|
68
|
-
let prev;
|
|
69
|
-
do {
|
|
70
|
-
prev = clean;
|
|
71
|
-
clean = clean.replace(safeRe, (m) => m[0] + m[2]);
|
|
72
|
-
} while (clean !== prev);
|
|
73
|
-
|
|
74
|
-
const match = clean.match(URL_RE);
|
|
75
|
-
if (!match) return null;
|
|
76
|
-
// Require a terminator after the URL — without it, the URL may still
|
|
77
|
-
// be growing in subsequent stdout chunks. Terminator = whitespace,
|
|
78
|
-
// quote/angle, or end-of-buffer-with-trailing-newline.
|
|
79
|
-
const after = clean.slice(match.index + match[0].length);
|
|
80
|
-
if (after.length === 0) return null; // EOF-as-grow case, wait
|
|
81
|
-
if (!/^[\s<>"']/.test(after)) return null; // URL still continuing
|
|
82
|
-
return match[0];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function openInBrowser(url) {
|
|
86
|
-
const cmd =
|
|
87
|
-
process.platform === 'darwin' ? 'open' :
|
|
88
|
-
process.platform === 'win32' ? 'start' :
|
|
89
|
-
process.env['WSL_DISTRO_NAME'] ? 'wslview' :
|
|
90
|
-
/* default Linux */ 'xdg-open';
|
|
91
|
-
try {
|
|
92
|
-
const r = spawnSync(cmd, [url], { stdio: 'ignore' });
|
|
93
|
-
return r.status === 0;
|
|
94
|
-
} catch {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
24
|
function usage(exit = 0) {
|
|
100
25
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
101
26
|
w.write(
|
|
102
|
-
`Usage: crosstalk chat --agent <name>
|
|
27
|
+
`Usage: crosstalk chat --agent <name>
|
|
103
28
|
crosstalk chat --shell
|
|
104
29
|
|
|
105
|
-
Opens an interactive session inside the engine container
|
|
106
|
-
PTY-wrapped entry point for everything you'd do inside the container
|
|
107
|
-
(daily chat, OAuth setup, sysadmin).
|
|
30
|
+
Opens an interactive session inside the engine container.
|
|
108
31
|
|
|
109
32
|
--agent is REQUIRED for any chat mode. Containers can have multiple
|
|
110
33
|
agent CLIs installed; the operator must say which one to invoke.
|
|
@@ -112,42 +35,27 @@ agent CLIs installed; the operator must say which one to invoke.
|
|
|
112
35
|
Supported agents: ${KNOWN_AGENTS.join(', ')}.
|
|
113
36
|
|
|
114
37
|
Modes:
|
|
115
|
-
--agent <name>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
--shell Drop into bash (install agent CLIs, debug,
|
|
122
|
-
inspect state). No --agent needed.
|
|
38
|
+
--agent <name> Interactive with the named agent CLI. Use the
|
|
39
|
+
agent's own keys/commands for first-time setup
|
|
40
|
+
(see transport/auth/README.md for recipes).
|
|
41
|
+
--shell Drop into bash (install agent CLIs, debug, run
|
|
42
|
+
one-time auth setup commands like
|
|
43
|
+
\`codex login --with-api-key\`).
|
|
123
44
|
|
|
124
45
|
Examples:
|
|
125
46
|
crosstalk chat --agent claude # interactive claude
|
|
126
47
|
crosstalk chat --agent gemini # interactive gemini
|
|
127
|
-
crosstalk chat --agent claude --login # OAuth, claude
|
|
128
|
-
crosstalk chat --agent codex --login # OAuth, codex
|
|
129
48
|
crosstalk chat --shell # bash
|
|
130
49
|
`,
|
|
131
50
|
);
|
|
132
51
|
process.exit(exit);
|
|
133
52
|
}
|
|
134
53
|
|
|
135
|
-
// Detect host terminal dims with sane fallbacks. Pass into the
|
|
136
|
-
// container via -e COLUMNS/LINES so the agent CLI renders at the
|
|
137
|
-
// operator's real terminal size instead of docker's 80×24 fallback.
|
|
138
|
-
// (When stdout is piped — like in --login — docker can't auto-detect
|
|
139
|
-
// the host's stdout terminal, which is mac's alpha.6 bug D.)
|
|
140
|
-
function hostTermDims() {
|
|
141
|
-
const cols = process.stdout.columns || process.stderr.columns || 120;
|
|
142
|
-
const rows = process.stdout.rows || process.stderr.rows || 40;
|
|
143
|
-
return { cols, rows };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
54
|
function runInteractive(container, command, env = {}) {
|
|
147
55
|
// Full TTY passthrough — docker exec -it gives the container a real
|
|
148
56
|
// PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
|
|
149
|
-
// operator's terminal. Agent CLIs
|
|
150
|
-
//
|
|
57
|
+
// operator's terminal. Agent CLIs render their rich TUI normally and
|
|
58
|
+
// SIGWINCH propagates from the host's tty to the container's PTY.
|
|
151
59
|
const envFlags = [];
|
|
152
60
|
for (const [k, v] of Object.entries(env)) {
|
|
153
61
|
envFlags.push('--env', `${k}=${v}`);
|
|
@@ -158,106 +66,6 @@ function runInteractive(container, command, env = {}) {
|
|
|
158
66
|
return r.status ?? 1;
|
|
159
67
|
}
|
|
160
68
|
|
|
161
|
-
// Per-agent login command. Returns null for agents without an OAuth
|
|
162
|
-
// path (gemini, qwen, opencode) — these need env-var auth, not browser
|
|
163
|
-
// OAuth. The full audit (cachy 2026-06-14) confirmed each agent's
|
|
164
|
-
// surface:
|
|
165
|
-
// claude → `claude auth login` (clean single-line URL output)
|
|
166
|
-
// codex → `codex login` (clean single-line URL output)
|
|
167
|
-
// agy → `agy --print init` (triggers OAuth, clean URL)
|
|
168
|
-
// We use the explicit auth subcommand instead of bare invocation —
|
|
169
|
-
// that's mac's bug D fix root cause. Bare `claude` drops into a TUI
|
|
170
|
-
// with line wraps + winsize sensitivity that broke URL extraction
|
|
171
|
-
// across alpha.3 → alpha.6. The official auth subcommand prints a
|
|
172
|
-
// plain single-line URL on stdout, no TUI involved.
|
|
173
|
-
function loginCommand(agent) {
|
|
174
|
-
if (agent === 'claude') return ['claude', 'auth', 'login'];
|
|
175
|
-
if (agent === 'codex') return ['codex', 'login'];
|
|
176
|
-
if (agent === 'agy') return ['agy', '--print', 'init'];
|
|
177
|
-
return null; // gemini, qwen, opencode — env-based
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Per-agent env-var hint for the no-OAuth agents.
|
|
181
|
-
function envHint(agent) {
|
|
182
|
-
if (agent === 'gemini') return 'GEMINI_API_KEY';
|
|
183
|
-
if (agent === 'qwen') return 'OPENAI_API_KEY (qwen --auth-type openai)';
|
|
184
|
-
if (agent === 'opencode') return 'per-provider keys in opencode config';
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function runLogin(container, agent) {
|
|
189
|
-
const cmd = loginCommand(agent);
|
|
190
|
-
if (!cmd) {
|
|
191
|
-
const env = envHint(agent);
|
|
192
|
-
process.stderr.write(
|
|
193
|
-
`crosstalk chat --login --agent ${agent}: this agent has no browser OAuth flow.\n` +
|
|
194
|
-
(env ? ` Use environment-based auth instead:\n crosstalk auth paste --agent ${agent}\n (or set ${env} directly in docker-compose.yml)\n` : '') +
|
|
195
|
-
` Documented in GUIDE-CLI.md § Agent auth.\n`,
|
|
196
|
-
);
|
|
197
|
-
return Promise.resolve(1);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Pass host terminal dims explicitly. Docker exec -t normally sizes
|
|
201
|
-
// the container PTY off its stdout terminal, but we PIPE stdout (to
|
|
202
|
-
// scrape the OAuth URL), so docker has no TTY to size from and falls
|
|
203
|
-
// back to 80×24 — that's mac's bug D root cause. Setting COLUMNS/LINES
|
|
204
|
-
// env vars gets the agent CLI to render at the right size from the
|
|
205
|
-
// start. (Live SIGWINCH updates aren't propagated — docker env is
|
|
206
|
-
// set-at-spawn — but OAuth flows are short; operators don't typically
|
|
207
|
-
// resize the terminal mid-auth.)
|
|
208
|
-
const { cols, rows } = hostTermDims();
|
|
209
|
-
|
|
210
|
-
return new Promise((resolve) => {
|
|
211
|
-
const child = spawn(
|
|
212
|
-
'docker',
|
|
213
|
-
['exec', '-it',
|
|
214
|
-
'-e', `COLUMNS=${cols}`,
|
|
215
|
-
'-e', `LINES=${rows}`,
|
|
216
|
-
'-e', 'TERM=xterm-256color',
|
|
217
|
-
container, ...cmd],
|
|
218
|
-
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
// SIGWINCH handler: docker env is set-at-spawn so we can't dynamically
|
|
222
|
-
// resize the in-container PTY without a control channel (would need
|
|
223
|
-
// node-pty or a sidecar process). Best-effort: surface that a resize
|
|
224
|
-
// happened so the operator knows why the TUI may not have responded.
|
|
225
|
-
const onWinch = () => {
|
|
226
|
-
const { cols: c, rows: r } = hostTermDims();
|
|
227
|
-
process.stderr.write(
|
|
228
|
-
`\n[crosstalk chat] Terminal resized to ${c}×${r} — in-container PTY won't update mid-session. ` +
|
|
229
|
-
`Exit (Ctrl-C) and retry if rendering is off.\n`,
|
|
230
|
-
);
|
|
231
|
-
};
|
|
232
|
-
process.on('SIGWINCH', onWinch);
|
|
233
|
-
|
|
234
|
-
let opened = false;
|
|
235
|
-
let buffer = '';
|
|
236
|
-
child.stdout.on('data', (chunk) => {
|
|
237
|
-
process.stdout.write(chunk);
|
|
238
|
-
if (opened) return;
|
|
239
|
-
buffer += chunk.toString();
|
|
240
|
-
const url = extractCompleteUrl(buffer);
|
|
241
|
-
if (!url) return;
|
|
242
|
-
opened = true;
|
|
243
|
-
const ok = openInBrowser(url);
|
|
244
|
-
process.stdout.write(
|
|
245
|
-
ok
|
|
246
|
-
? `\n[crosstalk chat] Opened auth URL in your default browser.\n`
|
|
247
|
-
: `\n[crosstalk chat] Could not auto-open browser. Open this URL manually:\n ${url}\n`,
|
|
248
|
-
);
|
|
249
|
-
});
|
|
250
|
-
child.on('exit', (code) => {
|
|
251
|
-
process.removeListener('SIGWINCH', onWinch);
|
|
252
|
-
resolve(code ?? 0);
|
|
253
|
-
});
|
|
254
|
-
child.on('error', () => {
|
|
255
|
-
process.removeListener('SIGWINCH', onWinch);
|
|
256
|
-
resolve(1);
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
69
|
export async function run(argv) {
|
|
262
70
|
if (has(argv, '--help') || has(argv, '-h')) usage(0);
|
|
263
71
|
|
|
@@ -307,9 +115,5 @@ export async function run(argv) {
|
|
|
307
115
|
return 1;
|
|
308
116
|
}
|
|
309
117
|
|
|
310
|
-
if (has(argv, '--login')) {
|
|
311
|
-
return await runLogin(name, agent);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
118
|
return runInteractive(name, agent);
|
|
315
119
|
}
|
package/commands/init.js
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from '../lib/resolve.js';
|
|
35
35
|
|
|
36
36
|
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
37
|
-
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.
|
|
37
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.8';
|
|
38
38
|
|
|
39
39
|
function usage(exit = 0) {
|
|
40
40
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
@@ -129,13 +129,6 @@ export async function run(argv) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
// Ensure an empty auth.env exists so compose's env_file: can reference
|
|
133
|
-
// it without erroring on missing file. Operator-owned, 0600 — even
|
|
134
|
-
// when empty it's where `crosstalk auth paste` will land tokens.
|
|
135
|
-
if (!existsSync(paths.authEnvFile)) {
|
|
136
|
-
writeFileSync(paths.authEnvFile, '', { mode: 0o600 });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
132
|
// ── Phase 2: Transport scaffold ────────────────────────────────────
|
|
140
133
|
// The template-scaffold check is "does CROSSTALK-VERSION exist?" NOT
|
|
141
134
|
// ".git exists" — git init is a separate phase, and the engine refuses
|
package/commands/status.js
CHANGED
|
@@ -34,10 +34,10 @@ export async function run(argv) {
|
|
|
34
34
|
if (installed && installed.length > 0) {
|
|
35
35
|
const verb = installed.length === 1 ? 'references it' : 'references them';
|
|
36
36
|
process.stdout.write(
|
|
37
|
-
` (${installed.join(', ')} installed in the container but no data/
|
|
37
|
+
` (${installed.join(', ')} installed in the container but no data/crosstalk.yaml entry ${verb} — add one to claim)\n`,
|
|
38
38
|
);
|
|
39
39
|
} else {
|
|
40
|
-
process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/
|
|
40
|
+
process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/crosstalk.yaml)\n');
|
|
41
41
|
}
|
|
42
42
|
} else {
|
|
43
43
|
for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
|
package/commands/up.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from '../lib/resolve.js';
|
|
25
25
|
|
|
26
26
|
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
27
|
-
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.
|
|
27
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.8';
|
|
28
28
|
|
|
29
29
|
function usage(exit = 0) {
|
|
30
30
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
@@ -69,8 +69,6 @@ services:
|
|
|
69
69
|
- ${paths.transportDir}:/var/lib/crosstalk-transport
|
|
70
70
|
- ${paths.crosstalkRoot}:/crosstalk-root
|
|
71
71
|
- ${paths.crosstalkState}:/var/lib/crosstalk-state
|
|
72
|
-
env_file:
|
|
73
|
-
- ${paths.authEnvFile}
|
|
74
72
|
environment:
|
|
75
73
|
CROSSTALK_ALIAS: ${alias}
|
|
76
74
|
CROSSTALK_UID: "${uid}"
|
package/lib/resolve.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "7.0.0-alpha.
|
|
3
|
+
"version": "7.0.0-alpha.8",
|
|
4
4
|
"description": "Crosstalk client — host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/commands/auth.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
// crosstalk auth — manage agent auth tokens/keys.
|
|
2
|
-
//
|
|
3
|
-
// One subverb: `paste`. Operator runs the agent's headless-token
|
|
4
|
-
// generator on their host (`claude setup-token`, etc.), pastes the
|
|
5
|
-
// resulting token at our prompt, and we persist it in the container's
|
|
6
|
-
// env so subsequent dispatch + chat invocations are authenticated.
|
|
7
|
-
//
|
|
8
|
-
// Storage: <base>/<name>/auth.env (0600, operator-UID owned). Docker
|
|
9
|
-
// Compose's `env_file:` references it; each line is `KEY=value`.
|
|
10
|
-
// Existing keys are replaced in-place; new keys append. Empty lines
|
|
11
|
-
// and `#` comments preserved.
|
|
12
|
-
//
|
|
13
|
-
// Why this exists: 5 of the 6 supported agents work cleanly with env-
|
|
14
|
-
// var auth (the headless path), but none of them have a uniform "set
|
|
15
|
-
// the token" UX inside the container. This verb is the operator's
|
|
16
|
-
// one-stop shop — same UX for any agent.
|
|
17
|
-
//
|
|
18
|
-
// Per-agent env var (the audit on 2026-06-14 confirmed each):
|
|
19
|
-
// claude → CLAUDE_CODE_OAUTH_TOKEN (preferred: `claude setup-token`)
|
|
20
|
-
// ANTHROPIC_API_KEY (alternate: Console API key)
|
|
21
|
-
// codex → OPENAI_API_KEY (preferred)
|
|
22
|
-
// CODEX_ACCESS_TOKEN (alternate: device-auth output)
|
|
23
|
-
// gemini → GEMINI_API_KEY
|
|
24
|
-
// qwen → OPENAI_API_KEY (qwen --auth-type openai)
|
|
25
|
-
// agy → none (interactive OAuth only; errors with hint)
|
|
26
|
-
|
|
27
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
28
|
-
import { createInterface } from 'readline';
|
|
29
|
-
import { has, flag } from '../lib/argv.js';
|
|
30
|
-
import { requireInitialized, DEFAULT_CONTAINER_NAME } from '../lib/resolve.js';
|
|
31
|
-
|
|
32
|
-
const AGENT_ENV = {
|
|
33
|
-
claude: { default: 'CLAUDE_CODE_OAUTH_TOKEN', label: 'OAuth token' },
|
|
34
|
-
codex: { default: 'OPENAI_API_KEY', label: 'API key' },
|
|
35
|
-
gemini: { default: 'GEMINI_API_KEY', label: 'API key' },
|
|
36
|
-
qwen: { default: 'OPENAI_API_KEY', label: 'API key' },
|
|
37
|
-
opencode: { default: 'OPENAI_API_KEY', label: 'provider API key' },
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const AGENT_GEN_CMD = {
|
|
41
|
-
claude: 'claude setup-token # on a host with a browser',
|
|
42
|
-
codex: 'echo $OPENAI_API_KEY # or platform.openai.com/api-keys',
|
|
43
|
-
gemini: 'echo $GEMINI_API_KEY # or aistudio.google.com/apikey',
|
|
44
|
-
qwen: 'echo $OPENAI_API_KEY # OpenAI-compatible',
|
|
45
|
-
opencode: 'echo $OPENAI_API_KEY # or provider-specific',
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Control byte values used by the TTY prompt — avoid embedding raw
|
|
49
|
-
// bytes inline so the file is editor/linter clean.
|
|
50
|
-
const CTRL_C = String.fromCharCode(3); // 0x03 — interrupt
|
|
51
|
-
const CTRL_D = String.fromCharCode(4); // 0x04 — EOF
|
|
52
|
-
const BS = String.fromCharCode(8); // 0x08 — backspace
|
|
53
|
-
const DEL = String.fromCharCode(127); // 0x7F — terminal backspace
|
|
54
|
-
|
|
55
|
-
function usage(exit = 0) {
|
|
56
|
-
const w = exit === 0 ? process.stdout : process.stderr;
|
|
57
|
-
w.write(
|
|
58
|
-
`Usage: crosstalk auth paste --agent <name> [--containername <name>]
|
|
59
|
-
|
|
60
|
-
Persists an auth token/key for an agent CLI into the container's
|
|
61
|
-
environment. Operator generates the value on the host (which has a
|
|
62
|
-
browser) and pastes at the prompt; crosstalk writes it to
|
|
63
|
-
<base>/<name>/auth.env. Subsequent dispatch + chat invocations pick
|
|
64
|
-
it up via the engine container's env.
|
|
65
|
-
|
|
66
|
-
Options:
|
|
67
|
-
--agent <name> One of: claude, codex, gemini, qwen, opencode.
|
|
68
|
-
agy is interactive-only; use 'crosstalk chat
|
|
69
|
-
--agent agy --login' for OAuth instead.
|
|
70
|
-
--containername <name> Which crosstalk container to set the env on
|
|
71
|
-
(default: 'crosstalk').
|
|
72
|
-
-c <name> Shorthand for --containername.
|
|
73
|
-
|
|
74
|
-
Reads from stdin (hidden) — if the input isn't a TTY, the entire stdin
|
|
75
|
-
is read as the token (e.g. piped from a setup script).
|
|
76
|
-
|
|
77
|
-
After paste, run 'crosstalk restart' to pick up the new env.
|
|
78
|
-
`,
|
|
79
|
-
);
|
|
80
|
-
process.exit(exit);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function promptHidden(promptText) {
|
|
84
|
-
return new Promise((resolve) => {
|
|
85
|
-
process.stderr.write(promptText);
|
|
86
|
-
if (!process.stdin.isTTY) {
|
|
87
|
-
let buf = '';
|
|
88
|
-
process.stdin.on('data', (chunk) => { buf += chunk.toString(); });
|
|
89
|
-
process.stdin.on('end', () => resolve(buf.trim()));
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
if (typeof process.stdin.setRawMode !== 'function') {
|
|
93
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
94
|
-
rl.question('', (ans) => { rl.close(); resolve(ans.trim()); });
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
process.stdin.setRawMode(true);
|
|
98
|
-
process.stdin.resume();
|
|
99
|
-
process.stdin.setEncoding('utf-8');
|
|
100
|
-
let buf = '';
|
|
101
|
-
const onData = (chunk) => {
|
|
102
|
-
for (const ch of chunk) {
|
|
103
|
-
if (ch === '\r' || ch === '\n') {
|
|
104
|
-
process.stderr.write('\n');
|
|
105
|
-
process.stdin.setRawMode(false);
|
|
106
|
-
process.stdin.pause();
|
|
107
|
-
process.stdin.removeListener('data', onData);
|
|
108
|
-
resolve(buf.trim());
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (ch === CTRL_C || ch === CTRL_D) {
|
|
112
|
-
process.stderr.write('\n');
|
|
113
|
-
process.stdin.setRawMode(false);
|
|
114
|
-
process.exit(130);
|
|
115
|
-
}
|
|
116
|
-
if (ch === BS || ch === DEL) {
|
|
117
|
-
buf = buf.slice(0, -1);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
buf += ch;
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
process.stdin.on('data', onData);
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Upsert a KEY=value line in env-file content. Preserves other lines +
|
|
128
|
-
// comments. Returns the new content.
|
|
129
|
-
function upsertEnv(existing, key, value) {
|
|
130
|
-
const lines = existing.split('\n');
|
|
131
|
-
let replaced = false;
|
|
132
|
-
const out = lines.map((line) => {
|
|
133
|
-
if (line.startsWith(`${key}=`)) {
|
|
134
|
-
replaced = true;
|
|
135
|
-
return `${key}=${value}`;
|
|
136
|
-
}
|
|
137
|
-
return line;
|
|
138
|
-
});
|
|
139
|
-
if (!replaced) {
|
|
140
|
-
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
141
|
-
out.push(`${key}=${value}`);
|
|
142
|
-
out.push('');
|
|
143
|
-
}
|
|
144
|
-
return out.join('\n');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export async function run(argv) {
|
|
148
|
-
if (has(argv, '--help') || has(argv, '-h')) usage(0);
|
|
149
|
-
|
|
150
|
-
const pos = argv.filter((a) => !a.startsWith('-'));
|
|
151
|
-
const subverb = pos[0];
|
|
152
|
-
if (subverb !== 'paste') {
|
|
153
|
-
if (subverb) {
|
|
154
|
-
process.stderr.write(`crosstalk auth: unknown subverb '${subverb}'. Try 'paste'.\n`);
|
|
155
|
-
} else {
|
|
156
|
-
usage(1);
|
|
157
|
-
}
|
|
158
|
-
return 1;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const agent = flag(argv, '--agent');
|
|
162
|
-
if (!agent) {
|
|
163
|
-
process.stderr.write(`crosstalk auth paste: --agent <name> is required.\n`);
|
|
164
|
-
return 1;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (agent === 'agy') {
|
|
168
|
-
process.stderr.write(
|
|
169
|
-
`crosstalk auth paste: agy has no headless env-var path — its only auth is interactive OAuth.\n` +
|
|
170
|
-
` Use 'crosstalk chat --agent agy --login' to set up auth.\n`,
|
|
171
|
-
);
|
|
172
|
-
return 1;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const cfg = AGENT_ENV[agent];
|
|
176
|
-
if (!cfg) {
|
|
177
|
-
process.stderr.write(
|
|
178
|
-
`crosstalk auth paste: unknown agent '${agent}'.\n` +
|
|
179
|
-
` Supported: ${Object.keys(AGENT_ENV).join(', ')}.\n`,
|
|
180
|
-
);
|
|
181
|
-
return 1;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const { name, paths } = requireInitialized(argv);
|
|
185
|
-
|
|
186
|
-
if (process.stdin.isTTY) {
|
|
187
|
-
process.stderr.write(
|
|
188
|
-
`\nPasting ${cfg.label} for ${agent} into '${name}' container.\n` +
|
|
189
|
-
` Env var: ${cfg.default}\n` +
|
|
190
|
-
` Storage: ${paths.authEnvFile}\n` +
|
|
191
|
-
`\nGenerate on host:\n ${AGENT_GEN_CMD[agent] ?? '(see agent docs)'}\n` +
|
|
192
|
-
`\nPaste the value below (input hidden, press Enter):\n`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
const value = await promptHidden('> ');
|
|
196
|
-
if (!value) {
|
|
197
|
-
process.stderr.write(`crosstalk auth paste: empty input — aborted.\n`);
|
|
198
|
-
return 1;
|
|
199
|
-
}
|
|
200
|
-
if (value.includes('\n')) {
|
|
201
|
-
process.stderr.write(
|
|
202
|
-
`crosstalk auth paste: input contains a newline. Tokens are single-line; check copy/paste.\n`,
|
|
203
|
-
);
|
|
204
|
-
return 1;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const existing = existsSync(paths.authEnvFile)
|
|
208
|
-
? readFileSync(paths.authEnvFile, 'utf-8')
|
|
209
|
-
: '';
|
|
210
|
-
const updated = upsertEnv(existing, cfg.default, value);
|
|
211
|
-
writeFileSync(paths.authEnvFile, updated, { mode: 0o600 });
|
|
212
|
-
|
|
213
|
-
process.stdout.write(
|
|
214
|
-
`\nSaved ${cfg.default} to ${paths.authEnvFile}.\n` +
|
|
215
|
-
`Run 'crosstalk restart${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to pick up the new env.\n`,
|
|
216
|
-
);
|
|
217
|
-
return 0;
|
|
218
|
-
}
|