diff --git a/.specs/kiloclaw-composio.md b/.specs/kiloclaw-composio.md index 44e13e0d0d..e4eeb29903 100644 --- a/.specs/kiloclaw-composio.md +++ b/.specs/kiloclaw-composio.md @@ -2,254 +2,84 @@ ## Role of This Document -This spec defines the business rules and invariants for integrating -Composio with KiloClaw. It is the source of truth for what the system -must guarantee about Composio credential ownership, OpenClaw instance -injection, manual configuration, managed provisioning, organization -sharing, and connection onboarding. +This spec defines the business rules and invariants for user-provided Composio CLI credentials in KiloClaw settings and the retirement of the removed managed Composio onboarding experiment. It is the source of truth for what the system must guarantee about credential ownership, encrypted instance injection, cleanup of managed credentials previously created by Kilo, and logging boundaries. -It deliberately does not prescribe how to implement those guarantees: -column layouts, endpoint names, controller helper names, and UI component -structure belong in plan documents and code, not here. +It deliberately does not prescribe implementation details such as endpoint names, column layouts, cleanup command names, or controller helper structure. ## Status -Draft -- proposed for PR #3348. Not yet shipped. +Draft -- created for managed Composio onboarding in PR #3348 on 2026-05-20. +Updated 2026-05-27 -- retired managed onboarding; retained user-provided Settings configuration only. ## Conventions -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", -"SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and -"OPTIONAL" in this document are to be interpreted as described in -BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in all -capitals, as shown here. +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in all capitals, as shown here. ## Definitions -- **Composio CLI credentials**: The Composio user API key and - organization identifier required to sign the `composio` CLI into a - Composio account or organization. -- **Manual Composio configuration**: User-provided Composio CLI - credentials entered through KiloClaw and injected into an OpenClaw - instance. -- **Managed Composio identity**: A Composio identity provisioned by Kilo - on behalf of a Kilo user or a Kilo user's organization context, with - credentials stored by Kilo and reused across KiloClaw instance - lifecycles. -- **Owner scope**: The Kilo ownership boundary for a Composio identity. - The supported scopes are a personal Kilo user and a Kilo organization - user context: one specific Kilo user acting inside one specific Kilo - organization. -- **Connected account**: A Composio record representing a user's - authorization to an external toolkit such as Google Calendar, Gmail, - GitHub, or Slack within a personal or organization context. -- **Connect Link**: A Composio-hosted authentication URL used to connect - an external toolkit account to a Composio user/context. -- **OpenClaw instance**: The Fly Machine-backed KiloClaw environment - where OpenClaw and the `composio` CLI run. -- **Kilo central Composio credential**: Any Composio credential owned by - Kilo as an operator/developer rather than by a specific Kilo user or - Kilo organization-user owner scope. +- **Composio CLI credentials**: The Composio user API key and organization identifier required to sign the `composio` CLI into a Composio account or organization. +- **Manual Composio configuration**: User-provided Composio CLI credentials saved through KiloClaw Settings and injected into an OpenClaw instance. +- **Retired managed Composio identity**: A Kilo-created Composio identity or connected-account record created during the removed managed onboarding experiment. +- **OpenClaw instance**: The provider-backed KiloClaw environment where OpenClaw and the `composio` CLI run. ## Overview -KiloClaw can expose Composio to OpenClaw instances in two ways. First, -users may manually provide Composio CLI credentials; KiloClaw injects -those credentials into the instance and signs the local `composio` CLI in -during controller bootstrap. This makes Composio available without Kilo -provisioning or owning a Composio identity. - -Kilo may also provision managed Composio identities during onboarding. -Managed personal identities are reused across new personal KiloClaw -instances created after a prior instance is destroyed. Managed -organization-context identities are scoped per organization user, not -shared across the whole organization: the same Kilo user receives a -distinct Composio identity for each organization context where they use -KiloClaw. Kilo may create Connect Links during onboarding so external -toolkit connections can be completed before the OpenClaw instance is fully -provisioned. +KiloClaw supports Composio only as an explicitly user-configured Settings secret. A user can enter their Composio CLI credentials, which are validated, encrypted, delivered through the existing instance secret pipeline, and used by the controller to make Composio available inside their instance. + +Kilo previously shipped a managed Composio onboarding experiment that created Kilo-owned identities and Connect Links. That behavior is retired. Kilo must not create or inject new managed Composio credentials. After new creation is disabled, Kilo must verify whether any active instance retains managed credentials, clear any confirmed runtime residue, and remove obsolete stored managed identity state. ## Rules ### Manual Configuration -1. Manual Composio configuration MUST be opt-in. An OpenClaw instance - without both a Composio user API key and Composio organization value - MUST continue to boot without Composio CLI sign-in. -2. Manual Composio credentials MUST be treated as user-provided secrets. - The Composio user API key MUST be encrypted at rest before it reaches - the KiloClaw worker and MUST be delivered to the machine through the - existing encrypted environment variable pipeline. -3. The Composio organization value MAY be less sensitive than the user - API key, but when collected with the Composio integration it SHOULD be - stored and transported through the same secret path to avoid exposing - account metadata unnecessarily. -4. The controller MUST NOT log Composio user API keys, generated login - commands containing those keys, Connect Links containing secret - material, OAuth tokens, or raw Composio credentials. -5. When manual Composio credentials are removed from KiloClaw settings, - the next OpenClaw instance bootstrap SHOULD leave the CLI unsigned-in - or clean up prior Composio CLI auth state so stale credentials are not - reused. -6. Kilo MUST NOT rotate, revoke, claim, or otherwise manage manually - entered Composio credentials unless the user explicitly requests that - action through a supported product flow. +1. Manual Composio configuration MUST be opt-in. An OpenClaw instance without both required Composio fields MUST continue to boot without Composio CLI sign-in. +2. Manual Composio credentials MUST be treated as user-provided secrets. The user API key and organization value MUST be encrypted before reaching the KiloClaw Worker and MUST be delivered through the existing encrypted environment variable pipeline. +3. Manual Composio fields MUST remain configurable from Settings through the secret catalog unless a future spec explicitly removes Composio support. +4. The system MUST validate manually entered Composio credential fields according to the catalog validation contract before saving or provisioning them. +5. When a user clears manual Composio settings, subsequent instance configuration MUST remove the corresponding injected secret values through the normal secret update path. +6. Kilo MUST NOT rotate, revoke, claim, or otherwise manage manually entered Composio credentials unless the user explicitly requests that action through a future supported flow. ### Instance CLI Sign-In -7. The OpenClaw instance MAY contain the Composio CLI even when no Composio - credentials are configured. -8. When valid Composio CLI credentials are available, the controller - SHOULD sign the CLI in during bootstrap so `composio` commands work - without an interactive browser login. -9. Composio CLI sign-in MUST be best-effort and MUST NOT prevent the - controller from starting OpenClaw unless the user or product has - explicitly configured Composio as a required startup dependency. -10. If sign-in uses a subprocess invocation, the implementation MUST use - a direct executable call rather than a shell and MUST suppress logs - that would include credentials. -11. If sign-in writes Composio CLI state files directly, those files MUST - be written with owner-only permissions and MUST be placed in the - OpenClaw instance user's Composio config directory. -12. Composio credentials injected for CLI sign-in MUST NOT be left in the - gateway child process environment when they are no longer needed by - the running gateway. -13. Configuring Composio through KiloClaw settings or managed - provisioning is an explicit request for Kilo to manage the OpenClaw - instance's Composio CLI sign-in. When valid Kilo-provided Composio - credentials are available, the controller MAY overwrite existing - on-disk Composio CLI configuration during bootstrap. -14. If a user signs into Composio manually inside the OpenClaw instance - after configuring Composio through KiloClaw, a later controller - bootstrap MAY overwrite that manual sign-in with the Kilo-provided - credentials. This is accepted behavior; users who want to preserve a - fully custom Composio CLI configuration SHOULD not configure Composio - through KiloClaw for that OpenClaw instance. -15. When Composio credentials are removed from Kilo settings or managed - provisioning, the controller MAY leave any existing on-disk Composio - CLI configuration untouched. Kilo is not required to determine - whether that configuration was written by Kilo or by a user. - -### Credential Boundary - -16. Kilo central Composio credentials MUST NOT be injected into a user or - organization OpenClaw instance. -17. An OpenClaw instance MUST receive only credentials for its own owner - scope: the user's manual credentials, the user's managed personal - Composio identity, or the user's managed Composio identity for the - active Kilo organization context. -18. The system MUST NOT fall back from a missing owner-scoped Composio - identity to any shared global Composio identity. -19. Manual personal Composio credentials MUST NOT be reused for an - organization OpenClaw instance unless the user explicitly configures - those credentials in that organization context. -20. Managed personal Composio credentials MUST NOT be reused for a Kilo - organization context. Managed organization-context credentials MUST - NOT be reused for a different user, a different organization, or a - personal context. - -### Managed Identity Ownership - -21. A managed personal Composio identity MUST be scoped to exactly one - Kilo user. -22. A managed organization-context Composio identity MUST be scoped to - exactly one Kilo user and exactly one Kilo organization. -23. Managed Composio identities MUST survive KiloClaw instance destroy - and reprovision operations unless the owner explicitly revokes the - identity or account deletion/org deletion policy requires revocation - or anonymization. -24. Kilo SHOULD store managed Composio identities in owner-scoped - persistent storage rather than instance-scoped Durable Object state. - That storage SHOULD distinguish pending provisioning from active - identities. -25. Managed Composio identity credentials MUST be encrypted at rest. -26. The KiloClaw worker MUST NOT be the primary creator of persistent - managed Composio identity records. Persistent identity writes SHOULD - be owned by the Next.js web app or another explicitly designated - control-plane service. -27. At most one non-revoked managed Composio identity SHOULD exist per - owner scope unless a future spec explicitly supports multiple active - identities. - -### Organization Contexts - -28. In a Kilo organization context, each eligible organization user MUST - receive their own managed Composio identity for that organization - context. Managed Composio identities MUST NOT be shared across all - members of an organization unless a future spec explicitly introduces - shared organization-level identities. -29. A managed organization-context identity MAY be reused by the same - Kilo user across new KiloClaw instances created after prior instances - are destroyed in the same Kilo organization. -30. Connected accounts associated with a managed organization-context - identity are scoped to that Kilo user in that Kilo organization - context. They MUST NOT become implicitly usable by other organization - members through a shared Composio workspace. -31. When a user loses access to a Kilo organization, the system MUST - prevent that user from receiving that organization-context Composio - identity's credentials in any future OpenClaw instance config. -32. Organization member removal SHOULD NOT delete other members' - organization-context Composio identities or connected accounts. -33. Organization deletion MUST define whether organization-context - Composio identities are revoked, anonymized, or retained for - audit/compliance before deletion support ships. - -### Connect Link Onboarding - -34. Kilo MAY create Composio Connect Links during onboarding before the - OpenClaw instance machine exists. -35. A Connect Link created by Kilo MUST be scoped to the correct managed - owner identity and to the intended Composio user/context for that - owner. -36. Connect Link callback handling MUST verify the authenticated Kilo - user still has access to the owner scope before recording a - connection as active or surfacing it in UI. -37. Kilo MUST NOT receive or persist raw OAuth access tokens from external - toolkits connected through Composio unless a separate spec explicitly - permits that behavior. -38. Connection status displayed in Kilo SHOULD be derived from Composio - connected-account state or from a Kilo cache that is refreshed from - Composio. Kilo MUST NOT treat creation of a Connect Link as proof - that the external account is connected. -39. For organization-context onboarding, Kilo MUST make clear that the - connection is for the current user inside the organization context, - not for a shared organization-wide Composio workspace. -40. Managed Connect Link onboarding UI MUST disclose that Composio powers - the toolkit connection. When manual Composio configuration is - available for the OpenClaw instance, the onboarding UI MUST provide a - path for the user to use their own Composio credentials instead of - the managed Composio identity. - -### Data Protection and Logging - -41. Composio user API keys, project API keys, agent keys, OAuth tokens, - and any equivalent credential material MUST be treated as secrets. -42. Logs, analytics, audit records, Sentry events, and user-facing errors - MUST NOT include raw Composio credentials or OAuth tokens. -43. Generated Composio emails or identifiers that can be linked to a Kilo - user SHOULD be treated as user-linked data for GDPR/anonymization - purposes. -44. When Kilo stores user-linked managed Composio data in Postgres, the - GDPR soft-delete flow MUST anonymize, revoke, or detach that data in - a way that complies with the product's account deletion policy. +7. The OpenClaw instance MAY contain the Composio CLI even when no Composio credentials are configured. +8. When valid manual Composio credentials are present, the controller SHOULD sign the CLI in during bootstrap so `composio` commands work without interactive browser login. +9. Composio CLI sign-in MUST be best-effort and MUST NOT prevent the controller from starting OpenClaw unless a future product contract makes Composio a required dependency. +10. If sign-in uses a subprocess, the implementation MUST invoke a direct executable rather than a shell and MUST suppress logs containing credentials. +11. Any Composio CLI state files written by the controller MUST use owner-only permissions and remain inside the instance user's Composio configuration directory. +12. Credentials used only for CLI sign-in MUST NOT remain unnecessarily available to unrelated child processes. + +### Removed Managed Onboarding + +13. Kilo MUST NOT create new managed Composio identities, Connect Links, connected-account onboarding flows, or managed credential injection for KiloClaw onboarding. +14. Direct Google Calendar onboarding, when offered, is independent of Composio and MUST NOT depend on retired managed Composio state. +15. Retired managed Composio identities MUST NOT be reused for new instances or configuration updates. +16. After managed creation paths are disabled, the system MUST verify whether any existing live instance retains managed Composio credentials before obsolete stored managed identity state is deleted. +17. Any confirmed managed credential material in an existing live instance MUST be cleared before obsolete stored managed identity state is deleted. Verification and clearing MUST NOT remove manually configured Composio credentials. +18. If no live managed runtime credential remains, obsolete managed identity rows, encrypted credential residue, connected-account identifiers, and destroyed-instance tracking markers MAY be removed by dropping the retired managed-state schema. +19. Obsolete managed-state database structures MUST NOT be dropped until managed creation is disabled and live runtime residue has been ruled out or cleared. + +### Credential Boundary and Data Protection + +20. Kilo central or retired managed Composio credentials MUST NOT be injected into a user or organization OpenClaw instance. +21. Logs, analytics, audit records, Sentry events, command output, and user-facing errors MUST NOT include raw Composio credentials, OAuth tokens, Connect Links containing secret material, or decrypted stored identity data. +22. User-provided Composio secrets MUST continue to follow the normal KiloClaw secret encryption, transport, and deletion rules. +23. Retired managed rows containing encrypted credentials or user-linked provider identifiers MUST be deleted after the required live-runtime verification or otherwise scrubbed in accordance with account-deletion requirements. ## Error Handling -1. If manual Composio credentials are missing or incomplete, the - controller MUST skip Composio CLI sign-in and continue startup. -2. If Composio CLI sign-in fails, the controller MUST log a sanitized - failure and SHOULD continue startup in a usable state. -3. If managed Composio identity provisioning fails during onboarding, - the onboarding flow MUST surface a retryable error and MUST NOT store - a partially usable identity as active. -4. If a Connect Link callback reports failure or the connected account is - not active, Kilo MUST keep the connection status non-active and allow - the user to retry. -5. If an organization user is no longer authorized for an organization - before a Connect Link callback completes, Kilo MUST reject the - callback result for that user's session. - -## Implementation Status - -This spec describes the intended first shipped behavior for PR #3348. -None of the managed Composio onboarding behavior has reached production. +1. If manual Composio credentials are missing or incomplete, the controller MUST skip Composio CLI sign-in and continue startup. +2. If manual Composio credential validation fails, the save or provision request MUST fail before transporting invalid credentials to the Worker. +3. If Composio CLI sign-in fails, the controller MUST log a sanitized failure and SHOULD continue startup in a usable state. +4. If clearing confirmed managed runtime credentials from an active instance fails, obsolete managed stored state MUST be retained until that cleanup can be retried successfully. + +## Changelog + +### 2026-05-27 -- Retired managed Composio onboarding + +- Removed managed identity provisioning, managed Connect Link onboarding, and managed callback injection from supported product behavior. +- Retained explicit user-provided Composio credentials through Settings and the encrypted secret pipeline. +- Added post-deploy live-runtime verification and subsequent stored-state removal requirements for managed credentials created or injected while the experiment was shipped. + +### 2026-05-20 -- Managed onboarding experiment + +- Introduced the managed Composio onboarding behavior later retired by the 2026-05-27 revision. diff --git a/.specs/kiloclaw-datamodel.md b/.specs/kiloclaw-datamodel.md index 04e8940a3b..4c8522ac92 100644 --- a/.specs/kiloclaw-datamodel.md +++ b/.specs/kiloclaw-datamodel.md @@ -21,6 +21,7 @@ background jobs). All consumers MUST comply with the rules below. Draft -- created 2026-04-15. Updated 2026-05-12 -- required KiloClaw price-version lineage invariants. +Updated 2026-05-27 -- required durable fresh-provision admission reservations. Updated 2026-05-28 -- fraud-enforcement subscription mutation invariants. ## Conventions @@ -68,6 +69,11 @@ capitals, as shown here. Fraud Warning under `.specs/stripe-early-fraud-warnings.md`. - **Active instance**: An instance record that has not been marked as destroyed. +- **Provision reservation**: Durable coordination state for one fresh + provisioning attempt in a user context. A reservation assigns the + candidate instance identifier that the attempt MUST use if it succeeds, + but it is not an instance record, does not assert that infrastructure + exists, and does not grant access. - **Mutation**: Any database write (INSERT or UPDATE) to a `kiloclaw_subscription` row that changes one or more of its business-relevant fields (status, plan, billing period, payment @@ -187,6 +193,40 @@ KiloClaw billing. 9. When the single-instance limit is relaxed in the future, no schema migration SHALL be required. +### Fresh Provision Admission + +1. Before a fresh personal or organization-context provision invokes an + instance Durable Object or any infra provider operation, the KiloClaw + Worker MUST persist a provision reservation for the requesting user and + context. +2. A provision reservation MUST remain coordination metadata only. It MUST + NOT be stored as an instance record, routed as an active instance, treated + as billing access, or reported as completed onboarding. +3. A provision reservation MUST assign one candidate instance identifier. + An admitted attempt MUST carry that identifier through Durable Object + routing, instance record insertion, subscription bootstrap, and routing + registry publication; runtime MUST NOT silently choose a replacement + identifier during that attempt. +4. While an admitted fresh attempt is in progress or its provider-side + outcome requires reconciliation, another fresh attempt for the same user + and context MUST NOT execute provider creation work. The system MUST fail + closed or report a retryable conflict rather than risk duplicate + infrastructure. +5. Before performing provider creation under an admitted reservation, the + Worker MUST reconcile authoritative active-instance state for the same + user/context. Existing active state MUST prevent another fresh provision + even if a routing index entry is absent or stale. +6. If a provision attempt fails after provider resources may have been + created, its reservation MUST remain blocked or marked for reconciliation + until cleanup or canonical recovery has been confirmed. An expired request + or lease alone MUST NOT authorize another fresh attempt. +7. A completed instance that is intentionally destroyed MAY later be + reprovisioned when no active instance remains in the context, subject to + subscription successor-transfer and entitlement rules. +8. Reservation storage and admission enforcement MUST remain application/ + Worker-layer behavior; they MUST NOT introduce a schema-level constraint + that prevents future multi-instance product behavior. + ### Operational Instance Markers Instance records MAY store operational lifecycle markers that do not @@ -287,10 +327,12 @@ MUST be enforced only after the existing data model has been brought into the desired state (rules 1–6 satisfied, early-bird backfill complete). -19. A Cloudflare Worker Durable Object and a infra provider base resource MUST both exist +19. A Cloudflare Worker Durable Object and an infra provider base resource MUST both exist before an instance record is created in `kiloclaw_instance`. Infrastructure MUST be provisioned first; the record is a - reflection of existing infrastructure, not a reservation. + reflection of existing infrastructure, not a reservation. A + provision reservation created under Fresh Provision Admission is + coordination metadata and does not violate this creation order. 20. If either infrastructure component fails to provision, the system MUST NOT create an instance record. Cleanup of any partially provisioned infrastructure is the responsibility of the @@ -350,6 +392,11 @@ not yet enforced in the current codebase: across all services that mutate subscription records. Some subscription-creation paths may already write change-log entries; complete cross-service coverage remains the intended invariant. +4. Fresh Provision Admission SHOULD be implemented in the Registry-backed + Worker admission flow before the existing web advisory lock is removed. + (Currently, web requests use transitional PostgreSQL advisory-lock + coordination that is being replaced because it is unsafe through + transaction-pooled production connections.) ## Changelog @@ -358,6 +405,12 @@ not yet enforced in the current codebase: - Defined enforced personal Stripe Early Fraud Warnings as exceptional immediate cancellation/suspension mutations that retain instance history, write system-attributed change logs, and preserve the seven-day destruction grace. - Excluded organization-owned warnings from automatic organization-managed instance or subscription mutation. +### 2026-05-27 -- Required durable fresh-provision admission reservations + +- Defined provision reservations as non-routable, non-entitling coordination state. +- Required Worker-side admission before any fresh provider creation and fail-closed handling for concurrent or ambiguous failed attempts. +- Preserved Worker-only instance insertion and infrastructure-before-row ordering. + ### 2026-05-12 -- Required KiloClaw price-version lineage invariants - Added required `kiloclaw_price_version` row semantics. diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx index 5c60aa4801..a3b24a34bf 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx @@ -23,7 +23,6 @@ import { ProvisioningStepView } from './ProvisioningStep'; const FAKE_STEP_LABELS: Record = { identity: 'Identity', - tools: 'Tools', calendar: 'Calendar', email: 'Inbound Email', interests: 'Interests', @@ -172,13 +171,12 @@ type RenderFakeStepInput = { }; function getFakeStepProgress(step: ClawOnboardingRenderStep): StepProgress { - return getClawOnboardingStepProgress(getFakeOnboardingStep(step), true, true, false); + return getClawOnboardingStepProgress(getFakeOnboardingStep(step), true, true); } function getFakeOnboardingStep(step: ClawOnboardingRenderStep): OnboardingStep { switch (step) { case 'identity': - case 'tools': case 'calendar': case 'email': case 'interests': @@ -195,7 +193,6 @@ function renderFakeStep({ step, setStep, stepProgress, basePath }: RenderFakeSte case 'identity': { return setStep('calendar')} />; } - case 'tools': case 'calendar': { return ( { expect(state.instanceStatus).toBeNull(); }); - test('allows managed tools before initial provisioning starts', () => { - const state = getClawOnboardingFlowState( - createInput({ - onboardingStep: 'tools', - hasBotIdentity: true, - hasToolsStep: true, - }) - ); - - expect(state.renderStep).toBe('tools'); - expect(state.createSetupActive).toBe(false); - expect(state.instanceStatus).toBeNull(); - }); - test('keeps create setup active once an instance status exists', () => { const state = getClawOnboardingFlowState( createInput({ @@ -130,16 +116,6 @@ describe('ClawOnboardingFlow state machine', () => { expect(getClawOnboardingFlowState(createInput({ createSetupStarted: true })).renderStep).toBe( 'identity' ); - expect( - getClawOnboardingFlowState( - createInput({ - createSetupStarted: true, - onboardingStep: 'tools', - hasBotIdentity: true, - hasToolsStep: true, - }) - ).renderStep - ).toBe('tools'); expect( getClawOnboardingFlowState( createInput({ @@ -210,28 +186,6 @@ describe('ClawOnboardingFlow state machine', () => { expect(getClawOnboardingStepProgress('done')).toEqual({ currentStep: 5, totalSteps: 5 }); }); - test('managed tools step replaces calendar in the active wizard', () => { - const state = getClawOnboardingFlowState( - createInput({ - createSetupStarted: true, - onboardingStep: 'calendar', - hasBotIdentity: true, - hasToolsStep: true, - }) - ); - - expect(state.renderStep).toBe('tools'); - expect(state.totalSteps).toBe(5); - expect(getClawOnboardingStepProgress('tools', true, true, true)).toEqual({ - currentStep: 2, - totalSteps: 5, - }); - expect(getClawOnboardingStepProgress('calendar', true, true, true)).toEqual({ - currentStep: 2, - totalSteps: 5, - }); - }); - test.each(CLAW_ONBOARDING_PROVISIONING_STATUSES)( 'renders the post-provisioning spinner while machine status is %s', status => { @@ -443,21 +397,6 @@ describe('ClawOnboardingFlow state machine', () => { expect(state.renderStep).toBe('calendar'); }); - test('renders tools in post-provisioning mode when explicit Composio resume is requested', () => { - const state = getClawOnboardingFlowState( - createInput({ - mode: 'post-provisioning', - status: createStatus('running'), - onboardingStep: 'tools', - hasBotIdentity: true, - hasToolsStep: true, - gatewayState: 'running', - }) - ); - - expect(state.renderStep).toBe('tools'); - }); - test('renders calendar in post-provisioning mode even before the gateway is ready', () => { // The OAuth round-trip can complete before the gateway boots; respect // the calendar resume regardless of postProvisioningReady. diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts index e71b36cb3b..04dbdefee2 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts @@ -8,7 +8,6 @@ export type ClawOnboardingMode = 'create-first' | 'post-provisioning'; export type OnboardingStep = | 'identity' - | 'tools' | 'calendar' | 'email' | 'interests' @@ -17,7 +16,6 @@ export type OnboardingStep = export const CLAW_ONBOARDING_WIZARD_STEPS = [ 'identity', - 'tools', 'calendar', 'email', 'interests', @@ -28,7 +26,6 @@ export type ClawOnboardingWizardStep = (typeof CLAW_ONBOARDING_WIZARD_STEPS)[num export type ClawOnboardingRenderStep = | 'identity' - | 'tools' | 'calendar' | 'email' | 'interests' @@ -85,7 +82,6 @@ export type ClawOnboardingFlowStateInput = { * in the render decision. */ hasCalendarStep?: boolean; - hasToolsStep?: boolean; /** * Whether the morning-briefing Interests step is available in the wizard. * Morning briefing is generally available; this is gated on controller @@ -106,7 +102,6 @@ export type ClawOnboardingFlowState = { createSetupActive: boolean; postProvisioningReady: boolean; hasCalendarStep: boolean; - hasToolsStep: boolean; hasInterestsStep: boolean; currentStep: number; totalSteps: number; @@ -130,13 +125,11 @@ export function isClawOnboardingErrorStatus(status: PopulatedClawStatus['status' } function getActiveWizardSteps( - hasToolsStep: boolean, hasCalendarStep: boolean, hasInterestsStep: boolean ): OnboardingStep[] { const steps: OnboardingStep[] = ['identity']; - if (hasToolsStep) steps.push('tools'); - else if (hasCalendarStep) steps.push('calendar'); + if (hasCalendarStep) steps.push('calendar'); steps.push('email'); if (hasInterestsStep) steps.push('interests'); steps.push('provisioning'); @@ -146,22 +139,18 @@ function getActiveWizardSteps( export function getClawOnboardingStepProgress( step: OnboardingStep, hasCalendarStep: boolean = true, - hasInterestsStep: boolean = true, - hasToolsStep: boolean = false + hasInterestsStep: boolean = true ): { currentStep: number; totalSteps: number } { - const wizardSteps = getActiveWizardSteps(hasToolsStep, hasCalendarStep, hasInterestsStep); + const wizardSteps = getActiveWizardSteps(hasCalendarStep, hasInterestsStep); const totalSteps = wizardSteps.length; if (step === 'done') { return { currentStep: totalSteps, totalSteps }; } - // A user sitting briefly on an unavailable step (e.g. via a stale URL) gets - // normalized to the next available step for progress display, matching the - // renderStep redirect in getRenderStepDecision. + // A user sitting briefly on an unavailable step gets normalized to the + // next available step for progress display, matching the render decision. let lookupStep: OnboardingStep = step; - if (lookupStep === 'tools' && !hasToolsStep) lookupStep = hasCalendarStep ? 'calendar' : 'email'; - if (lookupStep === 'calendar' && hasToolsStep) lookupStep = 'tools'; if (lookupStep === 'calendar' && !hasCalendarStep) lookupStep = 'email'; if (lookupStep === 'interests' && !hasInterestsStep) lookupStep = 'provisioning'; const index = wizardSteps.indexOf(lookupStep); @@ -179,7 +168,6 @@ export function getClawOnboardingFlowState({ hasBotIdentity, gatewayState, hasCalendarStep = true, - hasToolsStep = false, hasInterestsStep = true, debugLogSource = 'default', }: ClawOnboardingFlowStateInput): ClawOnboardingFlowState { @@ -193,8 +181,7 @@ export function getClawOnboardingFlowState({ const { currentStep, totalSteps } = getClawOnboardingStepProgress( onboardingStep, hasCalendarStep, - hasInterestsStep, - hasToolsStep + hasInterestsStep ); const renderStepDecision = getRenderStepDecision({ mode, @@ -204,7 +191,6 @@ export function getClawOnboardingFlowState({ postProvisioningReady, onboardingStep, hasBotIdentity, - hasToolsStep, hasCalendarStep, hasInterestsStep, }); @@ -217,7 +203,6 @@ export function getClawOnboardingFlowState({ createSetupActive, postProvisioningReady, hasCalendarStep, - hasToolsStep, hasInterestsStep, currentStep, totalSteps, @@ -232,7 +217,6 @@ export function getClawOnboardingFlowState({ hasBotIdentity, gatewayState, hasCalendarStep, - hasToolsStep, hasInterestsStep, debugLogSource, instanceStatus, @@ -255,7 +239,6 @@ type RenderStepInput = Pick< > & { instanceStatus: PopulatedClawStatus | null; postProvisioningReady: boolean; - hasToolsStep: boolean; hasCalendarStep: boolean; hasInterestsStep: boolean; }; @@ -267,7 +250,6 @@ type RenderStepDecision = { type ClawOnboardingFlowDebugLogInput = ClawOnboardingFlowStateInput & { debugLogSource: string; - hasToolsStep: boolean; hasCalendarStep: boolean; hasInterestsStep: boolean; instanceStatus: PopulatedClawStatus | null; @@ -298,7 +280,6 @@ function getRenderStepDecision({ postProvisioningReady, onboardingStep, hasBotIdentity, - hasToolsStep, hasCalendarStep, hasInterestsStep, }: RenderStepInput): RenderStepDecision { @@ -321,25 +302,7 @@ function getRenderStepDecision({ // wizard often remounts in post-provisioning mode because the instance // row is now visible — but the user is still mid-wizard. Honor any // explicit wizard step rather than auto-routing them past it. - if (onboardingStep === 'tools') { - if (!hasToolsStep) { - return { - renderStep: hasCalendarStep ? 'calendar' : 'email', - reason: 'tools step is unavailable; advance to the next configured setup step', - }; - } - return { - renderStep: 'tools', - reason: 'tools resume requested; honor it even in post-provisioning mode', - }; - } if (onboardingStep === 'calendar') { - if (hasToolsStep) { - return { - renderStep: 'tools', - reason: 'managed Composio tools step replaces calendar in this flow', - }; - } if (!hasCalendarStep) { return { renderStep: 'email', @@ -414,26 +377,7 @@ function getRenderStepDecision({ }; } - if (onboardingStep === 'tools') { - if (!hasToolsStep) { - return { - renderStep: hasCalendarStep ? 'calendar' : 'email', - reason: 'tools step is unavailable; advance to the next configured setup step', - }; - } - return { - renderStep: 'tools', - reason: 'stored onboarding step is tools', - }; - } - if (onboardingStep === 'calendar') { - if (hasToolsStep) { - return { - renderStep: 'tools', - reason: 'managed Composio tools step replaces calendar in this flow', - }; - } if (!hasCalendarStep) { return { renderStep: 'email', @@ -487,7 +431,6 @@ function logClawOnboardingFlowStateDecision({ onboardingStep, hasBotIdentity, gatewayState, - hasToolsStep, hasCalendarStep, hasInterestsStep, debugLogSource, @@ -511,7 +454,6 @@ function logClawOnboardingFlowStateDecision({ onboardingStep, hasBotIdentity, gatewayState: gatewayState ?? null, - hasToolsStep, hasCalendarStep, hasInterestsStep, status: status?.status ?? null, diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx index f507bb56fa..d4031d10ae 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx @@ -153,10 +153,8 @@ function ClawOnboardingFlowInner({ const [onboardingStep, setOnboardingStep] = useState(() => { if (typeof window === 'undefined') return 'identity'; const initialStep = new URLSearchParams(window.location.search).get('step'); - if (initialStep === 'tools') return 'tools'; return initialStep === 'calendar' ? 'calendar' : 'identity'; }); - const hasToolsStep = false; const gatewayUrl = useGatewayUrl(status); @@ -186,7 +184,6 @@ function ClawOnboardingFlowInner({ setupFailed, onboardingStep, hasBotIdentity: botIdentity !== null, - hasToolsStep, hasCalendarStep, hasInterestsStep, }; @@ -316,8 +313,8 @@ function ClawOnboardingFlowInner({ }, [flowState.instanceStatus, botIdentity]); // Resume the calendar step after the full-page Google OAuth round trip. - // Stale Composio callback URLs are cleaned up without showing connection - // feedback because Composio is no longer part of onboarding. + // Remove stale `tools` URLs from the retired integration flow without + // displaying obsolete connection feedback. const hasResumedFromQuery = useRef(false); // Allowlist of known OAuth error codes that the callback route can emit. @@ -688,7 +685,6 @@ function ClawOnboardingFlowInner({ switch (renderStep) { case 'identity': return renderIdentityStep(); - case 'tools': case 'calendar': return renderCalendarStep(); case 'email': diff --git a/apps/web/src/app/api/integrations/composio/callback/route.test.ts b/apps/web/src/app/api/integrations/composio/callback/route.test.ts deleted file mode 100644 index 6e90739ef5..0000000000 --- a/apps/web/src/app/api/integrations/composio/callback/route.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { beforeEach, describe, expect, test } from '@jest/globals'; -import { NextRequest, NextResponse } from 'next/server'; -import { getUserFromAuth } from '@/lib/user/server'; -import { getActiveInstance, getActiveOrgInstance } from '@/lib/kiloclaw/instance-registry'; -import { completeManagedComposioGoogleCalendarConnection } from '@/lib/kiloclaw/composio-onboarding'; -import { withKiloclawProvisionContextLock } from '@/lib/kiloclaw/provision-lock'; -import { failureResult } from '@/lib/maybe-result'; - -jest.mock('@/lib/user/server'); -jest.mock('@/lib/kiloclaw/instance-registry'); -jest.mock('@/lib/kiloclaw/composio-onboarding'); -jest.mock('@/lib/kiloclaw/provision-lock', () => ({ - getPersonalProvisionLockKey: jest.fn((userId: string) => `personal:${userId}`), - getOrganizationProvisionLockKey: jest.fn( - (userId: string, organizationId: string) => `org:${userId}:${organizationId}` - ), - withKiloclawProvisionContextLock: jest.fn(async (_key: string, work: () => Promise) => { - return await work(); - }), -})); -const mockedEnsureOrganizationAccess = jest.fn(); -jest.mock('@/routers/organizations/utils', () => ({ - ensureOrganizationAccess: mockedEnsureOrganizationAccess, -})); - -const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); -const mockedGetActiveInstance = jest.mocked(getActiveInstance); -const mockedGetActiveOrgInstance = jest.mocked(getActiveOrgInstance); -const mockedCompleteManagedComposioGoogleCalendarConnection = jest.mocked( - completeManagedComposioGoogleCalendarConnection -); -const mockedWithKiloclawProvisionContextLock = jest.mocked(withKiloclawProvisionContextLock); - -const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847'; -const ORG_ID = 'a32ba169-8d90-43f6-98ee-95e509a1b06b'; -const INSTANCE_ID = '62f96e7b-e010-4a4f-badb-85af870b9fd9'; -const fakeInstance = { - id: INSTANCE_ID, - userId: USER_ID, - sandboxId: 'sandbox-1', - organizationId: null, - name: null, - inboundEmailEnabled: false, - composioConfigSource: null, -}; - -function makeRequest(path: string) { - return new NextRequest(`http://localhost:3000${path}`); -} - -function redirectPath(response: Response): string { - const location = response.headers.get('location'); - expect(location).toBeTruthy(); - const url = new URL(location ?? ''); - return `${url.pathname}${url.search}`; -} - -async function responseBody(response: Response): Promise { - return await response.text(); -} - -describe('GET /api/integrations/composio/callback', () => { - beforeEach(() => { - jest.resetAllMocks(); - - mockedGetUserFromAuth.mockResolvedValue({ - user: { id: USER_ID, is_admin: false }, - authFailedResponse: null, - } as never); - mockedGetActiveInstance.mockResolvedValue(fakeInstance as never); - mockedGetActiveOrgInstance.mockResolvedValue(fakeInstance as never); - mockedCompleteManagedComposioGoogleCalendarConnection.mockResolvedValue(true); - mockedWithKiloclawProvisionContextLock.mockImplementation(async (_key, work) => { - return await work(); - }); - }); - - test('redirects to sign-in when auth fails', async () => { - mockedGetUserFromAuth.mockResolvedValue({ - user: null, - authFailedResponse: NextResponse.json(failureResult('Unauthorized'), { status: 401 }), - } as never); - - const { GET } = await import('./route'); - const response = await GET(makeRequest('/api/integrations/composio/callback') as never); - - expect(response.status).toBe(307); - expect(redirectPath(response)).toBe('/users/sign_in'); - }); - - test('returns popup failure instead of sign-in redirect when popup auth fails', async () => { - mockedGetUserFromAuth.mockResolvedValue({ - user: null, - authFailedResponse: NextResponse.json(failureResult('Unauthorized'), { status: 401 }), - } as never); - - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - '/api/integrations/composio/callback?popup=1&attemptId=attempt-1&status=success' - ) as never - ); - - expect(response.status).toBe(200); - expect(response.headers.get('location')).toBeNull(); - const body = await responseBody(response); - expect(body).toContain('kiloclaw:composio-connect'); - expect(body).toContain('attempt-1'); - expect(body).toContain('unauthorized'); - expect(body).toContain('BroadcastChannel'); - }); - - test('rejects backslash-prefixed returnTo values instead of redirecting externally', async () => { - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - '/api/integrations/composio/callback?returnTo=%2F%5Cevil.example.com%2Fpath&status=failed' - ) as never - ); - - expect(response.status).toBe(307); - expect(response.headers.get('location')).toContain('/claw/new?step=tools'); - expect(response.headers.get('location')).not.toContain('evil.example.com'); - }); - - test('does not emit success until the connected account verifies against Composio', async () => { - mockedCompleteManagedComposioGoogleCalendarConnection.mockResolvedValue(false); - - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - '/api/integrations/composio/callback?returnTo=%2Fclaw%2Fnew%3Fstep%3Dtools&status=success&connected_account_id=ca_123' - ) as never - ); - - expect(redirectPath(response)).toBe('/claw/new?step=tools&error=connection_failed'); - expect(mockedCompleteManagedComposioGoogleCalendarConnection).toHaveBeenCalledWith({ - userId: USER_ID, - instance: fakeInstance, - scope: { ownerType: 'user', userId: USER_ID }, - connectedAccountId: 'ca_123', - }); - }); - - test('emits success after verifying and applying managed credentials', async () => { - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - `/api/integrations/composio/callback?organizationId=${ORG_ID}&returnTo=%2Forganizations%2F${ORG_ID}%2Fclaw%2Fnew%3Fstep%3Dtools&status=success&connected_account_id=ca_123` - ) as never - ); - - expect(redirectPath(response)).toBe( - `/organizations/${ORG_ID}/claw/new?step=tools&success=composio_connected` - ); - expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( - { user: { id: USER_ID, is_admin: false } }, - ORG_ID - ); - expect(mockedGetActiveOrgInstance).toHaveBeenCalledWith(USER_ID, ORG_ID); - expect(mockedCompleteManagedComposioGoogleCalendarConnection).toHaveBeenCalledWith({ - userId: USER_ID, - instance: fakeInstance, - scope: { ownerType: 'organization_user', userId: USER_ID, organizationId: ORG_ID }, - connectedAccountId: 'ca_123', - }); - }); - - test('records managed connection before an instance exists', async () => { - mockedGetActiveInstance.mockResolvedValue(null as never); - - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - '/api/integrations/composio/callback?returnTo=%2Fclaw%2Fnew%3Fstep%3Dtools&status=success&connected_account_id=ca_123' - ) as never - ); - - expect(redirectPath(response)).toBe('/claw/new?step=tools&success=composio_connected'); - expect(mockedCompleteManagedComposioGoogleCalendarConnection).toHaveBeenCalledWith({ - userId: USER_ID, - instance: null, - scope: { ownerType: 'user', userId: USER_ID }, - connectedAccountId: 'ca_123', - }); - }); - - test('returns popup success document after verifying managed credentials', async () => { - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - `/api/integrations/composio/callback?popup=1&organizationId=${ORG_ID}&returnTo=%2Forganizations%2F${ORG_ID}%2Fclaw%2Fnew%3Fstep%3Dtools&status=success&connected_account_id=ca_123` - ) as never - ); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain('text/html'); - const body = await responseBody(response); - expect(body).toContain('Google Calendar connected'); - expect(body).toContain('Close popup'); - expect(body).toContain('localStorage.setItem'); - expect(body).toContain('window.opener.postMessage'); - expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( - { user: { id: USER_ID, is_admin: false } }, - ORG_ID - ); - expect(mockedCompleteManagedComposioGoogleCalendarConnection).toHaveBeenCalledWith({ - userId: USER_ID, - instance: fakeInstance, - scope: { ownerType: 'organization_user', userId: USER_ID, organizationId: ORG_ID }, - connectedAccountId: 'ca_123', - }); - }); - - test('includes popup attempt id in success document payload', async () => { - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - `/api/integrations/composio/callback?popup=1&attemptId=attempt-2&organizationId=${ORG_ID}&returnTo=%2Forganizations%2F${ORG_ID}%2Fclaw%2Fnew%3Fstep%3Dtools&status=success&connected_account_id=ca_123` - ) as never - ); - - const body = await responseBody(response); - expect(body).toContain('attempt-2'); - }); - - test('reports internal callback failures separately from authorization failures', async () => { - mockedCompleteManagedComposioGoogleCalendarConnection.mockRejectedValue( - new Error('db unavailable') - ); - - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - '/api/integrations/composio/callback?returnTo=%2Fclaw%2Fnew%3Fstep%3Dtools&status=success&connected_account_id=ca_123' - ) as never - ); - - expect(redirectPath(response)).toBe('/claw/new?step=tools&error=internal_error'); - }); - - test('escapes popup attempt id before embedding it in inline scripts', async () => { - mockedGetUserFromAuth.mockResolvedValue({ - user: null, - authFailedResponse: NextResponse.json(failureResult('Unauthorized'), { status: 401 }), - } as never); - - const { GET } = await import('./route'); - const response = await GET( - makeRequest( - `/api/integrations/composio/callback?popup=1&attemptId=${encodeURIComponent('')}&status=success` - ) as never - ); - - const body = await responseBody(response); - expect(body).not.toContain(''); - expect(body).toContain('\\u003c/script>\\u003cscript>alert(1)\\u003c/script>'); - }); -}); diff --git a/apps/web/src/app/api/integrations/composio/callback/route.ts b/apps/web/src/app/api/integrations/composio/callback/route.ts deleted file mode 100644 index d04afa61ee..0000000000 --- a/apps/web/src/app/api/integrations/composio/callback/route.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { TRPCError } from '@trpc/server'; -import { APP_URL } from '@/lib/constants'; -import { getUserFromAuth } from '@/lib/user/server'; -import { isSafeGoogleOAuthReturnTo } from '@/lib/integrations/google/oauth-state'; -import { completeManagedComposioGoogleCalendarConnection } from '@/lib/kiloclaw/composio-onboarding'; -import { getActiveInstance, getActiveOrgInstance } from '@/lib/kiloclaw/instance-registry'; -import { - getOrganizationProvisionLockKey, - getPersonalProvisionLockKey, - withKiloclawProvisionContextLock, -} from '@/lib/kiloclaw/provision-lock'; -import { ensureOrganizationAccess } from '@/routers/organizations/utils'; - -const OrganizationIdSchema = z.string().uuid(); - -function safeReturnTo(value: string | null, organizationId?: string): string { - if (value && value.length <= 500 && isSafeGoogleOAuthReturnTo(value)) return value; - if (organizationId) return `/organizations/${organizationId}/claw/new?step=tools`; - return '/claw/new?step=tools'; -} - -function appendResult(path: string, result: 'success' | 'failed' | 'unknown'): string { - const parsedPath = new URL(path, APP_URL); - const next = parsedPath.searchParams; - next.set('step', 'tools'); - - if (result === 'success') { - next.set('success', 'composio_connected'); - next.delete('error'); - } else if (result === 'failed') { - next.set('error', 'connection_failed'); - next.delete('success'); - } else { - next.delete('success'); - next.delete('error'); - } - - return `${parsedPath.pathname}?${next.toString()}`; -} - -function appendError(path: string, error: string): string { - const parsedPath = new URL(path, APP_URL); - const next = parsedPath.searchParams; - next.set('step', 'tools'); - next.set('error', error); - next.delete('success'); - return `${parsedPath.pathname}?${next.toString()}`; -} - -function callbackFailureError(error: unknown): 'unauthorized' | 'internal_error' { - return error instanceof TRPCError && error.code === 'UNAUTHORIZED' - ? 'unauthorized' - : 'internal_error'; -} - -function serializeInlineJson(value: unknown): string { - return JSON.stringify(value).replace(/ - - - - ${title} - - - -
-
KiloClaw
-

