Skip to content

Commit 8518ec8

Browse files
authored
fix(amr): recover late login failures and trace auth stages (#5986)
* fix(amr): recover late login failures and trace auth stages * fix(amr): preserve scoped cancel attempt IDs Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): defer unsupported Vela auth stages Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): narrow fallback telemetry to shipped Vela Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): preserve live login after stale cancel Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): preserve cancel during login startup Retain cancellation intent when a provisional cancel races the delayed canonical login response, then cancel the canonical attempt before polling can begin. Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): rejoin newer login after startup cancel Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): preserve cancel across web login starts Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): retain cancel when status refresh fails Generated-By: looper 0.11.1 (runner=fixer, agent=codex) * fix(amr): cancel onboarding status preflight Treat cancellation before an auth attempt exists as a local preflight cancel so a delayed status response cannot start login.\n\nGenerated-By: looper 0.11.1 (runner=fixer, agent=codex)
1 parent 2097115 commit 8518ec8

22 files changed

Lines changed: 2852 additions & 263 deletions

apps/daemon/src/integrations/vela.ts

Lines changed: 506 additions & 84 deletions
Large diffs are not rendered by default.

apps/daemon/src/routes/vela.ts

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Express, Request, Response } from 'express';
2+
import { randomUUID } from 'node:crypto';
23
import dns from 'node:dns';
34
import http from 'node:http';
45
import https from 'node:https';
@@ -23,6 +24,8 @@ import {
2324
mirrorAmrOnboardingProfileAnalytics,
2425
parseAmrEntryAnalyticsPayload,
2526
parseAmrOnboardingProfileAnalyticsPayload,
27+
parseVelaAuthAttemptId,
28+
parseVelaAuthRequestId,
2629
applyVelaLiveAccount,
2730
clearAllVelaLiveAccounts,
2831
parseVelaLoginAttribution,
@@ -31,10 +34,11 @@ import {
3134
readVelaCredentialRevision,
3235
readVelaControlApiContext,
3336
readVelaLoginStatus,
37+
readVelaLoginAttemptSnapshot,
3438
setVelaLiveAccount,
3539
shouldRefreshVelaLiveAccount,
3640
velaLiveAccountCacheKey,
37-
spawnVelaLogin,
41+
spawnVelaLoginWithFallback,
3842
type VelaLiveAccount,
3943
} from '../integrations/vela.js';
4044
import {
@@ -497,6 +501,20 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
497501
});
498502

