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
214 changes: 214 additions & 0 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1795,3 +1795,217 @@ test('relayfileOptionsResolver stops when the server repeats the same cursor', a
assert.equal(urls.length, 2);
assert.equal(new URL(urls[1]!).searchParams.get('cursor'), 'same');
});

// --- Daytona status-route contract (resolver-level) -----------------------
//
// Locks the exact `/integrations/daytona/status` response shape Cloud-Cred
// confirmed: connected emits ready:true / state:'ready' / oauth.connected:true
// (credential row + encrypted store object both present); missing either
// returns ready:false / state:'pending' / oauth.connected:false. These run the
// REAL relayfileIntegrationResolver (not a stub) so a drift in either side's
// contract fails here.

test('relayfileIntegrationResolver reads a connected daytona status from the credential-backed route', async () => {
let requestedUrl = '';
const resolver = relayfileIntegrationResolver({
apiUrl: 'https://cloud.example.test',
workspaceId: 'ws-1',
workspaceToken: 'tok',
fetch: async (input) => {
requestedUrl = String(input);
return okJson({
provider: 'daytona',
ready: true,
state: 'ready',
connectionMatched: true,
oauth: { connected: true }
});
}
});
assert.equal(
await resolver.isConnected({ workspace: 'ws-1', provider: 'daytona' }),
true
);
assert.match(requestedUrl, /\/workspaces\/ws-1\/integrations\/daytona\/status/);
assert.match(requestedUrl, /scope=deployer_user/);
});

test('relayfileIntegrationResolver treats a pending daytona status (no store object yet) as not connected', async () => {
const resolver = relayfileIntegrationResolver({
apiUrl: 'https://cloud.example.test',
workspaceId: 'ws-1',
workspaceToken: 'tok',
fetch: async () => okJson({
provider: 'daytona',
ready: false,
state: 'pending',
oauth: { connected: false }
})
});
assert.equal(
await resolver.isConnected({ workspace: 'ws-1', provider: 'daytona' }),
false
);
});

// --- Daytona connect (provider-agnostic walker proof) ---------------------
//
// daytona is captured out-of-band by `agent-relay cloud connect daytona`
// (sandbox `daytona login` → cloud stores provider 'daytona'), then surfaced
// to deploy via the SAME `/integrations/status` check every other provider
// uses. These tests prove the generic walker recognizes + gates `daytona`
// with zero provider-specific plumbing, exactly mirroring slack/github.
// See daytona-monitor/persona.json which declares `integrations.daytona`.

test('connectIntegrations treats a connected daytona integration as already-connected (no connect call)', async () => {
const io = createBufferedIO();
let connectCalled = false;
io.confirm = async () => {
throw new Error('should not prompt when daytona is already connected');
};

const result = await connectIntegrations({
persona: {
id: 'daytona-monitor',
intent: 'relay-orchestrator',
description: 'test persona',
tags: ['discovery'],
integrations: { daytona: { scope: { sandboxes: '/daytona/sandboxes/**' } } }
} as never,
workspace: 'ws-1',
noConnect: false,
io,
integrations: {
async isConnected(args) {
assert.equal(args.provider, 'daytona');
return true;
},
async connect() {
connectCalled = true;
return { connectionId: 'conn-daytona' };
}
}
});

assert.equal(connectCalled, false);
assert.deepEqual(result.outcomes, [{ provider: 'daytona', status: 'already-connected' }]);
assert.ok(io.messages.some((m) => m.level === 'info' && /daytona: already connected/.test(m.message)));
});

