diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index c81d89d2..eceffb16 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -165,9 +165,14 @@ export class LoginSession { this.proc = this.deps.spawner(cmd.file, cmd.args, { cwd: this.deps.paths.home, env, - // Wide enough that the ~350-char authorize URL is emitted on one line; - // the default 80 cols wraps it and breaks URL extraction. - cols: 400, + // Wide enough that the authorize URL is emitted on ONE line. If the URL + // wraps, the `&state=...` tail lands on the next line and the parser's + // [^\s]+ stops at the break — the captured URL then lacks `state` and + // claude.ai rejects it ("Missing state parameter"). The Claude subscription + // authorize URL is ~450 chars (client_id + redirect_uri + the full scope + // set + code_challenge + state); 400 cols was too narrow and dropped state. + // 2000 leaves ample headroom. + cols: 2000, rows: 50, }) this.proc.onData((chunk) => this.onData(chunk, updatedBy)) diff --git a/services/agents-login/test/helpers/fakePty.ts b/services/agents-login/test/helpers/fakePty.ts index d3be0ec1..073cf1b9 100644 --- a/services/agents-login/test/helpers/fakePty.ts +++ b/services/agents-login/test/helpers/fakePty.ts @@ -1,4 +1,4 @@ -import type { PtyProcess, PtySpawner } from '../../src/worker/pty.js' +import type { PtyProcess, PtySpawner, PtySpawnOptions } from '../../src/worker/pty.js' /** * Fake PTY whose script is a list of programmed actions: emit output, or wait @@ -69,13 +69,16 @@ export class FakePty implements PtyProcess { export function fakeSpawner(scriptFor: (file: string, args: string[]) => Action[]): { spawner: PtySpawner instances: FakePty[] + spawns: Array<{ file: string; args: string[]; options: PtySpawnOptions }> } { const instances: FakePty[] = [] - const spawner: PtySpawner = (file, args) => { + const spawns: Array<{ file: string; args: string[]; options: PtySpawnOptions }> = [] + const spawner: PtySpawner = (file, args, options) => { + spawns.push({ file, args, options }) const pty = new FakePty(scriptFor(file, args)) instances.push(pty) pty.run() return pty } - return { spawner, instances } + return { spawner, instances, spawns } } diff --git a/services/agents-login/test/session.test.ts b/services/agents-login/test/session.test.ts index e48c5e40..16f9e8f8 100644 --- a/services/agents-login/test/session.test.ts +++ b/services/agents-login/test/session.test.ts @@ -145,8 +145,9 @@ describe('LoginSession state machine', () => { ): { deps: SessionDeps instances: ReturnType['instances'] + spawns: ReturnType['spawns'] } { - const { spawner, instances } = fakeSpawner(scriptFor) + const { spawner, instances, spawns } = fakeSpawner(scriptFor) return { deps: { spawner, @@ -157,9 +158,22 @@ describe('LoginSession state machine', () => { ttlMs: 60_000, }, instances, + spawns, } } + it('spawns the Claude login PTY wide enough that the authorize URL + state never wraps', () => { + // The subscription authorize URL is ~450 chars (scopes + code_challenge + + // state). A narrow PTY wraps it and the `&state=...` tail is dropped, so + // claude.ai rejects the URL with "Missing state parameter". Guard the width. + const { deps: d, spawns } = deps(() => [{ type: 'emit', data: '' }]) + const mgr = new SessionManager(d) + mgr.start('claude', 'alice') + const claudeSpawn = spawns.find((s) => s.file === 'claude') + expect(claudeSpawn).toBeDefined() + expect(claudeSpawn!.options.cols ?? 0).toBeGreaterThanOrEqual(1000) + }) + it('seeds Claude subscription login settings and drives bounded onboarding Enter-through', async () => { vi.useFakeTimers() try {