${title}

-

${description}

- -
- - -`, - { headers: { 'content-type': 'text/html; charset=utf-8' } } - ); -} - -export async function GET(request: NextRequest) { - const organizationIdParam = request.nextUrl.searchParams.get('organizationId'); - const parsedOrgId = organizationIdParam - ? OrganizationIdSchema.safeParse(organizationIdParam) - : null; - const organizationId = parsedOrgId?.success ? parsedOrgId.data : undefined; - const returnTo = safeReturnTo(request.nextUrl.searchParams.get('returnTo'), organizationId); - const popup = request.nextUrl.searchParams.get('popup') === '1'; - const attemptId = request.nextUrl.searchParams.get('attemptId'); - - try { - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); - if (authFailedResponse) { - if (popup) return popupResultResponse('failed', 'unauthorized', attemptId); - return NextResponse.redirect(new URL('/users/sign_in', APP_URL)); - } - - if (organizationIdParam) { - if (!parsedOrgId?.success) { - if (popup) return popupResultResponse('failed', 'invalid_state', attemptId); - return NextResponse.redirect(new URL(appendError(returnTo, 'invalid_state'), APP_URL)); - } - await ensureOrganizationAccess({ user }, parsedOrgId.data); - } - - const providerStatus = request.nextUrl.searchParams.get('status'); - if (providerStatus === 'failed') { - if (popup) return popupResultResponse('failed', 'connection_failed', attemptId); - return NextResponse.redirect(new URL(appendResult(returnTo, 'failed'), APP_URL)); - } - - const connectedAccountId = request.nextUrl.searchParams.get('connected_account_id'); - if (providerStatus !== 'success' || !connectedAccountId) { - if (popup) return popupResultResponse('unknown', undefined, attemptId); - return NextResponse.redirect(new URL(appendResult(returnTo, 'unknown'), APP_URL)); - } - - const verified = await withKiloclawProvisionContextLock( - organizationId - ? getOrganizationProvisionLockKey(user.id, organizationId) - : getPersonalProvisionLockKey(user.id), - async () => { - const instance = organizationId - ? await getActiveOrgInstance(user.id, organizationId) - : await getActiveInstance(user.id); - return await completeManagedComposioGoogleCalendarConnection({ - userId: user.id, - instance, - scope: organizationId - ? { ownerType: 'organization_user', userId: user.id, organizationId } - : { ownerType: 'user', userId: user.id }, - connectedAccountId, - }); - } - ); - - if (popup) return popupResultResponse(verified ? 'success' : 'failed', undefined, attemptId); - return NextResponse.redirect( - new URL(appendResult(returnTo, verified ? 'success' : 'failed'), APP_URL) - ); - } catch (error) { - const failureError = callbackFailureError(error); - if (popup) return popupResultResponse('failed', failureError, attemptId); - return NextResponse.redirect(new URL(appendError(returnTo, failureError), APP_URL)); - } -} diff --git a/apps/web/src/lib/kiloclaw/composio-client.test.ts b/apps/web/src/lib/kiloclaw/composio-client.test.ts deleted file mode 100644 index a6cbd1f5fd..0000000000 --- a/apps/web/src/lib/kiloclaw/composio-client.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -jest.mock('@/lib/config.server', () => ({ - COMPOSIO_AGENTS_API_BASE_URL: 'https://agents.example.com', - COMPOSIO_API_BASE_URL: 'https://api.example.com', -})); - -import { - createComposioGoogleCalendarConnectLink, - listComposioConnectedAccounts, - resolveComposioConsumerProject, - signupComposioAgentIdentity, -} from './composio-client'; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -describe('Composio client', () => { - it('signs up a ready agent identity', async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { - requests.push({ url: String(url), init }); - return jsonResponse({ - status: 'ready', - agent_key: 'agent-key', - email: 'agent@example.com', - composio: { - org_id: 'org-1', - project_id: 'project-1', - api_key: 'api-key', - user_api_key: 'uak_123', - }, - }); - }; - - const identity = await signupComposioAgentIdentity(fetchImpl as typeof fetch); - - expect(identity.agent_key).toBe('agent-key'); - expect(identity.composio.user_api_key).toBe('uak_123'); - expect(requests).toEqual([ - { - url: 'https://agents.example.com/api/signup', - init: { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}', - }, - }, - ]); - }); - - it('sends a recovery request id when signing up an agent identity', async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { - requests.push({ url: String(url), init }); - return jsonResponse({ - status: 'ready', - request_id: 'reservation-1', - agent_key: 'agent-key', - composio: { - org_id: 'org-1', - user_api_key: 'uak_123', - }, - }); - }; - - await signupComposioAgentIdentity({ - idempotencyKey: 'reservation-1', - fetchImpl: fetchImpl as typeof fetch, - }); - - expect(requests[0]?.init?.body).toBe(JSON.stringify({ request_id: 'reservation-1' })); - }); - - it('creates a Google Calendar Connect Link through a Composio session', async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { - requests.push({ url: String(url), init }); - if (String(url).endsWith('/api/v3/tool_router/session')) { - return jsonResponse({ session_id: 'session_123' }); - } - return jsonResponse({ - redirect_url: 'https://composio.example.com/connect/link', - connected_account_id: 'ca_123', - }); - }; - - const result = await createComposioGoogleCalendarConnectLink({ - auth: { - userApiKey: 'uak_123', - orgId: 'org-1', - projectId: 'project-1', - }, - userId: 'kiloclaw:user:user-1', - callbackUrl: 'https://app.example.com/api/integrations/composio/callback', - fetchImpl: fetchImpl as typeof fetch, - }); - - expect(result).toEqual({ - redirectUrl: 'https://composio.example.com/connect/link', - connectedAccountId: 'ca_123', - }); - expect(requests[0].url).toBe('https://api.example.com/api/v3/tool_router/session'); - expect(requests[0].init?.headers).toEqual({ - 'content-type': 'application/json', - 'x-user-api-key': 'uak_123', - 'x-org-id': 'org-1', - 'x-project-id': 'project-1', - }); - expect(JSON.parse(String(requests[0].init?.body))).toEqual({ - user_id: 'kiloclaw:user:user-1', - toolkits: { enable: ['googlecalendar'] }, - manage_connections: { enable: true }, - }); - expect(requests[1].url).toBe( - 'https://api.example.com/api/v3/tool_router/session/session_123/link' - ); - expect(JSON.parse(String(requests[1].init?.body))).toEqual({ - toolkit: 'googlecalendar', - callback_url: 'https://app.example.com/api/integrations/composio/callback', - }); - expect(requests[1].init?.headers).toEqual({ - 'content-type': 'application/json', - 'x-user-api-key': 'uak_123', - 'x-org-id': 'org-1', - 'x-project-id': 'project-1', - }); - }); - - it('resolves the consumer project using user API key and org context', async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { - requests.push({ url: String(url), init }); - return jsonResponse({ - project_id: 'project-db-id', - project_nano_id: 'proj_nano_123', - project_name: 'Consumer Project', - org_id: 'org-1', - project_type: 'CONSUMER', - consumer_user_id: 'consumer-user-1', - }); - }; - - const project = await resolveComposioConsumerProject( - { userApiKey: 'uak_123', orgId: 'org-1' }, - fetchImpl as typeof fetch - ); - - expect(project.project_nano_id).toBe('proj_nano_123'); - expect(project.consumer_user_id).toBe('consumer-user-1'); - expect(requests).toEqual([ - { - url: 'https://api.example.com/api/v3/org/consumer/project/resolve', - init: { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-user-api-key': 'uak_123', - 'x-org-id': 'org-1', - }, - body: '{}', - }, - }, - ]); - }); - - it('filters connected accounts by consumer user and Google Calendar toolkit', async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { - requests.push({ url: String(url), init }); - return jsonResponse({ - items: [ - { - id: 'ca_123', - status: 'ACTIVE', - toolkit: { slug: 'googlecalendar' }, - }, - ], - }); - }; - - const accounts = await listComposioConnectedAccounts({ - auth: { - userApiKey: 'uak_123', - orgId: 'org-1', - projectId: 'project-1', - }, - userId: 'kiloclaw:user:user-1', - fetchImpl: fetchImpl as typeof fetch, - }); - - expect(accounts).toHaveLength(1); - const url = new URL(requests[0].url); - expect(url.origin + url.pathname).toBe('https://api.example.com/api/v3/connected_accounts'); - expect(url.searchParams.get('user_ids')).toBe('kiloclaw:user:user-1'); - expect(url.searchParams.get('auth_config_ids')).toBeNull(); - expect(url.searchParams.get('toolkit_slugs')).toBe('googlecalendar'); - expect(requests[0].init?.headers).toEqual({ - 'x-user-api-key': 'uak_123', - 'x-org-id': 'org-1', - 'x-project-id': 'project-1', - }); - }); -}); diff --git a/apps/web/src/lib/kiloclaw/composio-client.ts b/apps/web/src/lib/kiloclaw/composio-client.ts deleted file mode 100644 index d6da0f91ee..0000000000 --- a/apps/web/src/lib/kiloclaw/composio-client.ts +++ /dev/null @@ -1,275 +0,0 @@ -import 'server-only'; - -import * as z from 'zod'; -import { COMPOSIO_AGENTS_API_BASE_URL, COMPOSIO_API_BASE_URL } from '@/lib/config.server'; - -const AgentSignupReadyResponseSchema = z.object({ - status: z.string(), - request_id: z.string().optional(), - slug: z.string().optional(), - email: z.string().optional(), - agent_key: z.string(), - composio: z.object({ - member_id: z.string().optional(), - org_id: z.string(), - project_id: z.string().optional(), - api_key: z.string().optional(), - user_api_key: z.string(), - }), -}); - -const AgentWhoamiResponseSchema = AgentSignupReadyResponseSchema.extend({ - claimed_by: z.string().nullable().optional(), - claimed_at: z.string().nullable().optional(), -}); - -const ConsumerProjectResolveResponseSchema = z.object({ - project_id: z.string(), - project_nano_id: z.string(), - project_name: z.string(), - org_id: z.string(), - project_type: z.literal('CONSUMER'), - consumer_user_id: z.string(), -}); - -const LinkCreateResponseSchema = z.object({ - redirect_url: z.string().url(), - connected_account_id: z.string(), - expires_at: z.string().optional(), - link_token: z.string().optional(), -}); - -const SessionCreateResponseSchema = z.object({ - session_id: z.string(), -}); - -const ConnectedAccountSchema = z.object({ - id: z.string(), - status: z.string(), - toolkit: z.object({ slug: z.string() }).optional(), - auth_config: z.object({ id: z.string() }).optional(), -}); - -const ConnectedAccountListResponseSchema = z.object({ - items: z.array(ConnectedAccountSchema), -}); - -export type ComposioAgentIdentity = z.infer; -export type ComposioConnectedAccount = z.infer; -export type ComposioConsumerProject = z.infer; - -export type ComposioUserContextAuth = { - userApiKey: string; - orgId: string; - projectId: string; -}; - -const GOOGLE_CALENDAR_TOOLKIT_SLUG = 'googlecalendar'; - -class ComposioApiError extends Error { - constructor( - message: string, - readonly status: number, - readonly operation: string - ) { - super(message); - this.name = 'ComposioApiError'; - } -} - -function joinUrl(baseUrl: string, path: string): string { - const normalizedBase = baseUrl.replace(/\/+$/, ''); - return `${normalizedBase}${path}`; -} - -function contextAuthHeaders(auth: ComposioUserContextAuth): Record { - return { - 'x-user-api-key': auth.userApiKey, - 'x-org-id': auth.orgId, - 'x-project-id': auth.projectId, - }; -} - -function userOrgAuthHeaders(params: { userApiKey: string; orgId: string }): Record { - return { - 'x-user-api-key': params.userApiKey, - 'x-org-id': params.orgId, - }; -} - -function safeComposioErrorMetadata( - value: unknown -): Record | undefined { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; - - const source = value as Record; - const metadata: Record = {}; - for (const key of ['code', 'field', 'path', 'status']) { - const field = source[key]; - if (typeof field === 'string') metadata[key] = field.slice(0, 120); - else if (typeof field === 'number' || typeof field === 'boolean') metadata[key] = field; - } - - return Object.keys(metadata).length > 0 ? metadata : undefined; -} - -async function parseJsonResponse(response: Response, operation: string): Promise { - const raw = await response.text(); - let json: unknown; - try { - json = raw.length > 0 ? JSON.parse(raw) : null; - } catch { - if (!response.ok) { - console.warn('[kiloclaw:composio] upstream request failed', { - operation, - status: response.status, - upstream: { invalidJson: true }, - }); - throw new ComposioApiError(`Composio ${operation} failed`, response.status, operation); - } - throw new ComposioApiError( - `Composio ${operation} returned invalid JSON`, - response.status, - operation - ); - } - - if (!response.ok) { - const upstream = safeComposioErrorMetadata(json); - console.warn('[kiloclaw:composio] upstream request failed', { - operation, - status: response.status, - upstream, - }); - throw new ComposioApiError(`Composio ${operation} failed`, response.status, operation); - } - - return json; -} - -export async function signupComposioAgentIdentity( - params: { idempotencyKey?: string; fetchImpl?: typeof fetch } | typeof fetch = {} -): Promise { - const fetchImpl = typeof params === 'function' ? params : (params.fetchImpl ?? fetch); - const idempotencyKey = typeof params === 'function' ? undefined : params.idempotencyKey; - const response = await fetchImpl(joinUrl(COMPOSIO_AGENTS_API_BASE_URL, '/api/signup'), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(idempotencyKey ? { request_id: idempotencyKey } : {}), - }); - const json = await parseJsonResponse(response, 'agent signup'); - const parsed = AgentSignupReadyResponseSchema.safeParse(json); - if (!parsed.success || parsed.data.status.toLowerCase() !== 'ready') { - throw new ComposioApiError( - 'Composio agent identity is not ready', - response.status, - 'agent signup' - ); - } - return parsed.data; -} - -export async function getComposioAgentIdentity( - agentKey: string, - fetchImpl: typeof fetch = fetch -): Promise { - const response = await fetchImpl(joinUrl(COMPOSIO_AGENTS_API_BASE_URL, '/api/whoami'), { - headers: { authorization: `Bearer ${agentKey}` }, - }); - const json = await parseJsonResponse(response, 'agent whoami'); - const parsed = AgentWhoamiResponseSchema.safeParse(json); - if (!parsed.success || parsed.data.status.toLowerCase() !== 'ready') { - throw new ComposioApiError( - 'Composio agent identity is not ready', - response.status, - 'agent whoami' - ); - } - return parsed.data; -} - -export async function resolveComposioConsumerProject( - params: { - userApiKey: string; - orgId: string; - }, - fetchImpl: typeof fetch = fetch -): Promise { - const response = await fetchImpl( - joinUrl(COMPOSIO_API_BASE_URL, '/api/v3/org/consumer/project/resolve'), - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...userOrgAuthHeaders(params), - }, - body: '{}', - } - ); - const json = await parseJsonResponse(response, 'consumer project resolve'); - return ConsumerProjectResolveResponseSchema.parse(json); -} - -export async function createComposioGoogleCalendarConnectLink(params: { - auth: ComposioUserContextAuth; - userId: string; - callbackUrl: string; - fetchImpl?: typeof fetch; -}): Promise<{ redirectUrl: string; connectedAccountId: string }> { - const fetchImpl = params.fetchImpl ?? fetch; - const sessionResponse = await fetchImpl( - joinUrl(COMPOSIO_API_BASE_URL, '/api/v3/tool_router/session'), - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...contextAuthHeaders(params.auth), - }, - body: JSON.stringify({ - user_id: params.userId, - toolkits: { enable: [GOOGLE_CALENDAR_TOOLKIT_SLUG] }, - manage_connections: { enable: true }, - }), - } - ); - const sessionJson = await parseJsonResponse(sessionResponse, 'session create'); - const session = SessionCreateResponseSchema.parse(sessionJson); - - const linkResponse = await fetchImpl( - joinUrl(COMPOSIO_API_BASE_URL, `/api/v3/tool_router/session/${session.session_id}/link`), - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...contextAuthHeaders(params.auth), - }, - body: JSON.stringify({ - toolkit: GOOGLE_CALENDAR_TOOLKIT_SLUG, - callback_url: params.callbackUrl, - }), - } - ); - const json = await parseJsonResponse(linkResponse, 'connect link create'); - const parsed = LinkCreateResponseSchema.parse(json); - return { - redirectUrl: parsed.redirect_url, - connectedAccountId: parsed.connected_account_id, - }; -} - -export async function listComposioConnectedAccounts(params: { - auth: ComposioUserContextAuth; - userId: string; - fetchImpl?: typeof fetch; -}): Promise { - const url = new URL(joinUrl(COMPOSIO_API_BASE_URL, '/api/v3/connected_accounts')); - url.searchParams.append('user_ids', params.userId); - url.searchParams.append('toolkit_slugs', GOOGLE_CALENDAR_TOOLKIT_SLUG); - url.searchParams.append('limit', '25'); - - const response = await (params.fetchImpl ?? fetch)(url, { - headers: contextAuthHeaders(params.auth), - }); - const json = await parseJsonResponse(response, 'connected account list'); - return ConnectedAccountListResponseSchema.parse(json).items; -} diff --git a/apps/web/src/lib/kiloclaw/composio-identities.test.ts b/apps/web/src/lib/kiloclaw/composio-identities.test.ts deleted file mode 100644 index 7fc8c4ab42..0000000000 --- a/apps/web/src/lib/kiloclaw/composio-identities.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -jest.mock('@/lib/config.server', () => ({ - BYOK_ENCRYPTION_KEY: 'test-encryption-key', -})); - -jest.mock('@/lib/encryption', () => ({ - encryptWithSymmetricKey: jest.fn((value: string) => `encrypted:${value}`), - decryptWithSymmetricKey: jest.fn((value: string) => value.replace(/^encrypted:/, '')), -})); - -jest.mock('@/lib/kiloclaw/provision-lock', () => ({ - withKiloclawProvisionContextLock: jest.fn(async (_key: string, work: () => Promise) => { - return await work(); - }), -})); - -jest.mock('@/lib/kiloclaw/composio-client', () => ({ - getComposioAgentIdentity: jest.fn(), - resolveComposioConsumerProject: jest.fn(), - signupComposioAgentIdentity: jest.fn(), -})); - -const selectedRows: unknown[][] = []; -const insertedRows: unknown[][] = []; -const updatedRows: unknown[][] = []; -const updateSets: unknown[] = []; - -jest.mock('@/lib/drizzle', () => ({ - db: { - select: jest.fn(() => ({ - from: jest.fn(() => ({ - where: jest.fn(() => ({ - limit: jest.fn(async () => selectedRows.shift() ?? []), - })), - })), - })), - insert: jest.fn(() => ({ - values: jest.fn(() => ({ - returning: jest.fn(async () => insertedRows.shift() ?? []), - })), - })), - update: jest.fn(() => ({ - set: jest.fn((values: unknown) => { - updateSets.push(values); - return { - where: jest.fn(() => ({ - returning: jest.fn(async () => updatedRows.shift() ?? []), - })), - }; - }), - })), - }, -})); - -import { - getComposioAgentIdentity, - resolveComposioConsumerProject, - signupComposioAgentIdentity, - type ComposioAgentIdentity, -} from '@/lib/kiloclaw/composio-client'; -import { ensureManagedComposioIdentity } from './composio-identities'; - -const mockedGetComposioAgentIdentity = jest.mocked(getComposioAgentIdentity); -const mockedResolveComposioConsumerProject = jest.mocked(resolveComposioConsumerProject); -const mockedSignupComposioAgentIdentity = jest.mocked(signupComposioAgentIdentity); -const scope = { ownerType: 'user', userId: 'user-1' } as const; - -function identityRow(overrides: Record = {}) { - return { - id: 'identity-1', - owner_type: 'user', - user_id: 'user-1', - organization_id: null, - status: 'active', - composio_agent_key_encrypted: 'encrypted:agent-key', - composio_user_api_key_encrypted: 'encrypted:uak_123', - composio_api_key_encrypted: null, - composio_org_id: 'org-1', - composio_org_name: 'Workspace', - composio_project_id: 'project-1', - composio_consumer_user_id: 'consumer-user-1', - google_calendar_connected_account_id: null, - composio_agent_email: 'agent@example.com', - revoked_at: null, - ...overrides, - }; -} - -function upstreamIdentity(): ComposioAgentIdentity { - return { - status: 'ready', - agent_key: 'agent-key', - slug: 'Workspace', - email: 'agent@example.com', - composio: { - org_id: 'org-1', - user_api_key: 'uak_123', - }, - }; -} - -function resolveProject() { - mockedResolveComposioConsumerProject.mockResolvedValue({ - project_id: 'project-db-id', - project_nano_id: 'project-1', - project_name: 'Consumer Project', - project_type: 'CONSUMER', - org_id: 'org-1', - consumer_user_id: 'consumer-user-1', - }); -} - -describe('ensureManagedComposioIdentity', () => { - beforeEach(() => { - jest.clearAllMocks(); - selectedRows.length = 0; - insertedRows.length = 0; - updatedRows.length = 0; - updateSets.length = 0; - }); - - it('returns an active identity without refreshing complete context', async () => { - selectedRows.push([identityRow()]); - - const identity = await ensureManagedComposioIdentity(scope); - - expect(identity.agentKey).toBe('agent-key'); - expect(identity.userApiKey).toBe('uak_123'); - expect(mockedGetComposioAgentIdentity).not.toHaveBeenCalled(); - expect(mockedSignupComposioAgentIdentity).not.toHaveBeenCalled(); - }); - - it('refreshes an active identity with incomplete project context', async () => { - selectedRows.push([identityRow({ composio_project_id: null })]); - mockedGetComposioAgentIdentity.mockResolvedValue(upstreamIdentity()); - resolveProject(); - updatedRows.push([identityRow()]); - - const identity = await ensureManagedComposioIdentity(scope); - - expect(identity.row.composio_project_id).toBe('project-1'); - expect(mockedGetComposioAgentIdentity).toHaveBeenCalledWith('agent-key'); - expect(updateSets[0]).toMatchObject({ - composio_project_id: 'project-1', - composio_consumer_user_id: 'consumer-user-1', - status: 'active', - }); - }); - - it('reuses a stored pending reservation instead of signing up a second identity', async () => { - selectedRows.push([identityRow({ status: 'pending', composio_project_id: null })]); - mockedGetComposioAgentIdentity.mockResolvedValue(upstreamIdentity()); - resolveProject(); - updatedRows.push( - [identityRow({ status: 'pending', composio_project_id: null })], - [identityRow()] - ); - - const identity = await ensureManagedComposioIdentity(scope); - - expect(identity.row.status).toBe('active'); - expect(mockedGetComposioAgentIdentity).toHaveBeenCalledWith('agent-key'); - expect(mockedSignupComposioAgentIdentity).not.toHaveBeenCalled(); - expect(updateSets[1]).toMatchObject({ - composio_project_id: 'project-1', - composio_consumer_user_id: 'consumer-user-1', - status: 'active', - }); - }); -}); diff --git a/apps/web/src/lib/kiloclaw/composio-identities.ts b/apps/web/src/lib/kiloclaw/composio-identities.ts deleted file mode 100644 index e7d6980f8a..0000000000 --- a/apps/web/src/lib/kiloclaw/composio-identities.ts +++ /dev/null @@ -1,263 +0,0 @@ -import 'server-only'; - -import { and, eq, isNull } from 'drizzle-orm'; -import { db } from '@/lib/drizzle'; -import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@/lib/encryption'; -import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; -import { withKiloclawProvisionContextLock } from '@/lib/kiloclaw/provision-lock'; -import { - getComposioAgentIdentity, - resolveComposioConsumerProject, - signupComposioAgentIdentity, - type ComposioAgentIdentity, -} from '@/lib/kiloclaw/composio-client'; -import { - kiloclaw_composio_identities, - type KiloClawComposioIdentity, - type KiloClawComposioIdentityOwnerType, - type KiloClawComposioIdentityStatus, - type NewKiloClawComposioIdentity, -} from '@kilocode/db/schema'; - -export type ComposioOwnerScope = - | { ownerType: 'user'; userId: string } - | { ownerType: 'organization_user'; userId: string; organizationId: string }; - -export type DecryptedComposioIdentity = { - row: KiloClawComposioIdentity; - agentKey: string; - userApiKey: string; - apiKey: string | null; - org: string; - consumerUserId: string; -}; - -function requireComposioEncryptionKey(): string { - if (!BYOK_ENCRYPTION_KEY) { - throw new Error('BYOK_ENCRYPTION_KEY is not configured'); - } - return BYOK_ENCRYPTION_KEY; -} - -function ownerScopeLockKey(scope: ComposioOwnerScope): string { - if (scope.ownerType === 'user') return `kiloclaw-composio:user:${scope.userId}`; - return `kiloclaw-composio:organization-user:${scope.organizationId}:${scope.userId}`; -} - -export function composioConsumerUserId(scope: ComposioOwnerScope): string { - if (scope.ownerType === 'user') return `kiloclaw:user:${scope.userId}`; - return `kiloclaw:org-user:${scope.organizationId}:${scope.userId}`; -} - -function scopeWhere(scope: ComposioOwnerScope, status?: KiloClawComposioIdentityStatus) { - const statusClause = status ? eq(kiloclaw_composio_identities.status, status) : undefined; - if (scope.ownerType === 'user') { - return and( - eq( - kiloclaw_composio_identities.owner_type, - 'user' satisfies KiloClawComposioIdentityOwnerType - ), - eq(kiloclaw_composio_identities.user_id, scope.userId), - isNull(kiloclaw_composio_identities.organization_id), - statusClause, - isNull(kiloclaw_composio_identities.revoked_at) - ); - } - - return and( - eq( - kiloclaw_composio_identities.owner_type, - 'organization_user' satisfies KiloClawComposioIdentityOwnerType - ), - eq(kiloclaw_composio_identities.user_id, scope.userId), - eq(kiloclaw_composio_identities.organization_id, scope.organizationId), - statusClause, - isNull(kiloclaw_composio_identities.revoked_at) - ); -} - -async function findActiveComposioIdentity( - scope: ComposioOwnerScope -): Promise { - const [row] = await db - .select() - .from(kiloclaw_composio_identities) - .where(scopeWhere(scope, 'active')) - .limit(1); - return row ?? null; -} - -async function findCurrentComposioIdentity( - scope: ComposioOwnerScope -): Promise { - const [row] = await db - .select() - .from(kiloclaw_composio_identities) - .where(scopeWhere(scope)) - .limit(1); - return row ?? null; -} - -function requireIdentityField(value: string | null, field: string): string { - if (value) return value; - throw new Error(`Active Composio identity is missing ${field}`); -} - -function decryptComposioIdentity(row: KiloClawComposioIdentity): DecryptedComposioIdentity { - const encryptionKey = requireComposioEncryptionKey(); - const agentKey = requireIdentityField(row.composio_agent_key_encrypted, 'agent key'); - const userApiKey = requireIdentityField(row.composio_user_api_key_encrypted, 'user API key'); - const org = requireIdentityField(row.composio_org_id, 'organization'); - return { - row, - agentKey: decryptWithSymmetricKey(agentKey, encryptionKey), - userApiKey: decryptWithSymmetricKey(userApiKey, encryptionKey), - apiKey: row.composio_api_key_encrypted - ? decryptWithSymmetricKey(row.composio_api_key_encrypted, encryptionKey) - : null, - org, - consumerUserId: row.composio_consumer_user_id ?? composioConsumerUserId(scopeFromRow(row)), - }; -} - -function scopeFromRow(row: KiloClawComposioIdentity): ComposioOwnerScope { - if (row.owner_type === 'user') return { ownerType: 'user', userId: row.user_id }; - if (!row.organization_id) { - throw new Error('Composio organization-user identity is missing organization_id'); - } - return { - ownerType: 'organization_user', - userId: row.user_id, - organizationId: row.organization_id, - }; -} - -function encryptComposioIdentityCredentials( - scope: ComposioOwnerScope, - identity: ComposioAgentIdentity -): NewKiloClawComposioIdentity { - const encryptionKey = requireComposioEncryptionKey(); - return { - owner_type: scope.ownerType, - user_id: scope.userId, - organization_id: scope.ownerType === 'organization_user' ? scope.organizationId : null, - status: 'pending', - composio_agent_key_encrypted: encryptWithSymmetricKey(identity.agent_key, encryptionKey), - composio_user_api_key_encrypted: encryptWithSymmetricKey( - identity.composio.user_api_key, - encryptionKey - ), - composio_api_key_encrypted: identity.composio.api_key - ? encryptWithSymmetricKey(identity.composio.api_key, encryptionKey) - : null, - composio_org_id: identity.composio.org_id, - composio_org_name: identity.slug, - composio_agent_email: identity.email, - }; -} - -async function resolveComposioIdentityContext(identity: ComposioAgentIdentity) { - const consumerProject = await resolveComposioConsumerProject({ - userApiKey: identity.composio.user_api_key, - orgId: identity.composio.org_id, - }); - return { - composio_project_id: consumerProject.project_nano_id, - composio_consumer_user_id: consumerProject.consumer_user_id, - }; -} - -async function encryptComposioIdentity( - scope: ComposioOwnerScope, - identity: ComposioAgentIdentity -): Promise { - return { - ...encryptComposioIdentityCredentials(scope, identity), - ...(await resolveComposioIdentityContext(identity)), - status: 'active', - }; -} - -function hasStoredComposioCredentials(row: KiloClawComposioIdentity): boolean { - return !!row.composio_agent_key_encrypted && !!row.composio_user_api_key_encrypted; -} - -function needsComposioIdentityRefresh(row: KiloClawComposioIdentity): boolean { - return ( - !row.composio_project_id || - !row.composio_consumer_user_id || - row.composio_consumer_user_id.startsWith('kiloclaw:') - ); -} - -async function createPendingComposioIdentityReservation( - scope: ComposioOwnerScope -): Promise { - const [inserted] = await db - .insert(kiloclaw_composio_identities) - .values({ - owner_type: scope.ownerType, - user_id: scope.userId, - organization_id: scope.ownerType === 'organization_user' ? scope.organizationId : null, - status: 'pending', - }) - .returning(); - if (!inserted) { - throw new Error('Failed to reserve managed Composio identity'); - } - return inserted; -} - -export async function getActiveManagedComposioIdentity( - scope: ComposioOwnerScope -): Promise { - const row = await findActiveComposioIdentity(scope); - return row ? decryptComposioIdentity(row) : null; -} - -export async function ensureManagedComposioIdentity( - scope: ComposioOwnerScope -): Promise { - return await withKiloclawProvisionContextLock(ownerScopeLockKey(scope), async () => { - const existing = await findCurrentComposioIdentity(scope); - if (existing?.status === 'active') { - const decrypted = decryptComposioIdentity(existing); - if (!needsComposioIdentityRefresh(existing)) return decrypted; - - const refreshed = await getComposioAgentIdentity(decrypted.agentKey); - const [updated] = await db - .update(kiloclaw_composio_identities) - .set(await encryptComposioIdentity(scope, refreshed)) - .where(eq(kiloclaw_composio_identities.id, existing.id)) - .returning(); - if (!updated) { - throw new Error('Failed to refresh managed Composio identity context'); - } - return decryptComposioIdentity(updated); - } - - requireComposioEncryptionKey(); - const pending = existing ?? (await createPendingComposioIdentityReservation(scope)); - const identity = hasStoredComposioCredentials(pending) - ? await getComposioAgentIdentity(decryptComposioIdentity(pending).agentKey) - : await signupComposioAgentIdentity({ idempotencyKey: pending.id }); - const [storedCredentials] = await db - .update(kiloclaw_composio_identities) - .set(encryptComposioIdentityCredentials(scope, identity)) - .where(eq(kiloclaw_composio_identities.id, pending.id)) - .returning(); - if (!storedCredentials) { - throw new Error('Failed to store managed Composio identity credentials'); - } - - const [activated] = await db - .update(kiloclaw_composio_identities) - .set({ ...(await resolveComposioIdentityContext(identity)), status: 'active' }) - .where(eq(kiloclaw_composio_identities.id, storedCredentials.id)) - .returning(); - if (!activated) { - throw new Error('Failed to resolve managed Composio identity context'); - } - return decryptComposioIdentity(activated); - }); -} diff --git a/apps/web/src/lib/kiloclaw/composio-onboarding.test.ts b/apps/web/src/lib/kiloclaw/composio-onboarding.test.ts deleted file mode 100644 index d3d04832e6..0000000000 --- a/apps/web/src/lib/kiloclaw/composio-onboarding.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -jest.mock('@/lib/config.server', () => ({ - BYOK_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'), -})); - -jest.mock('@/lib/kiloclaw/composio-client', () => ({ - createComposioGoogleCalendarConnectLink: jest.fn(), - listComposioConnectedAccounts: jest.fn(), -})); - -jest.mock('@/lib/kiloclaw/composio-identities', () => ({ - ensureManagedComposioIdentity: jest.fn(), - getActiveManagedComposioIdentity: jest.fn(), -})); - -jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => ({ - KiloClawInternalClient: jest.fn(), -})); - -jest.mock('@/lib/kiloclaw/encryption', () => ({ - encryptKiloClawSecret: jest.fn((value: string) => `encrypted:${value}`), -})); - -const selectedRows: unknown[][] = []; -const updateSets: unknown[] = []; - -jest.mock('@/lib/drizzle', () => ({ - db: { - select: jest.fn(() => ({ - from: jest.fn(() => ({ - where: jest.fn(() => ({ - limit: jest.fn(async () => { - return selectedRows.shift() ?? []; - }), - })), - })), - })), - insert: jest.fn(() => ({ - values: jest.fn(() => ({ - onConflictDoUpdate: jest.fn(async () => undefined), - })), - })), - update: jest.fn(() => ({ - set: jest.fn((values: unknown) => { - updateSets.push(values); - return { - where: jest.fn(async () => undefined), - }; - }), - })), - delete: jest.fn(), - }, -})); - -import { listComposioConnectedAccounts } from '@/lib/kiloclaw/composio-client'; -import { getActiveManagedComposioIdentity } from '@/lib/kiloclaw/composio-identities'; -import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; -import { - buildComposioProvisionSecrets, - completeManagedComposioGoogleCalendarConnection, - composioSecretsPatchSource, - getManagedComposioGoogleCalendarStatus, -} from './composio-onboarding'; - -const mockedListComposioConnectedAccounts = jest.mocked(listComposioConnectedAccounts); -const mockedGetActiveManagedComposioIdentity = jest.mocked(getActiveManagedComposioIdentity); -const mockedKiloClawInternalClient = jest.mocked(KiloClawInternalClient); - -const scope = { ownerType: 'user', userId: 'user-1' } as const; -const instance = { - id: '62f96e7b-e010-4a4f-badb-85af870b9fd9', - userId: 'user-1', - sandboxId: 'sandbox-1', - organizationId: null, - name: null, - inboundEmailEnabled: false, - composioConfigSource: null, -}; - -function mockManagedIdentity() { - mockedGetActiveManagedComposioIdentity.mockResolvedValue({ - row: { - id: 'identity-1', - composio_project_id: 'project-1', - google_calendar_connected_account_id: 'ca_123', - }, - agentKey: 'agent-key', - userApiKey: 'uak_123', - apiKey: 'api-key', - org: 'org-1', - consumerUserId: 'consumer-user-1', - } as never); -} - -beforeEach(() => { - jest.clearAllMocks(); - selectedRows.length = 0; - updateSets.length = 0; - mockManagedIdentity(); - mockedListComposioConnectedAccounts.mockResolvedValue([ - { id: 'ca_123', status: 'ACTIVE' }, - ] as never); -}); - -describe('getManagedComposioGoogleCalendarStatus', () => { - it('does not report connected when the Composio account exists but sandbox secrets are missing', async () => { - selectedRows.push([{ source: 'managed' }]); - - const status = await getManagedComposioGoogleCalendarStatus({ - scope, - instance, - sandboxHasComposioSecrets: false, - }); - - expect(status).toEqual({ - enabled: true, - status: 'disconnected', - connectedAccountId: null, - sandboxConfigSource: 'managed', - }); - }); - - it('reports connected only when the account is active and the current sandbox has managed secrets', async () => { - selectedRows.push([{ source: 'managed' }]); - - const status = await getManagedComposioGoogleCalendarStatus({ - scope, - instance, - sandboxHasComposioSecrets: true, - }); - - expect(status).toEqual({ - enabled: true, - status: 'connected', - connectedAccountId: 'ca_123', - sandboxConfigSource: 'managed', - }); - }); - - it('reports connected before provision when the owner identity has an active account', async () => { - const status = await getManagedComposioGoogleCalendarStatus({ - scope, - instance: null, - sandboxHasComposioSecrets: false, - }); - - expect(status).toEqual({ - enabled: true, - status: 'connected', - connectedAccountId: 'ca_123', - sandboxConfigSource: null, - }); - }); - - it('does not report pre-provision connected until the callback stores the durable account marker', async () => { - mockedGetActiveManagedComposioIdentity.mockResolvedValue({ - row: { - id: 'identity-1', - composio_project_id: 'project-1', - google_calendar_connected_account_id: null, - }, - agentKey: 'agent-key', - userApiKey: 'uak_123', - apiKey: 'api-key', - org: 'org-1', - consumerUserId: 'consumer-user-1', - } as never); - - const status = await getManagedComposioGoogleCalendarStatus({ - scope, - instance: null, - sandboxHasComposioSecrets: false, - }); - - expect(status).toEqual({ - enabled: true, - status: 'disconnected', - connectedAccountId: null, - sandboxConfigSource: null, - }); - }); - - it('keeps manual sandbox configuration separate from managed connected-account status', async () => { - selectedRows.push([{ source: 'manual' }]); - - const status = await getManagedComposioGoogleCalendarStatus({ - scope, - instance, - sandboxHasComposioSecrets: true, - }); - - expect(status).toEqual({ - enabled: true, - status: 'disconnected', - connectedAccountId: null, - sandboxConfigSource: 'manual', - }); - }); -}); - -describe('completeManagedComposioGoogleCalendarConnection', () => { - it('does not overwrite manual credentials saved after a managed link was created', async () => { - selectedRows.push([{ source: 'manual' }]); - - const result = await completeManagedComposioGoogleCalendarConnection({ - userId: 'user-1', - instance, - scope, - connectedAccountId: 'ca_123', - }); - - expect(result).toBe(false); - expect(mockedKiloClawInternalClient).not.toHaveBeenCalled(); - }); - - it('patches managed credentials through workerInstanceId routing', async () => { - selectedRows.push([{ source: 'managed' }]); - const patchSecrets = jest.fn(async () => ({})); - mockedKiloClawInternalClient.mockImplementation( - () => ({ patchSecrets }) as unknown as KiloClawInternalClient - ); - - const result = await completeManagedComposioGoogleCalendarConnection({ - userId: 'user-1', - instance, - scope, - connectedAccountId: 'ca_123', - }); - - expect(result).toBe(true); - expect(patchSecrets).toHaveBeenCalledWith( - 'user-1', - { secrets: expect.objectContaining({ composioUserApiKey: expect.any(String) }) }, - undefined - ); - }); - - it('records the connected account without patching secrets before an instance exists', async () => { - const patchSecrets = jest.fn(async () => ({})); - mockedKiloClawInternalClient.mockImplementation( - () => ({ patchSecrets }) as unknown as KiloClawInternalClient - ); - - const result = await completeManagedComposioGoogleCalendarConnection({ - userId: 'user-1', - instance: null, - scope, - connectedAccountId: 'ca_123', - }); - - expect(result).toBe(true); - expect(patchSecrets).not.toHaveBeenCalled(); - expect(updateSets[0]).toMatchObject({ - google_calendar_connected_account_id: 'ca_123', - }); - }); -}); - -describe('composioSecretsPatchSource', () => { - it('clears manual source when either manual Composio secret is removed', () => { - expect(composioSecretsPatchSource({ composioUserApiKey: null })).toBe('clear'); - expect(composioSecretsPatchSource({ composioOrg: null })).toBe('clear'); - }); -}); - -describe('buildComposioProvisionSecrets', () => { - it('preserves manual Composio credentials instead of injecting managed credentials', async () => { - const result = await buildComposioProvisionSecrets({ - secrets: { - composioUserApiKey: 'uak_manual_credential_123', - composioOrg: 'manual-org', - otherSecret: 'kept', - }, - }); - - expect(result).toEqual({ - secrets: { - composioUserApiKey: 'uak_manual_credential_123', - composioOrg: 'manual-org', - otherSecret: 'kept', - }, - configToMark: { source: 'manual' }, - }); - expect(mockedGetActiveManagedComposioIdentity).not.toHaveBeenCalled(); - }); - - it('rejects invalid pre-provision manual Composio credentials', async () => { - await expect( - buildComposioProvisionSecrets({ - secrets: { - composioUserApiKey: 'uak_short', - composioOrg: 'manual-org', - }, - }) - ).rejects.toThrow('Composio user API keys start with uak_'); - }); - - it('does not inject stored managed credentials when no manual credentials are submitted', async () => { - const result = await buildComposioProvisionSecrets({ - secrets: { otherSecret: 'kept' }, - }); - - expect(result).toEqual({ - secrets: { otherSecret: 'kept' }, - configToMark: null, - }); - expect(mockedGetActiveManagedComposioIdentity).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/lib/kiloclaw/composio-onboarding.ts b/apps/web/src/lib/kiloclaw/composio-onboarding.ts deleted file mode 100644 index 9f058cdc5c..0000000000 --- a/apps/web/src/lib/kiloclaw/composio-onboarding.ts +++ /dev/null @@ -1,270 +0,0 @@ -import 'server-only'; - -import { TRPCError } from '@trpc/server'; -import { eq } from 'drizzle-orm'; -import { FIELD_KEY_TO_ENTRY, validateFieldValue } from '@kilocode/kiloclaw-secret-catalog'; -import { APP_URL } from '@/lib/constants'; -import { encryptKiloClawSecret } from '@/lib/kiloclaw/encryption'; -import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; -import { - createComposioGoogleCalendarConnectLink, - listComposioConnectedAccounts, - type ComposioUserContextAuth, -} from '@/lib/kiloclaw/composio-client'; -import { - kiloclaw_composio_identities, - kiloclaw_instances, - type KiloClawComposioInstanceConfigSource, -} from '@kilocode/db/schema'; -import { db } from '@/lib/drizzle'; - -import { - ensureManagedComposioIdentity, - getActiveManagedComposioIdentity, - type DecryptedComposioIdentity, - type ComposioOwnerScope, -} from '@/lib/kiloclaw/composio-identities'; -import { workerInstanceId, type ActiveKiloClawInstance } from '@/lib/kiloclaw/instance-registry'; - -export type ComposioConnectionStatus = 'not_configured' | 'disconnected' | 'connected' | 'error'; - -export type ComposioSandboxConfigSource = KiloClawComposioInstanceConfigSource | null; - -export type ProvisionComposioConfigToMark = { source: 'manual' } | null; - -export function composioSecretsPatchSource( - secrets: Record -): 'upsert_manual' | 'clear' | 'none' { - const touchedEntries = Object.entries(secrets).filter( - ([key]) => key === 'composioUserApiKey' || key === 'composioOrg' - ); - if (touchedEntries.length === 0) return 'none'; - if (touchedEntries.every(([, value]) => value === null)) return 'clear'; - if (touchedEntries.some(([, value]) => value !== null)) return 'upsert_manual'; - return 'none'; -} - -function composioUserContextAuth( - identity: DecryptedComposioIdentity -): ComposioUserContextAuth | null { - if (!identity.row.composio_project_id) return null; - return { - userApiKey: identity.userApiKey, - orgId: identity.org, - projectId: identity.row.composio_project_id, - }; -} - -export function getComposioConnectCallbackUrl(params: { - organizationId?: string; - returnTo: string; - popup?: boolean; - attemptId?: string; -}): string { - const url = new URL('/api/integrations/composio/callback', APP_URL); - url.searchParams.set('returnTo', params.returnTo); - if (params.organizationId) url.searchParams.set('organizationId', params.organizationId); - if (params.popup) url.searchParams.set('popup', '1'); - if (params.attemptId) url.searchParams.set('attemptId', params.attemptId); - return url.toString(); -} - -export async function markComposioInstanceConfig(params: { - instanceId: string; - source: KiloClawComposioInstanceConfigSource; -}): Promise { - await db - .update(kiloclaw_instances) - .set({ composio_config_source: params.source }) - .where(eq(kiloclaw_instances.id, params.instanceId)); -} - -export async function clearComposioInstanceConfig(instanceId: string): Promise { - await db - .update(kiloclaw_instances) - .set({ composio_config_source: null }) - .where(eq(kiloclaw_instances.id, instanceId)); -} - -export async function getComposioInstanceConfigSource( - instanceId: string -): Promise { - const [row] = await db - .select({ source: kiloclaw_instances.composio_config_source }) - .from(kiloclaw_instances) - .where(eq(kiloclaw_instances.id, instanceId)) - .limit(1); - return row?.source ?? null; -} - -function hasComposioProvisionSecrets(secrets: Record | undefined): boolean { - return secrets?.composioUserApiKey !== undefined || secrets?.composioOrg !== undefined; -} - -function validateManualComposioProvisionSecrets(secrets: Record): void { - for (const key of ['composioUserApiKey', 'composioOrg']) { - const value = secrets[key]; - if (value === undefined) continue; - const entry = FIELD_KEY_TO_ENTRY.get(key); - const field = entry?.fields.find(candidate => candidate.key === key); - if (field?.maxLength != null && value.length > field.maxLength) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${field.label} exceeds maximum length of ${field.maxLength} characters`, - }); - } - if (!validateFieldValue(value, field?.validationPattern)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: field?.validationMessage ?? `Invalid value for ${key}`, - }); - } - } -} - -export async function buildComposioProvisionSecrets(params: { - secrets?: Record; -}): Promise<{ - secrets?: Record; - configToMark: ProvisionComposioConfigToMark; -}> { - if (!hasComposioProvisionSecrets(params.secrets)) { - return { secrets: params.secrets, configToMark: null }; - } - - validateManualComposioProvisionSecrets(params.secrets ?? {}); - return { secrets: params.secrets, configToMark: { source: 'manual' } }; -} - -export async function completeManagedComposioGoogleCalendarConnection(params: { - userId: string; - instance: ActiveKiloClawInstance | null; - scope: ComposioOwnerScope; - connectedAccountId: string; -}): Promise { - const identity = await getActiveManagedComposioIdentity(params.scope); - if (!identity) return false; - const auth = composioUserContextAuth(identity); - if (!auth) return false; - - const accounts = await listComposioConnectedAccounts({ - auth, - userId: identity.consumerUserId, - }); - const connected = accounts.some( - account => account.id === params.connectedAccountId && account.status === 'ACTIVE' - ); - if (!connected) return false; - - if (!params.instance) { - await db - .update(kiloclaw_composio_identities) - .set({ google_calendar_connected_account_id: params.connectedAccountId }) - .where(eq(kiloclaw_composio_identities.id, identity.row.id)); - return true; - } - - // Blocks callbacks after manual mode is recorded. The worker secret write - // below is cross-service, so a manual save starting concurrently still races - // until these writes share a common lock or transaction boundary. - const sandboxConfigSource = await getComposioInstanceConfigSource(params.instance.id); - if (sandboxConfigSource === 'manual') return false; - - const client = new KiloClawInternalClient(); - await client.patchSecrets( - params.userId, - { - secrets: { - composioUserApiKey: encryptKiloClawSecret(identity.userApiKey), - composioOrg: encryptKiloClawSecret(identity.org), - }, - }, - workerInstanceId(params.instance) - ); - await db - .update(kiloclaw_composio_identities) - .set({ google_calendar_connected_account_id: params.connectedAccountId }) - .where(eq(kiloclaw_composio_identities.id, identity.row.id)); - await markComposioInstanceConfig({ - instanceId: params.instance.id, - source: 'managed', - }); - return true; -} - -export async function createManagedComposioGoogleCalendarLink(params: { - userId: string; - scope: ComposioOwnerScope; - organizationId?: string; - returnTo: string; - popup?: boolean; - attemptId?: string; -}): Promise<{ redirectUrl: string; connectedAccountId: string }> { - const identity = await ensureManagedComposioIdentity(params.scope); - const auth = composioUserContextAuth(identity); - if (!auth) { - throw new Error('Managed Composio identity is missing project context'); - } - - return await createComposioGoogleCalendarConnectLink({ - auth, - userId: identity.consumerUserId, - callbackUrl: getComposioConnectCallbackUrl({ - organizationId: params.organizationId, - returnTo: params.returnTo, - popup: params.popup, - attemptId: params.attemptId, - }), - }); -} - -export async function getManagedComposioGoogleCalendarStatus(params: { - scope: ComposioOwnerScope; - instance: ActiveKiloClawInstance | null; - sandboxHasComposioSecrets: boolean; -}): Promise<{ - enabled: boolean; - status: ComposioConnectionStatus; - connectedAccountId: string | null; - sandboxConfigSource: ComposioSandboxConfigSource; -}> { - const sandboxConfigSource = params.instance - ? await getComposioInstanceConfigSource(params.instance.id) - : null; - - const identity = await getActiveManagedComposioIdentity(params.scope); - if (!identity) { - return { enabled: true, status: 'disconnected', connectedAccountId: null, sandboxConfigSource }; - } - const knownConnectedAccountId = identity.row.google_calendar_connected_account_id; - const auth = composioUserContextAuth(identity); - if (!auth) - return { enabled: true, status: 'error', connectedAccountId: null, sandboxConfigSource }; - - try { - const accounts = await listComposioConnectedAccounts({ - auth, - userId: identity.consumerUserId, - }); - const active = accounts.find( - account => - account.status === 'ACTIVE' && - (!knownConnectedAccountId || account.id === knownConnectedAccountId) - ); - if ( - active && - ((!params.instance && knownConnectedAccountId !== null) || - (params.instance && params.sandboxHasComposioSecrets && sandboxConfigSource === 'managed')) - ) { - return { - enabled: true, - status: 'connected', - connectedAccountId: active.id, - sandboxConfigSource, - }; - } - return { enabled: true, status: 'disconnected', connectedAccountId: null, sandboxConfigSource }; - } catch { - return { enabled: true, status: 'error', connectedAccountId: null, sandboxConfigSource }; - } -} diff --git a/apps/web/src/lib/kiloclaw/instance-registry.ts b/apps/web/src/lib/kiloclaw/instance-registry.ts index 4467c5dc10..36194926d2 100644 --- a/apps/web/src/lib/kiloclaw/instance-registry.ts +++ b/apps/web/src/lib/kiloclaw/instance-registry.ts @@ -5,7 +5,7 @@ import { markInstanceDestroyedWithPersonalSubscriptionCollapse, type KiloClawSubscriptionChangeActor, } from '@kilocode/db'; -import { kiloclaw_instances, type KiloClawComposioInstanceConfigSource } from '@kilocode/db/schema'; +import { kiloclaw_instances } from '@kilocode/db/schema'; import { db, type DrizzleTransaction } from '@/lib/drizzle'; export type ActiveKiloClawInstance = { @@ -15,7 +15,6 @@ export type ActiveKiloClawInstance = { organizationId: string | null; name: string | null; inboundEmailEnabled: boolean; - composioConfigSource?: KiloClawComposioInstanceConfigSource | null; }; export type EnsureActiveInstanceResult = { @@ -217,7 +216,6 @@ export async function getActiveInstance( organizationId: kiloclaw_instances.organization_id, name: kiloclaw_instances.name, inboundEmailEnabled: kiloclaw_instances.inbound_email_enabled, - composioConfigSource: kiloclaw_instances.composio_config_source, }) .from(kiloclaw_instances) .where( @@ -247,7 +245,6 @@ export async function getInstanceById(instanceId: string): Promise ({ import { encryptProvisionSecretsForWorker } from './provision-secrets'; describe('encryptProvisionSecretsForWorker', () => { - it('maps catalog field keys to worker env var names before encrypting', () => { + it('maps valid manual Composio secret keys to worker env var names before encrypting', () => { expect( encryptProvisionSecretsForWorker({ - composioUserApiKey: 'uak_123', + composioUserApiKey: 'uak_manual_credential_123', composioOrg: 'org-1', CUSTOM_SECRET: 'kept', }) ).toEqual({ - COMPOSIO_USER_API_KEY: 'encrypted:uak_123', + COMPOSIO_USER_API_KEY: 'encrypted:uak_manual_credential_123', COMPOSIO_ORG: 'encrypted:org-1', CUSTOM_SECRET: 'encrypted:kept', }); }); + + it('keeps manual Composio validation when secrets are passed during provision', () => { + expect(() => + encryptProvisionSecretsForWorker({ + composioUserApiKey: 'uak_short', + composioOrg: 'org-1', + }) + ).toThrow('Composio user API keys start with uak_'); + }); + + const partialComposioCredentialPairs: Array> = [ + { composioUserApiKey: 'uak_manual_credential_123' }, + { composioOrg: 'org-1' }, + ]; + + it.each(partialComposioCredentialPairs)( + 'rejects a partial manual Composio credential pair during provision', + secrets => { + expect(() => encryptProvisionSecretsForWorker(secrets)).toThrow( + 'Composio requires all fields to be set together' + ); + } + ); }); diff --git a/apps/web/src/lib/kiloclaw/provision-secrets.ts b/apps/web/src/lib/kiloclaw/provision-secrets.ts index 106ee796e8..941f8d2eaa 100644 --- a/apps/web/src/lib/kiloclaw/provision-secrets.ts +++ b/apps/web/src/lib/kiloclaw/provision-secrets.ts @@ -1,10 +1,52 @@ -import { FIELD_KEY_TO_ENV_VAR } from '@kilocode/kiloclaw-secret-catalog'; +import { TRPCError } from '@trpc/server'; +import { + FIELD_KEY_TO_ENTRY, + FIELD_KEY_TO_ENV_VAR, + validateFieldValue, +} from '@kilocode/kiloclaw-secret-catalog'; import { encryptKiloClawSecret } from '@/lib/kiloclaw/encryption'; +const COMPOSIO_SECRET_FIELD_KEYS = ['composioUserApiKey', 'composioOrg'] as const; + +function hasComposioProvisionSecrets(secrets: Record): boolean { + return COMPOSIO_SECRET_FIELD_KEYS.some(key => secrets[key] !== undefined); +} + +function validateComposioProvisionSecrets(secrets: Record): void { + if (!hasComposioProvisionSecrets(secrets)) return; + const hasAllFields = COMPOSIO_SECRET_FIELD_KEYS.every(key => secrets[key] !== undefined); + if (!hasAllFields) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Composio requires all fields to be set together', + }); + } + + for (const key of COMPOSIO_SECRET_FIELD_KEYS) { + const value = secrets[key]; + if (value === undefined) continue; + const entry = FIELD_KEY_TO_ENTRY.get(key); + const field = entry?.fields.find(candidate => candidate.key === key); + if (field?.maxLength != null && value.length > field.maxLength) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${field.label} exceeds maximum length of ${field.maxLength} characters`, + }); + } + if (!validateFieldValue(value, field?.validationPattern)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: field?.validationMessage ?? `Invalid value for ${key}`, + }); + } + } +} + export function encryptProvisionSecretsForWorker( secrets: Record | undefined ): Record> | undefined { if (!secrets) return undefined; + validateComposioProvisionSecrets(secrets); return Object.fromEntries( Object.entries(secrets).map(([key, value]) => [ FIELD_KEY_TO_ENV_VAR.get(key) ?? key, diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 5a18c64a44..24daca1c8b 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -77,15 +77,6 @@ import { withKiloclawProvisionContextLock, } from '@/lib/kiloclaw/provision-lock'; import { encryptProvisionSecretsForWorker } from '@/lib/kiloclaw/provision-secrets'; -import { - buildComposioProvisionSecrets, - composioSecretsPatchSource, - createManagedComposioGoogleCalendarLink, - clearComposioInstanceConfig, - getManagedComposioGoogleCalendarStatus, - getComposioInstanceConfigSource, - markComposioInstanceConfig, -} from '@/lib/kiloclaw/composio-onboarding'; import { clearSubscriptionLifecycleAfterInstanceDestroy, clearTrialInactivityStopAfterStart, @@ -878,18 +869,6 @@ const patchBotIdentitySchema = z.object({ botEmoji: z.string().trim().min(1).max(16).nullable().optional(), }); -const composioConnectLinkSchema = z.object({ - returnTo: z - .string() - .min(1) - .max(500) - .refine(value => value.startsWith('/'), { - message: 'returnTo must be a relative path', - }), - popup: z.boolean().optional(), - attemptId: z.string().min(1).max(100).optional(), -}); - /** * Build the worker provision payload from plaintext channel tokens. * The worker expects the flat encrypted envelope shape for channels. @@ -1114,11 +1093,7 @@ async function provisionInstance( params: { instanceId: string | null; bootstrapSubscription: boolean }, executor: typeof db | DrizzleTransaction = db ) { - const composioProvision = await buildComposioProvisionSecrets({ - secrets: input.secrets, - }); - - const encryptedSecrets = encryptProvisionSecretsForWorker(composioProvision.secrets); + const encryptedSecrets = encryptProvisionSecretsForWorker(input.secrets); const expiresInSeconds = TOKEN_EXPIRY.thirtyDays; const kilocodeApiKey = generateApiToken(user, undefined, { @@ -1158,10 +1133,6 @@ async function provisionInstance( : undefined ); - if (composioProvision.configToMark?.source === 'manual') { - await markComposioInstanceConfig({ instanceId: result.instanceId, source: 'manual' }); - } - return result; } @@ -3482,18 +3453,11 @@ export const kiloclawRouter = createTRPCRouter({ const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawInternalClient(); try { - const result = await client.patchSecrets( + return await client.patchSecrets( ctx.user.id, { secrets: encryptedPatch, meta: input.meta }, workerInstanceId(instance) ); - const sourceAction = instance ? composioSecretsPatchSource(secrets) : 'none'; - if (instance && sourceAction === 'upsert_manual') { - await markComposioInstanceConfig({ instanceId: instance.id, source: 'manual' }); - } else if (instance && sourceAction === 'clear') { - await clearComposioInstanceConfig(instance.id); - } - return result; } catch (err) { if (err instanceof KiloClawApiError && err.statusCode >= 400 && err.statusCode < 500) { // Extract message from worker response body (JSON or plain text) @@ -3520,58 +3484,6 @@ export const kiloclawRouter = createTRPCRouter({ return client.getConfig({ userId: ctx.user.id, instanceId: workerInstanceId(instance) }); }), - getComposioOnboardingStatus: baseProcedure.query(async ({ ctx }) => { - const instance = await getActiveInstance(ctx.user.id); - let sandboxHasComposioSecrets = false; - if (instance) { - const client = new KiloClawUserClient( - generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }) - ); - const config = await client.getConfig({ - userId: ctx.user.id, - instanceId: workerInstanceId(instance), - }); - sandboxHasComposioSecrets = config.configuredSecrets.composio === true; - } - return await getManagedComposioGoogleCalendarStatus({ - scope: { - ownerType: 'user', - userId: ctx.user.id, - }, - instance, - sandboxHasComposioSecrets, - }); - }), - - createComposioGoogleCalendarLink: baseProcedure - .input(composioConnectLinkSchema) - .mutation(async ({ ctx, input }) => { - return await withKiloclawProvisionContextLock( - getPersonalProvisionLockKey(ctx.user.id), - async () => { - await ensureProvisionAccess(ctx.user.id, ctx.user.google_user_email); - const instance = await getActiveInstance(ctx.user.id); - const sandboxConfigSource = instance - ? await getComposioInstanceConfigSource(instance.id) - : null; - if (sandboxConfigSource === 'manual') { - throw new TRPCError({ - code: 'CONFLICT', - message: 'This sandbox already uses your own Composio credentials.', - }); - } - - return await createManagedComposioGoogleCalendarLink({ - userId: ctx.user.id, - scope: { ownerType: 'user', userId: ctx.user.id }, - returnTo: input.returnTo, - popup: input.popup, - attemptId: input.attemptId, - }); - } - ); - }), - getChannelCatalog: baseProcedure.query(async ({ ctx }) => { const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawUserClient( diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index 08e1860f9a..650d4a0e85 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -51,15 +51,6 @@ import { withKiloclawProvisionContextLock, } from '@/lib/kiloclaw/provision-lock'; import { encryptProvisionSecretsForWorker } from '@/lib/kiloclaw/provision-secrets'; -import { - buildComposioProvisionSecrets, - composioSecretsPatchSource, - createManagedComposioGoogleCalendarLink, - clearComposioInstanceConfig, - getComposioInstanceConfigSource, - getManagedComposioGoogleCalendarStatus, - markComposioInstanceConfig, -} from '@/lib/kiloclaw/composio-onboarding'; import { organizationMemberProcedure, organizationMemberMutationProcedure, @@ -225,19 +216,6 @@ const patchBotIdentitySchema = z.object({ botEmoji: z.string().trim().min(1).max(16).nullable().optional(), }); -const composioConnectLinkSchema = z.object({ - organizationId: z.uuid(), - returnTo: z - .string() - .min(1) - .max(500) - .refine(value => value.startsWith('/'), { - message: 'returnTo must be a relative path', - }), - popup: z.boolean().optional(), - attemptId: z.string().min(1).max(100).optional(), -}); - // ── Helpers ──────────────────────────────────────────────────────── function buildWorkerChannels(channels: z.infer['channels']) { @@ -477,11 +455,7 @@ export const organizationKiloclawRouter = createTRPCRouter({ }); } - const composioProvision = await buildComposioProvisionSecrets({ - secrets: input.secrets, - }); - - const encryptedSecrets = encryptProvisionSecretsForWorker(composioProvision.secrets); + const encryptedSecrets = encryptProvisionSecretsForWorker(input.secrets); const expiresInSeconds = TOKEN_EXPIRY.thirtyDays; const kilocodeApiKey = generateApiToken(ctx.user, undefined, { @@ -507,10 +481,6 @@ export const organizationKiloclawRouter = createTRPCRouter({ { orgId: input.organizationId } ); - if (composioProvision.configToMark?.source === 'manual') { - await markComposioInstanceConfig({ instanceId: result.instanceId, source: 'manual' }); - } - PostHogClient().capture({ distinctId: ctx.user.google_user_email, event: 'claw_org_instance_provisioned', @@ -533,11 +503,7 @@ export const organizationKiloclawRouter = createTRPCRouter({ // Re-provision: same as provision but expects existing instance const instance = await requireOrgInstance(ctx.user.id, input.organizationId); - const composioProvision = await buildComposioProvisionSecrets({ - secrets: input.secrets, - }); - - const encryptedSecrets = encryptProvisionSecretsForWorker(composioProvision.secrets); + const encryptedSecrets = encryptProvisionSecretsForWorker(input.secrets); const expiresInSeconds = TOKEN_EXPIRY.thirtyDays; const kilocodeApiKey = generateApiToken(ctx.user, undefined, { @@ -568,10 +534,6 @@ export const organizationKiloclawRouter = createTRPCRouter({ { instanceId: instance.id, orgId: input.organizationId } ); - if (composioProvision.configToMark?.source === 'manual') { - await markComposioInstanceConfig({ instanceId: result.instanceId, source: 'manual' }); - } - return result; }), @@ -775,18 +737,11 @@ export const organizationKiloclawRouter = createTRPCRouter({ const instance = await requireOrgInstance(ctx.user.id, input.organizationId); const client = new KiloClawInternalClient(); try { - const result = await client.patchSecrets( + return await client.patchSecrets( ctx.user.id, { secrets: encryptedPatch, meta: input.meta }, workerInstanceId(instance) ); - const sourceAction = composioSecretsPatchSource(input.secrets); - if (sourceAction === 'upsert_manual') { - await markComposioInstanceConfig({ instanceId: instance.id, source: 'manual' }); - } else if (sourceAction === 'clear') { - await clearComposioInstanceConfig(instance.id); - } - return result; } catch (err) { if (err instanceof KiloClawApiError && err.statusCode >= 400 && err.statusCode < 500) { let message = `Secret patch failed (${err.statusCode})`; @@ -810,62 +765,6 @@ export const organizationKiloclawRouter = createTRPCRouter({ return client.getConfig({ userId: ctx.user.id, instanceId: workerInstanceId(instance) }); }), - getComposioOnboardingStatus: organizationMemberProcedure.query(async ({ ctx, input }) => { - const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); - let sandboxHasComposioSecrets = false; - if (instance) { - const token = generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }); - const client = new KiloClawUserClient(token); - const config = await client.getConfig({ - userId: ctx.user.id, - instanceId: workerInstanceId(instance), - }); - sandboxHasComposioSecrets = config.configuredSecrets.composio === true; - } - return await getManagedComposioGoogleCalendarStatus({ - scope: { - ownerType: 'organization_user', - userId: ctx.user.id, - organizationId: input.organizationId, - }, - instance, - sandboxHasComposioSecrets, - }); - }), - - createComposioGoogleCalendarLink: organizationMemberMutationProcedure - .input(composioConnectLinkSchema) - .mutation(async ({ ctx, input }) => { - return await withKiloclawProvisionContextLock( - getOrganizationProvisionLockKey(ctx.user.id, input.organizationId), - async () => { - const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); - const sandboxConfigSource = instance - ? await getComposioInstanceConfigSource(instance.id) - : null; - if (sandboxConfigSource === 'manual') { - throw new TRPCError({ - code: 'CONFLICT', - message: 'This sandbox already uses your own Composio credentials.', - }); - } - - return await createManagedComposioGoogleCalendarLink({ - userId: ctx.user.id, - scope: { - ownerType: 'organization_user', - userId: ctx.user.id, - organizationId: input.organizationId, - }, - organizationId: input.organizationId, - returnTo: input.returnTo, - popup: input.popup, - attemptId: input.attemptId, - }); - } - ); - }), - getChannelCatalog: organizationMemberProcedure.query(async ({ ctx, input }) => { const instance = await requireOrgInstance(ctx.user.id, input.organizationId); const token = generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }); diff --git a/services/kiloclaw/AGENTS.md b/services/kiloclaw/AGENTS.md index 01bb0ed99e..75e20657bd 100644 --- a/services/kiloclaw/AGENTS.md +++ b/services/kiloclaw/AGENTS.md @@ -16,6 +16,7 @@ These are non-negotiable. Do not reintroduce shared/fallback paths. - **Env var name constraints.** User-provided `envVars` and `encryptedSecrets` keys must be valid shell identifiers (`/^[A-Za-z_][A-Za-z0-9_]*$/`) and must not use reserved prefixes `KILOCLAW_ENC_` or `KILOCLAW_ENV_`. Validated at schema level (ingest) and runtime (decrypt block). - **Token comparisons must be timing-safe.** Never compare auth/proxy tokens with `===`/`!==`. Use `timingSafeTokenEqual` from `controller/src/auth.ts` (or an equivalent `crypto.timingSafeEqual`-based helper) for bearer/proxy token validation. - **`kiloclaw_instances` write split.** The Worker is the sole inserter of rows (`insertProvisionedInstanceRecord`, enforced by `.specs/kiloclaw-datamodel.md` rule 21) — infrastructure is provisioned first, the row reflects it. The Worker also owns updates to a small set of DO-mirrored columns: destroy finalization (`markDestroyedInPostgresHelper`) and denormalized operational metadata (`tracked_image_tag`, `instance_type`, `admin_size_override`) written via `syncTrackedImageTagToPostgresHelper` / `syncInstanceTypeToPostgresHelper` / `syncAdminSizeOverrideToPostgresHelper`. The DO is the source of truth for these columns; the Postgres copy is a denormalized read cache for SQL-filterable admin tooling. `tracked_image_tag` and `instance_type` are written by the alarm reconciler when DO state changes; `admin_size_override` is written only on explicit admin RPC paths (set/clear/auto-clear-on-tier-resize) — there is no observation path. Next.js owns updates to ownership, lifecycle, and billing columns (`organization_id`, `name`, `inbound_email_enabled`, soft-delete via `markActiveInstanceDestroyed`, etc. in `apps/web/src/lib/kiloclaw/instance-registry.ts` and `instance-lifecycle.ts`). Next.js never inserts `kiloclaw_instances` rows. New Worker writes beyond the DO-mirrored carve-out require explicit justification — prefer a Next.js tRPC procedure unless the data fundamentally lives in the DO and is being denormalized for query-shape reasons. +- **Fresh-provision admission reservations (required rollout target).** Once the Registry reservation rollout lands, fresh instance creation MUST acquire durable, non-routable context admission in `KiloClawRegistry` before invoking `KiloClawInstance.provision()` or provider allocation. A reservation is not a Postgres instance record and grants no access. Personal admission is per user; organization admission is per assigned user within `org:{orgId}`. Pending or reconciliation-required attempts fail closed and MUST NOT be exposed through routable registry entry reads. Until that rollout is deployed, the existing web provision lock is transitional protection and must not be removed. - **DO restore from Postgres.** If DO SQLite is wiped, `start(userId)` reads the active instance row from Postgres and repopulates the DO state. This is the backup path for development mistakes that corrupt DO storage. - **Two-phase destroy.** Fly resource IDs (`pendingDestroyMachineId`, `pendingDestroyVolumeId`) are persisted before deletion attempts. DO state is only cleared when both are confirmed deleted. The alarm retries on failure. - **No machine recreation on transient errors.** `startExistingMachine()` only creates a new machine on 404 (confirmed gone). Transient Fly API errors (500, timeout) are re-thrown, not masked by duplicate creation. @@ -219,10 +220,10 @@ KiloClaw is transitioning from one-instance-per-user to N-instances-per-owner (p - **Postgres `kiloclaw_instances.id` IS the instanceId.** There is no separate `instance_id` column. The existing UUID primary key is the routing identity. - **Row creation is Worker-driven.** Provision flows call the Worker's platform route, which inserts the `kiloclaw_instances` row and returns the `id`. `ensureActiveInstance()` in Next.js is now a read-through helper that returns the existing row; it does not create rows. See `.specs/kiloclaw-datamodel.md` rule 21. -### Upcoming PRs (do not implement yet) +### Current multi-instance control plane -- **KiloClawRegistry DO** — SQLite-backed DO that indexes instances per owner (`user:{userId}` or `org:{orgId}`). Will replace direct `idFromName(userId)` lookups in the catch-all proxy. -- **Org instances** — `organization_id` column (already added to schema) links instances to orgs. Org tRPC router, org membership checks, and org member removal cleanup are pending. +- **KiloClawRegistry DO** — SQLite-backed DO that indexes instances per owner (`user:{userId}` or `org:{orgId}`) for routing. Fresh-provision admission reservations are being added as a separate non-routable state surface; never expose pending reservations from normal route-resolution methods. +- **Org instances** — `organization_id` links instances to org contexts. Registry entries for an organization may contain several assigned users; any current one-active-instance admission rule must be qualified by assigned user rather than treating the whole organization as one slot. ## Code Style