test('connectIntegrations gates the deploy when daytona is not connected under --no-prompt', async () => {
const io = createBufferedIO();
let connectCalled = false;

const result = await connectIntegrations({
persona: {
id: 'daytona-monitor',
intent: 'relay-orchestrator',
description: 'test persona',
tags: ['discovery'],
integrations: { daytona: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
noPrompt: true,
io,
integrations: {
async isConnected() {
return false;
},
async connect() {
connectCalled = true;
return { connectionId: 'conn-daytona' };
}
}
});

assert.equal(connectCalled, false);
assert.equal(result.outcomes.length, 1);
const [outcome] = result.outcomes;
assert.equal(outcome.provider, 'daytona');
assert.equal(outcome.status, 'failed');
assert.match(outcome.message ?? '', /not connected/i);
});

test('connectIntegrations instructs the relay CLI capture command when daytona is not connected (interactive)', async () => {
const io = createBufferedIO();
let connectCalled = false;
let confirmCalled = false;
io.confirm = async () => {
confirmCalled = true;
return true;
};

const result = await connectIntegrations({
persona: {
id: 'daytona-monitor',
intent: 'relay-orchestrator',
description: 'test persona',
tags: ['discovery'],
integrations: { daytona: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
io,
integrations: {
async isConnected() {
return false;
},
async connect() {
connectCalled = true;
return { connectionId: 'conn-daytona' };
}
}
});

// daytona is captured out-of-band: no browser prompt, no connect-session call.
assert.equal(confirmCalled, false);
assert.equal(connectCalled, false);
assert.equal(result.outcomes.length, 1);
const [outcome] = result.outcomes;
assert.equal(outcome.provider, 'daytona');
assert.equal(outcome.status, 'failed');
assert.match(outcome.message ?? '', /agent-relay cloud connect daytona/);
assert.ok(io.messages.some((m) =>
m.level === 'error' && /agent-relay cloud connect daytona/.test(m.message)
));
// Must NOT show the misleading browser prompt copy for a CLI-captured provider.
assert.ok(!io.messages.some((m) => /opens browser/.test(m.message)));
});

test('connectIntegrations --reconnect daytona instructs the relay CLI capture command (no browser connect)', async () => {
const io = createBufferedIO();
let connectCalled = false;

const result = await connectIntegrations({
persona: {
id: 'daytona-monitor',
intent: 'relay-orchestrator',
description: 'test persona',
tags: ['discovery'],
integrations: { daytona: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
reconnectProviders: ['daytona'],
io,
integrations: {
async isConnected() {
return true;
},
async connect() {
connectCalled = true;
return { connectionId: 'conn-daytona' };
}
}
});

// --reconnect for a CLI-captured provider can't run a browser flow either;
// it instructs the capture command and gates.
assert.equal(connectCalled, false);
assert.equal(result.outcomes.length, 1);
const [outcome] = result.outcomes;
assert.equal(outcome.provider, 'daytona');
assert.equal(outcome.status, 'failed');
assert.match(outcome.message ?? '', /agent-relay cloud connect daytona/);
});
40 changes: 40 additions & 0 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,27 @@ export interface ConnectAllResult {
subscriptionProvider?: string;
}

/**
* Providers whose credential is captured out-of-band by the relay CLI
* (`agent-relay cloud connect <provider>` → local `daytona login` →
* normalized token uploaded to the cloud credential-store) rather than the
* browser `connect-session` OAuth flow.
*
* For these, deploy cannot drive the connect inline: there is no Nango/OAuth
* session URL to open (the cloud returns `409 unknown_provider` for them).
* The walker instead instructs the user to run the capture command and gates
* the deploy until the credential exists. Status is still reported through the
* same `/integrations/status` check every other provider uses, so the
* `already-connected` path is identical to slack/github.
*
* Keep this in sync with the relay CLI's `CLI_AUTH_CONFIG` capture providers.
*/
const CLI_CAPTURED_PROVIDERS = new Set<string>(['daytona']);

function isCliCapturedProvider(provider: string): boolean {
return CLI_CAPTURED_PROVIDERS.has(provider);
}

/**
* Walk the persona's declared integrations and ensure each is connected.
* Per the deploy-v1 spec, the orchestrator prompts before each provider's
Expand Down Expand Up @@ -711,6 +732,25 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
continue;
}

if (isCliCapturedProvider(provider)) {
// No browser OAuth flow exists for relay-CLI-captured providers — the
// credential is captured out-of-band. Don't show a misleading "(opens
// browser)" prompt or call connect-session (which 409s); instruct the
// user to run the capture command and gate the deploy. This covers both
// the interactive not-connected path and `--reconnect <provider>` (which
// reaches here when forceReconnect is set).
const command = `agent-relay cloud connect ${provider}`;
input.io.error(
`integrations.${provider}: not connected. Run \`${command}\` to capture the credential, then re-deploy.`
);
outcomes.push({
provider,
status: 'failed',
message: `not connected (run \`${command}\`)`
});
continue;
}
Comment on lines +735 to +752

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isCliCapturedProvider(provider) check is currently executed after the noPrompt and noConnect checks.\n\nWhen --no-prompt or --no-connect is passed (which is common in CI/CD or non-interactive environments), the deployment will fail with a generic and misleading error message (e.g., telling the user to run without --no-prompt to connect inline), rather than instructing them to run the specific CLI command (agent-relay cloud connect <provider>).\n\nSince CLI-captured providers cannot be connected via the browser anyway, we should check isCliCapturedProvider(provider) before the noPrompt and noConnect checks so that the user always gets the correct, actionable instruction.\n\n### Suggested Placement\nMove this block to be the very first check after the statusCheckFailure checks (around line 710, before if (input.noPrompt && !forceReconnect)).


if (!forceReconnect) {
const shouldConnect = await input.io.confirm(
`Connect ${provider} now? (opens browser)`,
Expand Down
Loading