499503
app.post('/api/integrations/vela/login', async (req, res) => {
504+
// Resolve a request-owned correlation id before any config or spawn work.
505+
// A pre-spawn failure must never inherit the previous login's snapshot.
506+
const requestAuthAttemptId = parseVelaAuthAttemptId(req.body) ?? randomUUID();
507+
const requestAuthRequestId = parseVelaAuthRequestId(req.body);
508+
const bodyHasRequestId = Boolean(
509+
req.body
510+
&& typeof req.body === 'object'
511+
&& !Array.isArray(req.body)
512+
&& Object.prototype.hasOwnProperty.call(req.body, 'authRequestId'),
513+
);
514+
if (bodyHasRequestId && !requestAuthRequestId) {
515+
res.status(400).json({ error: 'invalid_auth_request_id' });
516+
return;
517+
}
500518
try {
501519
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);
502520
const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr');
@@ -525,43 +543,78 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
525543
// 飞连/CorpLink → 30.x), that extra hop makes the upstream lose the
526544
// client IP and reject device authorization with
527545
// "502: Invalid IP address: undefined", even though the direct path
528-
// resolves fine. So only fall back to the proxy when the direct attempt
529-
// fails to start — never when a login is already in flight.
530-
let spawned;
531-
try {
532-
spawned = await spawnVelaLogin({
533-
configuredEnv,
534-
attribution: loginAttribution,
535-
correlationEnv,
536-
// Block until the direct attempt reaches device-auth steady state or
537-
// exits/errors before it, so a direct failure that arrives AFTER the
538-
// 250ms startup grace (the common shape on a broken edge path) still
539-
// falls through to the proxy retry below instead of returning 202.
540-
waitForActivation: true,
541-
});
542-
} catch (directErr) {
543-
const directMessage =
544-
directErr instanceof Error ? directErr.message : String(directErr);
545-
if (/already running/i.test(directMessage)) throw directErr;
546-
spawned = await spawnVelaLogin({
547-
configuredEnv,
548-
attribution: loginAttribution,
549-
correlationEnv,
550-
defaultApiUrl: velaApiProxyBaseUrl(req, getPublicBaseUrl),
551-
waitForActivation: true,
552-
});
553-
}
554-
res.status(202).json(spawned);
546+
// resolves fine. So only fall back to the proxy when the direct child
547+
// actually terminates before activation (including after this request
548+
// returns) — never merely because it is slow, already activated, or a
549+
// login is already in flight.
550+
const spawned = await spawnVelaLoginWithFallback({
551+
authAttemptId: requestAuthAttemptId,
552+
authRequestId: requestAuthRequestId,
553+
configuredEnv,
554+
attribution: loginAttribution,
555+
correlationEnv,
556+
proxyApiUrl: velaApiProxyBaseUrl(req, getPublicBaseUrl),
557+
// Block until the direct attempt reaches device-auth steady state or
558+
// exits/errors before it. If it remains alive beyond this grace, the
559+
// attempt supervisor keeps watching after this route returns and owns
560+
// a single non-overlapping proxy retry on a later pre-activation exit.
561+
waitForActivation: true,
562+
});
563+
const snapshot = readVelaLoginAttemptSnapshot();
564+
res.status(202).json({
565+
...spawned,
566+
...(snapshot.authAttemptId === requestAuthAttemptId ? snapshot : {}),
567+
});
555568
} catch (err) {
556569
const message = err instanceof Error ? err.message : String(err);
557570
const status = /already running/i.test(message) ? 409 : 500;
558-
res.status(status).json({ error: message });
571+
const snapshot = readVelaLoginAttemptSnapshot();
572+
// 409 intentionally joins the already-running attempt so concurrent UI
573+
// initiators can reconcile to its canonical id. Every other failure only
574+
// exposes state created for this request; config/read failures before
575+
// beginVelaLoginAttempt therefore cannot contaminate analytics.
576+
const responseSnapshot = status === 409
577+
|| snapshot.authAttemptId === requestAuthAttemptId
578+
? snapshot
579+
: {};
580+
res.status(status).json({
581+
error: message,
582+
...responseSnapshot,
583+
});
559584
}
560585
});
561586

