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
78 changes: 78 additions & 0 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,84 @@ test('connectIntegrations fails status-check errors without opening a connect fl
assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('failed while checking connection status')));
});

test('connectIntegrations fails useSubscription without a resolver before integration checks', async () => {
const io = createBufferedIO();
let integrationChecked = false;
let integrationConnected = false;

await assert.rejects(
connectIntegrations({
persona: {
id: 'essay',
intent: 'essay',
description: 'test persona',
tags: ['implementation'],
useSubscription: true,
integrations: { notion: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
io,
integrations: {
async isConnected() {
integrationChecked = true;
return false;
},
async connect() {
integrationConnected = true;
return { connectionId: 'conn-notion' };
}
}
}),
/useSubscription:true.*no subscription connector/
);

assert.equal(integrationChecked, false);
assert.equal(integrationConnected, false);
});

test('connectIntegrations connects subscription provider before integration checks', async () => {
const io = createBufferedIO();
const order: string[] = [];

const result = await connectIntegrations({
persona: {
id: 'essay',
intent: 'essay',
description: 'test persona',
tags: ['implementation'],
useSubscription: true,
integrations: { notion: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
io,
integrations: {
async isConnected() {
order.push('integration-check');
return true;
},
async connect() {
order.push('integration-connect');
return { connectionId: 'conn-notion' };
}
},
subscription: {
async isConnected() {
order.push('subscription-check');
return false;
},
async connect() {
order.push('subscription-connect');
return { provider: 'anthropic' };
}
}
});

assert.deepEqual(order, ['subscription-check', 'subscription-connect', 'integration-check']);
assert.equal(result.subscriptionProvider, 'anthropic');
});

test('connectIntegrations honors --no-prompt for subscription provider setup', async () => {
const io = createBufferedIO();
let confirmCalled = false;
Expand Down
89 changes: 50 additions & 39 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,12 @@ export interface ConnectAllResult {
export async function connectIntegrations(input: ConnectAllInput): Promise<ConnectAllResult> {
const integrations = input.persona.integrations ?? {};
const outcomes: IntegrationConnectOutcome[] = [];
const subscription = input.persona.useSubscription
? requireSubscriptionResolver(input.persona.id, input.subscription)
: undefined;
const subscriptionProvider = subscription
? await connectSubscriptionProvider(input, subscription)
: undefined;

for (const provider of Object.keys(integrations)) {
const integrationEntry = integrations[provider] ?? {};
Expand Down Expand Up @@ -447,51 +453,56 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
}
}

// Track the subscription provider only when this deploy actually
// connected one — already-connected cases stay logged but do not
// leak a sentinel string up to callers reading `subscriptionProvider`.
let subscriptionProvider: string | undefined;
if (input.persona.useSubscription) {
if (!input.subscription) {
throw new Error(
'persona has useSubscription:true but no subscription resolver was supplied to the deploy orchestrator'
);
}
const isConn = await input.subscription
.isConnected({ workspace: input.workspace })
.catch(() => false);
if (!isConn) {
if (input.noPrompt) {
throw new Error(
'persona requires a subscription provider connection, but --no-prompt was passed. Connect it before deploying or run without --no-prompt.'
);
}
if (input.noConnect) {
throw new Error(
'persona requires a subscription provider connection, but --no-connect was passed'
);
}
const ok = await input.io.confirm(
'persona has useSubscription:true — connect your LLM provider now?',
{ defaultValue: true }
);
if (!ok) {
throw new Error('user declined the subscription provider connect; deploy aborted');
}
const result = await input.subscription.connect({ workspace: input.workspace });
subscriptionProvider = result.provider;
input.io.info(`subscription: connected (${result.provider})`);
} else {
input.io.info('subscription: already connected');
}
}

return {
outcomes,
...(subscriptionProvider ? { subscriptionProvider } : {})
};
}

async function connectSubscriptionProvider(
input: ConnectAllInput,
subscription: ProviderSubscriptionResolver
): Promise<string | undefined> {
const isConn = await subscription
.isConnected({ workspace: input.workspace })
.catch(() => false);
if (isConn) {
input.io.info('subscription: already connected');
return undefined;
}
if (input.noPrompt) {
throw new Error(
'persona requires a subscription provider connection, but --no-prompt was passed. Connect it before deploying or run without --no-prompt.'
);
}
if (input.noConnect) {
throw new Error(
'persona requires a subscription provider connection, but --no-connect was passed'
);
}
const ok = await input.io.confirm(
'persona has useSubscription:true — connect your LLM provider now?',
{ defaultValue: true }
);
if (!ok) {
throw new Error('user declined the subscription provider connect; deploy aborted');
}
const result = await subscription.connect({ workspace: input.workspace });
input.io.info(`subscription: connected (${result.provider})`);
return result.provider;
}

function requireSubscriptionResolver(
personaId: string,
subscription: ProviderSubscriptionResolver | undefined
): ProviderSubscriptionResolver {
if (subscription) return subscription;
throw new Error(
`persona "${personaId}" sets useSubscription:true, but no subscription connector is available. ` +
'Use the deploy orchestrator cloud mode, provide a subscription resolver, or remove useSubscription to use workforce-billed inference.'
);
}

async function checkProviderConnected(
input: ConnectAllInput,
provider: string,
Expand Down
160 changes: 160 additions & 0 deletions packages/deploy/src/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,166 @@ test('deploy --dry-run validates persona and exits before side effects', async (
}
});

test('deploy --dry-run accepts useSubscription personas in cloud mode', async () => {
const { personaPath, cleanup } = await withTempPersona(
basePersonaJson({ useSubscription: true })
);
const io = createBufferedIO();
try {
const result = await deploy({ personaPath, mode: 'cloud', dryRun: true, io });
assert.equal(result.deploymentId, 'demo');
assert.equal(result.mode, 'cloud');
assert.ok(io.messages.find((m) => m.message.includes('--dry-run')));
assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:')));
} finally {
await cleanup();
}
});

test('deploy --dry-run rejects useSubscription when cloud mode is not selected', async () => {
const { personaPath, cleanup } = await withTempPersona(
basePersonaJson({ useSubscription: true })
);
const io = createBufferedIO();
try {
await assert.rejects(
deploy({ personaPath, mode: 'dev', dryRun: true, io }),
/requires --mode cloud/
);
assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:')));
} finally {
await cleanup();
}
});

test('deploy --dry-run rejects useSubscription with workforce plan credentials', async () => {
const { personaPath, cleanup } = await withTempPersona(
basePersonaJson({ useSubscription: true })
);
const io = createBufferedIO();
try {
await assert.rejects(
deploy({ personaPath, mode: 'cloud', dryRun: true, harnessSource: 'plan', io }),
/use --harness-source oauth/
);
assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:')));
} finally {
await cleanup();
}
});

test('deploy accepts useSubscription when a subscription resolver is supplied', async () => {
const { personaPath, cleanup } = await withTempPersona(
basePersonaJson({ useSubscription: true })
);
const io = createBufferedIO();
try {
const result = await deploy(
{ personaPath, dryRun: true, io },
{
subscription: {
async isConnected() {
throw new Error('dry-run should not check subscription status');
},
async connect() {
throw new Error('dry-run should not connect subscriptions');
}
}
}
);
assert.equal(result.deploymentId, 'demo');
assert.ok(io.messages.find((m) => m.message.includes('--dry-run')));
} finally {
await cleanup();
}
});

test('deploy prepares useSubscription BYOK credentials before integration side effects', async () => {
const { personaPath, cleanup } = await withTempPersona(
basePersonaJson({
useSubscription: true,
integrations: { github: { triggers: [{ on: 'pull_request.opened' }] } }
})
);
const io = createBufferedIO();
const originalFetch = globalThis.fetch;
const order: string[] = [];
let launchedSelections: Record<string, string> | undefined;
try {
globalThis.fetch = (async (input, init) => {
const url = String(input);
if (url.endsWith('/provider-credentials/byok')) {
order.push('subscription-byok');
assert.equal(init?.method, 'POST');
assert.deepEqual(JSON.parse(String(init?.body)), {
modelProvider: 'anthropic',
model_provider: 'anthropic',
key: 'sk-test',
api_key: 'sk-test'
});
return jsonResponse({ providerCredentialId: 'cred-byok' }, 201);
}
throw new Error(`unexpected URL ${url}`);
}) as typeof fetch;

const result = await deploy(
{
personaPath,
mode: 'cloud',
harnessSource: 'byok',
byokKey: 'sk-test',
cloudUrl: 'https://cloud.example.test',
io
},
{
workspaceAuth: {
async resolveWorkspace() {
order.push('workspace');
return { workspace: 'ws-test', token: 'tok' };
}
},
providerConfigKeys: {
async resolve() {
return undefined;
}
},
integrations: {
async isConnected() {
order.push('integration-check');
return true;
},
async connect() {
order.push('integration-connect');
return { connectionId: 'conn-github' };
}
},
bundle: successfulBundleStager(),
modes: {
cloud: {
async launch(input) {
order.push('launch');
launchedSelections = input.credentialSelections;
return {
id: 'cloud-1',
async stop() {
/* no-op */
},
done: Promise.resolve({ code: 0 })
};
}
}
}
}
);
assert.equal(result.deploymentId, 'demo');
assert.deepEqual(order, ['workspace', 'subscription-byok', 'integration-check', 'launch']);
assert.deepEqual(launchedSelections, { anthropic: 'cred-byok' });
} finally {
globalThis.fetch = originalFetch;
await cleanup();
}
});

test('deploy fails clearly when integration is not connected and --no-connect is set', async () => {
const { personaPath, cleanup } = await withTempPersona(
basePersonaJson({ integrations: { github: { triggers: [{ on: 'pull_request.opened' }] } } })
Expand Down
Loading
Loading