diff --git a/src/cli/commands/on/start.test.ts b/src/cli/commands/on/start.test.ts index 271db1d4..be223953 100644 --- a/src/cli/commands/on/start.test.ts +++ b/src/cli/commands/on/start.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; vi.mock('@agent-relay/cloud', () => ({ + readStoredAuth: vi.fn().mockResolvedValue(null), ensureAuthenticated: vi.fn().mockResolvedValue({ accessToken: 'test-token' }), })); diff --git a/src/cli/commands/on/start.ts b/src/cli/commands/on/start.ts index ef36733c..9fac0e27 100644 --- a/src/cli/commands/on/start.ts +++ b/src/cli/commands/on/start.ts @@ -19,7 +19,7 @@ import { compileDotfiles, hasDotfiles } from './dotfiles.js'; import { ensureRelayfileMountBinary } from './relayfile-binary.js'; import { mintToken } from './token.js'; import { seedWorkspace as seedWorkspaceFiles, seedAclRules } from './workspace.js'; -import { ensureAuthenticated } from '@agent-relay/cloud'; +import { ensureAuthenticated, readStoredAuth } from '@agent-relay/cloud'; interface OnOptions { agent?: string; @@ -243,7 +243,10 @@ function readWorkspaceRegistry(relayDir?: string): LocalWorkspaceRegistry { function writeWorkspaceRegistry(relayDir: string, registry: LocalWorkspaceRegistry): void { ensureDirectory(relayDir); - writeFileSync(getWorkspaceRegistryPath(relayDir), `${JSON.stringify(registry, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + writeFileSync(getWorkspaceRegistryPath(relayDir), `${JSON.stringify(registry, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); } function updateWorkspaceRegistry( @@ -280,19 +283,36 @@ async function postWorkspaceApi( 'X-Correlation-Id': `agent-relay-on-${Date.now()}`, }; - // Attach cloud auth token for remote endpoints + // For remote endpoints, try anonymous first — attach existing auth if + // available but never force a browser login. If the server returns 401, + // fall back to interactive login and retry once. if (!isLocalBaseUrl(url)) { + const stored = await readStoredAuth().catch(() => null); const parsed = new URL(url); - const auth = await ensureAuthenticated(`${parsed.protocol}//${parsed.host}`); - headers['Authorization'] = `Bearer ${auth.accessToken}`; + const targetOrigin = `${parsed.protocol}//${parsed.host}`; + if (stored && stored.apiUrl === targetOrigin) { + headers['Authorization'] = `Bearer ${stored.accessToken}`; + } } - const response = await fetchFn(url, { + let response = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body), }); + // Retry with interactive login if the server requires auth + if (response.status === 401 && !isLocalBaseUrl(url)) { + const parsed = new URL(url); + const auth = await ensureAuthenticated(`${parsed.protocol}//${parsed.host}`); + headers['Authorization'] = `Bearer ${auth.accessToken}`; + response = await fetchFn(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + } + const raw = await response.text(); if (!response.ok) { throw new Error(`workspace API request failed (${response.status}): ${raw}`.trim()); @@ -617,7 +637,10 @@ function loadConfigFromFile(configPath: string, projectDir: string): RelayConfig const fallbackWorkspace = path.basename(projectDir); const workspace = toString(payload.workspace, toString(root.workspace, fallbackWorkspace)); - const signing_secret = toString(payload.signing_secret, toString(root.signing_secret, process.env.SIGNING_KEY ?? '')); + const signing_secret = toString( + payload.signing_secret, + toString(root.signing_secret, process.env.SIGNING_KEY ?? '') + ); if (!signing_secret) { throw new Error( `relay config at ${configPath} is missing signing_secret and SIGNING_KEY env var is not set. ` + @@ -1028,7 +1051,11 @@ interface GoOnRelayDeps { exit?: (code: number) => never | void; fetch?: FetchFn; provision?: (config: RelayConfig, agent: RelayConfigAgent) => Promise; - provisionAgentToken?: (opts: { config: RelayConfig; agent: RelayConfigAgent; tokenPath: string }) => Promise; + provisionAgentToken?: (opts: { + config: RelayConfig; + agent: RelayConfigAgent; + tokenPath: string; + }) => Promise; ensureServicesRunning?: (authBase: string, fileBase: string) => Promise; startServices?: (opts: { authBase: string; fileBase: string }) => Promise; } @@ -1208,16 +1235,14 @@ export async function goOnTheRelay( // Compile dotfile permissions for this agent const hasDots = hasDotfiles(projectDir); - const dotfileAcl = hasDots - ? compileDotfiles(projectDir, agent.name, workspaceSession.workspaceId) - : null; + const dotfileAcl = hasDots ? compileDotfiles(projectDir, agent.name, workspaceSession.workspaceId) : null; if (workspaceSession.created) { const seedExcludes = [...DEFAULT_SEED_EXCLUDES]; if (dotfileAcl) { // Add ignored patterns so ignored files are never uploaded for (const [dir, rules] of Object.entries(dotfileAcl.acl)) { - if (rules.some(r => r.startsWith('deny:agent:'))) { + if (rules.some((r) => r.startsWith('deny:agent:'))) { seedExcludes.push(dir.replace(/^\//, '')); } } @@ -1240,12 +1265,20 @@ export async function goOnTheRelay( // Write compiled ACL for mount to read const bundlePath = path.join(relayDir, 'compiled-acl.json'); - writeFileSync(bundlePath, JSON.stringify({ - workspace: workspaceSession.workspaceId, - acl: dotfileAcl.acl, - summary: dotfileAcl.summary, - agents: [{ name: agent.name, summary: dotfileAcl.summary }], - }, null, 2) + '\n', { encoding: 'utf8' }); + writeFileSync( + bundlePath, + JSON.stringify( + { + workspace: workspaceSession.workspaceId, + acl: dotfileAcl.acl, + summary: dotfileAcl.summary, + agents: [{ name: agent.name, summary: dotfileAcl.summary }], + }, + null, + 2 + ) + '\n', + { encoding: 'utf8' } + ); } } @@ -1396,10 +1429,18 @@ export async function goOnTheRelay( // Wait for the agent process to exit so agentExitCode is set by the close handler, // then ensure cleanup completes before resolving — avoids data loss from premature exit cleanupInProgress = new Promise((r) => { - if (!agentProc || agentProc.exitCode !== null) { r(); return; } + if (!agentProc || agentProc.exitCode !== null) { + r(); + return; + } const t = setTimeout(r, 2000); - agentProc.once('close', () => { clearTimeout(t); r(); }); - }).then(() => finalizeCleanup()).then(() => resolve()); + agentProc.once('close', () => { + clearTimeout(t); + r(); + }); + }) + .then(() => finalizeCleanup()) + .then(() => resolve()); }; process.once('SIGINT', cleanupHook);