562-
app.post('/api/integrations/vela/login/cancel', (_req, res) => {
587+
app.post('/api/integrations/vela/login/cancel', (req, res) => {
563588
try {
564-
res.json(cancelVelaLogin());
589+
const bodyHasAttemptId = Boolean(
590+
req.body
591+
&& typeof req.body === 'object'
592+
&& !Array.isArray(req.body)
593+
&& Object.prototype.hasOwnProperty.call(req.body, 'authAttemptId'),
594+
);
595+
const authAttemptId = parseVelaAuthAttemptId(req.body);
596+
const bodyHasRequestId = Boolean(
597+
req.body
598+
&& typeof req.body === 'object'
599+
&& !Array.isArray(req.body)
600+
&& Object.prototype.hasOwnProperty.call(req.body, 'authRequestId'),
601+
);
602+
const authRequestId = parseVelaAuthRequestId(req.body);
603+
if (
604+
(bodyHasAttemptId && !authAttemptId)
605+
|| (bodyHasRequestId && !authRequestId)
606+
|| (bodyHasAttemptId && bodyHasRequestId)
607+
) {
608+
res.status(400).json({ error: 'invalid_auth_attempt_id' });
609+
return;
610+
}
611+
// No body remains a compatibility path for older web clients. New
612+
// callers always target the attempt they observed so a delayed cancel
613+
// can never terminate a newer login.
614+
res.json(cancelVelaLogin(
615+
authAttemptId ?? undefined,
616+
authRequestId ?? undefined,
617+
));
565618
} catch (err) {
566619
res.status(500).json({ error: String(err) });
567620
}

apps/daemon/tests/fixtures/fake-vela.mjs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
*/
5454

5555
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
56+
import { spawn as spawnChild } from 'node:child_process';
5657
import { homedir } from 'node:os';
5758
import { dirname, join } from 'node:path';
5859
import { argv, stdin, stdout, stderr, env, exit } from 'node:process';
@@ -348,6 +349,61 @@ stdin.on('end', () => {
348349
// handler above ignores login mode so delayed login tests can keep this
349350
// process alive without opening the ACP stdio bridge.
350351
function loginAndExit() {
352+
const logLoginLifecycle = (event) => {
353+
if (!env.FAKE_VELA_LOGIN_INVOCATION_LOG) return;
354+
appendFileSync(env.FAKE_VELA_LOGIN_INVOCATION_LOG, `${JSON.stringify({
355+
event,
356+
route: (env.VELA_API_URL ?? '').trim() ? 'proxy' : 'direct',
357+
})}\n`);
358+
};
359+
logLoginLifecycle('start');
360+
if (
361+
env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_DELAY_MS
362+
&& !(env.VELA_API_URL ?? '').trim()
363+
) {
364+
const delayMs = Number(env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_DELAY_MS) || 1;
365+
const exitCode = Number(env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_CODE) || 0;
366+
const activationBlock = [
367+
'Open this URL to continue:',
368+
'https://fake-vela.example/cli/activate?deviceId=activation-then-exit',
369+
'',
370+
'Code: ACTIVATE-EXIT',
371+
'',
372+
].join('\n');
373+
setTimeout(() => {
374+
stdout.write(activationBlock, () => {
375+
logLoginLifecycle('exit');
376+
exit(exitCode);
377+
});
378+
}, delayMs);
379+
return;
380+
}
381+
if (
382+
env.FAKE_VELA_LOGIN_ACTIVATION_AFTER_PARENT_EXIT_MS
383+
&& !(env.VELA_API_URL ?? '').trim()
384+
) {
385+
const delayMs = Number(env.FAKE_VELA_LOGIN_ACTIVATION_AFTER_PARENT_EXIT_MS) || 50;
386+
const activationBlock = [
387+
'Open this URL to continue:',
388+
'https://fake-vela.example/cli/activate?deviceId=late-drain',
389+
'',
390+
'Code: LATE-DRAIN',
391+
'',
392+
].join('\n');
393+
const exitParent = () => {
394+
const grandchild = spawnChild(
395+
process.execPath,
396+
['-e', `setTimeout(() => process.stdout.write(${JSON.stringify(activationBlock)}), ${delayMs})`],
397+
{ stdio: ['ignore', stdout, stderr] },
398+
);
399+
grandchild.unref();
400+
exit(0);
401+
};
402+
const parentDelayMs = Number(env.FAKE_VELA_LOGIN_PARENT_EXIT_DELAY_MS) || 0;
403+
if (parentDelayMs > 0) setTimeout(exitParent, parentDelayMs);
404+
else exitParent();
405+
return;
406+
}
351407
if (env.FAKE_VELA_LOGIN_FAIL) {
352408
stderr.write(`${env.FAKE_VELA_LOGIN_FAIL}\n`);
353409
exit(1);
@@ -356,6 +412,17 @@ function loginAndExit() {
356412
// (#3726): fail unless the daemon routed login through its IPv4 API proxy
357413
// (which sets VELA_API_URL). Lets tests assert the direct-first / proxy-
358414
// fallback contract of the login route.
415+
if (
416+
env.FAKE_VELA_LOGIN_EXIT_ZERO_WITHOUT_API_URL_DELAY_MS &&
417+
!(env.VELA_API_URL ?? '').trim()
418+
) {
419+
const delayMs = Number(env.FAKE_VELA_LOGIN_EXIT_ZERO_WITHOUT_API_URL_DELAY_MS) || 0;
420+
setTimeout(() => {
421+
logLoginLifecycle('exit');
422+
exit(0);
423+
}, delayMs);
424+
return;
425+
}
359426
if (
360427
env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL &&
361428
!(env.VELA_API_URL ?? '').trim()
@@ -368,11 +435,13 @@ function loginAndExit() {
368435
if (failDelayMs > 0) {
369436
setTimeout(() => {
370437
stderr.write(`${env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL}\n`);
438+
logLoginLifecycle('exit');
371439
exit(1);
372440
}, failDelayMs);
373441
return;
374442
}
375443
stderr.write(`${env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL}\n`);
444+
logLoginLifecycle('exit');
376445
exit(1);
377446
}
378447
if (env.FAKE_VELA_ENV_DUMP_PATH) {

0 commit comments

Comments
 (0)