Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/commands/on/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}));

Expand Down
83 changes: 62 additions & 21 deletions src/cli/commands/on/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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;
Expand Down Expand Up @@ -243,7 +243,10 @@

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(
Expand Down Expand Up @@ -280,19 +283,36 @@
'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());
Expand Down Expand Up @@ -495,7 +515,7 @@
};
}

export async function requestWorkspaceSession(options: WorkspaceSessionRequest): Promise<WorkspaceSession> {

Check warning on line 518 in src/cli/commands/on/start.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'requestWorkspaceSession' has a complexity of 17. Maximum allowed is 15
const fetchFn = options.fetchFn ?? fetch;
const requestedWorkspaceId = normalizeWorkspaceId(options.requestedWorkspaceId);

Expand Down Expand Up @@ -617,7 +637,10 @@
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. ` +
Expand Down Expand Up @@ -1028,7 +1051,11 @@
exit?: (code: number) => never | void;
fetch?: FetchFn;
provision?: (config: RelayConfig, agent: RelayConfigAgent) => Promise<void>;
provisionAgentToken?: (opts: { config: RelayConfig; agent: RelayConfigAgent; tokenPath: string }) => Promise<string | undefined>;
provisionAgentToken?: (opts: {
config: RelayConfig;
agent: RelayConfigAgent;
tokenPath: string;
}) => Promise<string | undefined>;
ensureServicesRunning?: (authBase: string, fileBase: string) => Promise<void>;
startServices?: (opts: { authBase: string; fileBase: string }) => Promise<void>;
}
Expand Down Expand Up @@ -1208,16 +1235,14 @@

// 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(/^\//, ''));
}
}
Expand All @@ -1240,12 +1265,20 @@

// 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' }
);
}
}

Expand Down Expand Up @@ -1396,10 +1429,18 @@
// 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<void>((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);
Expand Down
Loading