diff --git a/.specs/kiloclaw-composio.md b/.specs/kiloclaw-composio.md index 933dbbaa5a..9faecf98b0 100644 --- a/.specs/kiloclaw-composio.md +++ b/.specs/kiloclaw-composio.md @@ -2,7 +2,7 @@ ## Role of This Document -This spec defines the security and product rules for user-provided Composio CLI credentials configured in KiloClaw Settings. Managed Composio identity provisioning and managed connection onboarding are retired and are not supported behavior. Removing retired managed persistence does not alter this manual Settings contract. +This spec defines the security and product rules for the user-provided Composio credential configured in KiloClaw Settings. Managed Composio identity provisioning and managed connection onboarding are retired and are not supported behavior. Removing retired managed persistence does not alter this manual Settings contract. It deliberately does not prescribe implementation details such as endpoint names, column layouts, or controller helper structure. @@ -10,6 +10,7 @@ It deliberately does not prescribe implementation details such as endpoint names Draft -- created for managed Composio onboarding in PR #3348 on 2026-05-20. Updated 2026-05-27 -- reduced to manual Settings configuration after retiring managed onboarding and storage. +Updated 2026-07-22 -- switched the supported surface from Composio CLI sign-in to Composio Connect over MCP. ## Conventions @@ -17,13 +18,17 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ## Definitions -- **Composio CLI credentials**: The user API key and organization identifier required to sign the `composio` CLI into a user's Composio account or organization. -- **Manual Composio configuration**: User-provided Composio CLI credentials saved through KiloClaw Settings and injected into that user's OpenClaw instance. -- **OpenClaw instance**: The provider-backed KiloClaw environment where OpenClaw and the `composio` CLI run. +- **Consumer key**: The `ck_`-prefixed credential a user copies from the AI Clients page of the Composio dashboard. It authenticates against Composio Connect and is not interchangeable with the CLI's user API key (`uak_`) or a project API key (`ak_`). +- **Composio Connect**: Composio's hosted MCP server, which exposes toolkit capabilities as MCP tools to any client presenting a consumer key. +- **Manual Composio configuration**: A user-provided consumer key saved through KiloClaw Settings and injected into that user's OpenClaw instance. +- **Legacy CLI credentials**: `COMPOSIO_USER_API_KEY` and `COMPOSIO_ORG` values configured before this surface existed, or created by a user running `composio login` inside their own instance. +- **OpenClaw instance**: The provider-backed KiloClaw environment where OpenClaw runs. ## Overview -KiloClaw supports Composio only as explicitly user-provided Settings secrets. A user may enter Composio CLI credentials, which are validated, encrypted, transported through the existing instance secret pipeline, and used by the controller to make the Composio CLI available inside that user's instance. +KiloClaw supports Composio only as an explicitly user-provided Settings secret. A user may enter a consumer key, which is validated, encrypted, transported through the existing instance secret pipeline, and written by the controller into the instance's OpenClaw configuration as a remote MCP server definition. Composio's tools then reach the agent over HTTP; KiloClaw installs and runs nothing on the user's behalf. + +Toolkit authorization (Gmail, Calendar, and so on) happens entirely in the Composio dashboard. KiloClaw MUST NOT attempt to broker, initiate, or store those connections. Kilo MUST NOT provision managed Composio identities, create managed Connect Link onboarding flows, store managed Composio credential state, or inject operator-owned or previously managed credentials into instances. @@ -31,42 +36,51 @@ Kilo MUST NOT provision managed Composio identities, create managed Connect Link ### Manual Configuration -1. Manual Composio configuration MUST be opt-in. An instance without both required Composio fields MUST continue to boot without Composio CLI sign-in. -2. The system MUST validate manual Composio fields according to the secret catalog contract before saving or provisioning them. If either required Composio field is supplied during provision, both MUST be supplied together. -3. Manual Composio credentials MUST be treated as user-provided secrets. Both the user API key and organization value MUST be encrypted before reaching the KiloClaw Worker and MUST use the existing encrypted instance-secret transport pipeline. -4. Manual Composio fields MAY remain configurable through Settings and MAY be updated or removed through the normal instance secret update path. -5. Kilo MUST NOT rotate, revoke, claim, share, or otherwise manage manually provided Composio credentials unless a future supported flow explicitly requests that behavior. -6. Manual personal Composio credentials MUST NOT be reused for an organization instance unless the user explicitly configures them in that organization context. +1. Manual Composio configuration MUST be opt-in. An instance without a consumer key MUST continue to boot with no Composio server defined. +2. The system MUST validate the consumer key according to the secret catalog contract before saving or provisioning it. +3. Consumer key validation SHOULD stay permissive beyond the credential family prefix. Composio performs no prefix or length check of its own, so validation stricter than the documented shape risks rejecting a valid credential. +4. The consumer key MUST be treated as a user-provided secret, encrypted before reaching the KiloClaw Worker, and carried by the existing encrypted instance-secret transport pipeline. +5. The consumer key MAY be updated or removed through the normal instance secret update path. +6. Kilo MUST NOT rotate, revoke, claim, share, or otherwise manage a manually provided Composio credential unless a future supported flow explicitly requests that behavior. +7. A personal consumer key MUST NOT be reused for an organization instance unless the user explicitly configures it in that organization context. ### Removed Managed Behavior -7. Kilo MUST NOT create new managed Composio identities, managed connected-account onboarding flows, Connect Links for managed onboarding, or managed credential injection for KiloClaw. -8. Kilo MUST NOT fall back from missing manual Composio credentials to any operator-owned, shared, historical, or managed credential. -9. New instances and Settings updates MUST NOT create retired managed-onboarding metadata for manual Composio configuration. -10. Direct Google Calendar onboarding, when offered, is independent of Composio and MUST NOT depend on retired managed Composio state. +8. Kilo MUST NOT create new managed Composio identities, managed connected-account onboarding flows, Connect Links for managed onboarding, or managed credential injection for KiloClaw. +9. Kilo MUST NOT fall back from a missing consumer key to any operator-owned, shared, historical, or managed credential. +10. New instances and Settings updates MUST NOT create retired managed-onboarding metadata for manual Composio configuration. +11. Direct Google Calendar onboarding, when offered, is independent of Composio and MUST NOT depend on retired managed Composio state. -### Instance CLI Sign-In +### Instance Configuration -11. The OpenClaw instance MAY contain the Composio CLI when no Composio credentials are configured. -12. When valid manual Composio credentials are present, the controller SHOULD sign the CLI in during bootstrap so `composio` commands work without interactive browser login. -13. Composio CLI sign-in MUST be best-effort and MUST NOT prevent controller startup unless a future product contract makes it required. -14. If sign-in uses a subprocess, the implementation MUST invoke a direct executable rather than a shell and MUST suppress logs containing credentials. -15. Any Composio CLI state files written by the controller MUST use owner-only permissions and remain inside the instance user's Composio configuration directory. -16. Credentials used only for CLI sign-in MUST NOT remain unnecessarily available to unrelated child processes. +12. When a consumer key is present, the controller MUST define Composio Connect as a remote MCP server in the instance's OpenClaw configuration, replacing any existing definition of that server outright. It MUST NOT install software, spawn a login subprocess, or perform any network call on the user's behalf to establish the connection. Carrying fields over from a previous definition risks retaining an authentication mode that suppresses the configured credential. +13. When no consumer key is present, the controller MUST remove the server definition it manages, because instance configuration persists across redeploys and a stale definition would otherwise outlive the credential's removal from Settings. +14. Removal MUST be limited to a definition KiloClaw explicitly marked as managed when it wrote it. Ownership MUST NOT be inferred from a definition's endpoint, transport, headers, or any other value published by Composio, because a user configuring the same product by hand produces an identical definition. An unmarked Composio server MUST be left intact. +15. Configuring Composio MUST NOT prevent controller startup. An unreachable or unauthorized endpoint surfaces at tool-call time and MUST NOT be treated as a boot failure. +16. The instance MAY continue to contain the Composio CLI, and legacy CLI credentials MUST continue to reach the instance so that a sign-in a user performed themselves is not broken by an upgrade. Retiring a credential's Settings field MUST NOT downgrade how that credential is carried: a retired credential env var name MUST remain classified sensitive so its value is still encrypted in transport rather than written to the provider's plaintext environment. +17. Agent-facing documentation MUST describe the MCP surface and MUST NOT instruct the agent to sign the CLI in, because doing so cannot change which tools the configured credential reaches. ### Data Protection and Logging -17. Logs, analytics, audit records, Sentry events, command output, and user-facing errors MUST NOT contain raw Composio credentials, OAuth tokens, or generated login commands containing credential material. -18. Manual Composio secrets MUST follow the normal KiloClaw secret encryption, transport, update, and deletion rules. +18. Logs, analytics, audit records, Sentry events, command output, and user-facing errors MUST NOT contain raw Composio credentials, OAuth tokens, or generated commands containing credential material. +19. Manual Composio secrets MUST follow the normal KiloClaw secret encryption, transport, update, and deletion rules. ## Error Handling -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. +1. If no consumer key is configured, the controller MUST continue startup with no Composio server defined. +2. If consumer key validation fails, the save or provision request MUST fail before transporting the invalid credential to the Worker. +3. If Composio Connect is unreachable or rejects the credential, the failure MUST surface to the agent at tool-call time and MUST NOT degrade the instance. ## Changelog +### 2026-07-22 -- Composio Connect over MCP + +- Replaced the CLI user API key and organization fields with a single consumer key field. +- Defined Composio Connect as a remote MCP server written into instance configuration, replacing controller-run CLI sign-in as the supported path. +- Scoped managed removal to definitions KiloClaw marks as its own, so hand-configured Composio servers are not deleted. +- Kept legacy CLI credentials flowing to instances, and the CLI installed, so existing manual sign-ins survive. +- Retained the two legacy CLI env var names as always-sensitive so a value under either stays encrypted in transport even though the fields left the catalog. + ### 2026-05-27 -- Retained manual configuration only - Removed managed identity provisioning, managed Connect Link onboarding, managed persistence, and instance-source tracking from supported behavior. diff --git a/apps/web/src/app/(app)/claw/components/secret-ui-adapter.ts b/apps/web/src/app/(app)/claw/components/secret-ui-adapter.ts index 042bd92ba5..a1f2d4f851 100644 --- a/apps/web/src/app/(app)/claw/components/secret-ui-adapter.ts +++ b/apps/web/src/app/(app)/claw/components/secret-ui-adapter.ts @@ -36,7 +36,7 @@ const DESCRIPTION_MAP: Record = { agentcard: 'Give your bot virtual debit cards for spending', onepassword: 'Look up credentials and manage vault items via the op CLI', 'brave-search': 'Add a Brave Search API key for web search', - composio: 'Sign the Composio CLI into this sandbox', + composio: 'Connect your Composio toolkits', }; export function getDescription(entryId: string): string { diff --git a/apps/web/src/lib/kiloclaw/provision-secrets.test.ts b/apps/web/src/lib/kiloclaw/provision-secrets.test.ts index 6a35a336d0..1c523299e2 100644 --- a/apps/web/src/lib/kiloclaw/provision-secrets.test.ts +++ b/apps/web/src/lib/kiloclaw/provision-secrets.test.ts @@ -8,13 +8,11 @@ describe('encryptProvisionSecretsForWorker', () => { it('maps valid manual Composio secret keys to worker env var names before encrypting', () => { expect( encryptProvisionSecretsForWorker({ - composioUserApiKey: 'uak_manual_credential_123', - composioOrg: 'org-1', + composioConsumerKey: 'ck_manual_credential_123', CUSTOM_SECRET: 'kept', }) ).toEqual({ - COMPOSIO_USER_API_KEY: 'encrypted:uak_manual_credential_123', - COMPOSIO_ORG: 'encrypted:org-1', + COMPOSIO_CONSUMER_KEY: 'encrypted:ck_manual_credential_123', CUSTOM_SECRET: 'encrypted:kept', }); }); @@ -22,23 +20,31 @@ describe('encryptProvisionSecretsForWorker', () => { it('keeps manual Composio validation when secrets are passed during provision', () => { expect(() => encryptProvisionSecretsForWorker({ - composioUserApiKey: 'uak_short', - composioOrg: 'org-1', + composioConsumerKey: 'ck_short', }) - ).toThrow('Composio user API keys start with uak_'); + ).toThrow('Composio consumer keys start with ck_'); }); - const partialComposioCredentialPairs: Array> = [ - { composioUserApiKey: 'uak_manual_credential_123' }, - { composioOrg: 'org-1' }, - ]; + it('rejects a Composio CLI user API key, which belongs to a different surface', () => { + expect(() => + encryptProvisionSecretsForWorker({ + composioConsumerKey: 'uak_manual_credential_123', + }) + ).toThrow('Composio consumer keys start with ck_'); + }); - 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' - ); - } - ); + // Credentials configured before the Composio Connect switch keep flowing to + // the instance as ordinary custom secrets, so a stale CLI login is not + // silently broken by an upgrade. + it('passes legacy Composio CLI env vars through untouched', () => { + expect( + encryptProvisionSecretsForWorker({ + COMPOSIO_USER_API_KEY: 'uak_manual_credential_123', + COMPOSIO_ORG: 'org-1', + }) + ).toEqual({ + COMPOSIO_USER_API_KEY: 'encrypted:uak_manual_credential_123', + COMPOSIO_ORG: 'encrypted:org-1', + }); + }); }); diff --git a/apps/web/src/lib/kiloclaw/provision-secrets.ts b/apps/web/src/lib/kiloclaw/provision-secrets.ts index 941f8d2eaa..da9209ef11 100644 --- a/apps/web/src/lib/kiloclaw/provision-secrets.ts +++ b/apps/web/src/lib/kiloclaw/provision-secrets.ts @@ -6,22 +6,9 @@ import { } 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); -} +const COMPOSIO_SECRET_FIELD_KEYS = ['composioConsumerKey'] as const; 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; diff --git a/packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts b/packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts index d9f541f19f..b2d0c7d956 100644 --- a/packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts +++ b/packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts @@ -8,6 +8,7 @@ import { FIELD_KEY_TO_ENTRY, ALL_SECRET_ENV_VARS, INTERNAL_SENSITIVE_ENV_VARS, + RETAINED_SENSITIVE_ENV_VARS, getEntriesByCategory, getFieldKeysByCategory, isValidCustomSecretKey, @@ -129,8 +130,7 @@ describe('Secret Catalog', () => { 'GITHUB_EMAIL', 'BRAVE_API_KEY', 'LINEAR_API_KEY', - 'COMPOSIO_USER_API_KEY', - 'COMPOSIO_ORG', + 'COMPOSIO_CONSUMER_KEY', ]); const catalogEnvVars = new Set(FIELD_KEY_TO_ENV_VAR.values()); @@ -149,8 +149,7 @@ describe('Secret Catalog', () => { expect(FIELD_KEY_TO_ENV_VAR.get('githubUsername')).toBe('GITHUB_USERNAME'); expect(FIELD_KEY_TO_ENV_VAR.get('githubEmail')).toBe('GITHUB_EMAIL'); expect(FIELD_KEY_TO_ENV_VAR.get('braveSearchApiKey')).toBe('BRAVE_API_KEY'); - expect(FIELD_KEY_TO_ENV_VAR.get('composioUserApiKey')).toBe('COMPOSIO_USER_API_KEY'); - expect(FIELD_KEY_TO_ENV_VAR.get('composioOrg')).toBe('COMPOSIO_ORG'); + expect(FIELD_KEY_TO_ENV_VAR.get('composioConsumerKey')).toBe('COMPOSIO_CONSUMER_KEY'); }); it('ENV_VAR_TO_FIELD_KEY is the exact reverse of FIELD_KEY_TO_ENV_VAR', () => { @@ -169,8 +168,7 @@ describe('Secret Catalog', () => { expect(ENV_VAR_TO_FIELD_KEY.get('GITHUB_USERNAME')).toBe('githubUsername'); expect(ENV_VAR_TO_FIELD_KEY.get('GITHUB_EMAIL')).toBe('githubEmail'); expect(ENV_VAR_TO_FIELD_KEY.get('BRAVE_API_KEY')).toBe('braveSearchApiKey'); - expect(ENV_VAR_TO_FIELD_KEY.get('COMPOSIO_USER_API_KEY')).toBe('composioUserApiKey'); - expect(ENV_VAR_TO_FIELD_KEY.get('COMPOSIO_ORG')).toBe('composioOrg'); + expect(ENV_VAR_TO_FIELD_KEY.get('COMPOSIO_CONSUMER_KEY')).toBe('composioConsumerKey'); }); }); @@ -246,9 +244,8 @@ describe('Secret Catalog', () => { expect(keys).toContain('agentcardApiKey'); expect(keys).toContain('onepasswordServiceAccountToken'); expect(keys).toContain('braveSearchApiKey'); - expect(keys).toContain('composioUserApiKey'); - expect(keys).toContain('composioOrg'); - expect(keys.size).toBe(9); + expect(keys).toContain('composioConsumerKey'); + expect(keys.size).toBe(8); }); it('returns empty set for categories with no entries', () => { @@ -400,17 +397,18 @@ describe('Secret Catalog', () => { expect(validateFieldValue('bsa' + 'A'.repeat(20), pattern)).toBe(false); }); - it('accepts valid Composio user API keys', () => { - const pattern = '^uak_[A-Za-z0-9_-]{16,}$'; - expect(validateFieldValue('uak_FAKE_TEST_KEY_1234567890', pattern)).toBe(true); - expect(validateFieldValue('uak_' + 'A'.repeat(16), pattern)).toBe(true); + it('accepts valid Composio consumer keys', () => { + const pattern = '^ck_[A-Za-z0-9_-]{8,}$'; + expect(validateFieldValue('ck_FAKE_TEST_KEY_1234567890', pattern)).toBe(true); + expect(validateFieldValue('ck_' + 'A'.repeat(8), pattern)).toBe(true); }); - it('rejects invalid Composio user API keys', () => { - const pattern = '^uak_[A-Za-z0-9_-]{16,}$'; + it('rejects invalid Composio consumer keys', () => { + const pattern = '^ck_[A-Za-z0-9_-]{8,}$'; expect(validateFieldValue('invalid', pattern)).toBe(false); - expect(validateFieldValue('uak_short', pattern)).toBe(false); - expect(validateFieldValue('ak_' + 'A'.repeat(16), pattern)).toBe(false); + expect(validateFieldValue('ck_short', pattern)).toBe(false); + // uak_ is the CLI's user API key — a different credential family. + expect(validateFieldValue('uak_' + 'A'.repeat(16), pattern)).toBe(false); }); it('rejects empty strings', () => { @@ -467,13 +465,12 @@ describe('Secret Catalog', () => { ]); }); - it('composio entry requires user API key and organization', () => { + it('composio entry takes a single consumer key', () => { const composio = SECRET_CATALOG_MAP.get('composio'); - expect(composio?.allFieldsRequired).toBe(true); - expect(composio?.fields.map(f => f.key)).toEqual(['composioUserApiKey', 'composioOrg']); - expect( - composio?.fields.find(f => f.key === 'composioOrg')?.validationPattern - ).toBeUndefined(); + // One field, so there is nothing to require together. + expect(composio?.allFieldsRequired).toBeFalsy(); + expect(composio?.fields.map(f => f.key)).toEqual(['composioConsumerKey']); + expect(composio?.fields[0]?.envVar).toBe('COMPOSIO_CONSUMER_KEY'); }); it('telegram and discord do not have allFieldsRequired', () => { @@ -508,6 +505,27 @@ describe('Secret Catalog', () => { }); }); + describe('RETAINED_SENSITIVE_ENV_VARS', () => { + it('keeps retired Composio CLI names classified sensitive', () => { + expect(RETAINED_SENSITIVE_ENV_VARS.has('COMPOSIO_USER_API_KEY')).toBe(true); + expect(RETAINED_SENSITIVE_ENV_VARS.has('COMPOSIO_ORG')).toBe(true); + }); + + it('does not re-add retired names to the catalog', () => { + for (const envVar of RETAINED_SENSITIVE_ENV_VARS) { + expect(ALL_SECRET_ENV_VARS.has(envVar)).toBe(false); + } + }); + + // Retained names stay visible/deletable in the Custom Secrets UI — the + // reason they live here rather than in INTERNAL_SENSITIVE_ENV_VARS. + it('leaves retired names visible as custom secrets', () => { + for (const envVar of RETAINED_SENSITIVE_ENV_VARS) { + expect(isCustomSecretEnvVar(envVar)).toBe(true); + } + }); + }); + describe('maxLength contract', () => { it('all maxLength values are within the global ceiling', () => { for (const entry of SECRET_CATALOG) { diff --git a/packages/kiloclaw-secret-catalog/src/catalog.ts b/packages/kiloclaw-secret-catalog/src/catalog.ts index b379488322..30a2a60f2f 100644 --- a/packages/kiloclaw-secret-catalog/src/catalog.ts +++ b/packages/kiloclaw-secret-catalog/src/catalog.ts @@ -231,29 +231,24 @@ const SECRET_CATALOG_RAW = [ category: 'tool', icon: 'plug', order: 6, - allFieldsRequired: true, fields: [ { - key: 'composioUserApiKey', - label: 'User API Key', - placeholder: 'uak_...', - placeholderConfigured: 'Enter new user API key to replace', - envVar: 'COMPOSIO_USER_API_KEY', - validationPattern: '^uak_[A-Za-z0-9_-]{16,}$', - validationMessage: 'Composio user API keys start with uak_.', - maxLength: 300, - }, - { - key: 'composioOrg', - label: 'Organization ID or Name', - placeholder: 'username_workspace', - placeholderConfigured: 'Enter new organization ID, name, or slug to replace', - envVar: 'COMPOSIO_ORG', + key: 'composioConsumerKey', + label: 'Consumer Key', + placeholder: 'ck_...', + placeholderConfigured: 'Enter new consumer key to replace', + envVar: 'COMPOSIO_CONSUMER_KEY', + // Deliberately loose: Composio's own CLI performs no prefix or length + // check on its keys, so anything stricter than "looks like a consumer + // key" risks rejecting a valid credential we have not seen yet. + validationPattern: '^ck_[A-Za-z0-9_-]{8,}$', + validationMessage: 'Composio consumer keys start with ck_.', maxLength: 300, }, ], - helpText: 'Used to sign the Composio CLI into this sandbox.', - helpUrl: 'https://docs.composio.dev/docs/cli', + helpText: + 'Connects this instance to your Composio toolkits. Copy a consumer key from the AI Clients page in the Composio dashboard.', + helpUrl: 'https://dashboard.composio.dev', }, ] as const satisfies readonly SecretCatalogEntry[]; @@ -312,6 +307,25 @@ export const INTERNAL_SENSITIVE_ENV_VARS: ReadonlySet = new Set([ 'KILOCLAW_GOG_CONFIG_TARBALL', ]); +/** + * Env var names that used to be catalog fields and must still be classified + * sensitive even though they no longer appear in the UI. + * + * Plaintext env vars are encrypted only when their name is a known sensitive + * key; a name that leaves the catalog would otherwise be written to the + * provider's plaintext env. These names carried credentials before they were + * retired, so a value entered under one of them stays encrypted in transport. + * + * Unlike INTERNAL_SENSITIVE_ENV_VARS, these are deliberately NOT excluded from + * isCustomSecretEnvVar: a leftover value should remain visible and deletable in + * the Custom Secrets UI, just carried securely. + */ +export const RETAINED_SENSITIVE_ENV_VARS: ReadonlySet = new Set([ + // Retired 2026-07-22 when Composio moved from CLI sign-in to a consumer key. + 'COMPOSIO_USER_API_KEY', + 'COMPOSIO_ORG', +]); + /** * Get all entries for a given category, sorted by order (undefined sorts last). */ diff --git a/packages/kiloclaw-secret-catalog/src/index.ts b/packages/kiloclaw-secret-catalog/src/index.ts index 7898edb2ce..3834082923 100644 --- a/packages/kiloclaw-secret-catalog/src/index.ts +++ b/packages/kiloclaw-secret-catalog/src/index.ts @@ -29,6 +29,7 @@ export { ALL_SECRET_ENV_VARS, MAX_SECRET_FIELD_LENGTH, INTERNAL_SENSITIVE_ENV_VARS, + RETAINED_SENSITIVE_ENV_VARS, getEntriesByCategory, getFieldKeysByCategory, // Custom secret helpers diff --git a/services/kiloclaw/controller/src/bootstrap.test.ts b/services/kiloclaw/controller/src/bootstrap.test.ts index 0da785b3d4..29d66572d0 100644 --- a/services/kiloclaw/controller/src/bootstrap.test.ts +++ b/services/kiloclaw/controller/src/bootstrap.test.ts @@ -2045,12 +2045,15 @@ describe('TOOLS.md section configs', () => { expect(GOG_SECTION_CONFIG.section).not.toContain('gog drive files list'); }); - it('Composio section references core CLI commands', () => { + it('Composio section describes the MCP surface and steers the agent away from the CLI', () => { const section = COMPOSIO_SECTION_CONFIG.section; - expect(section).toContain('composio whoami'); - expect(section).toContain('composio search'); - expect(section).toContain('composio connections list'); - expect(section).toContain('composio link '); + expect(section).toContain('MCP server'); + expect(section).toContain('https://dashboard.composio.dev'); + expect(section).toContain('Do NOT run `composio login`'); + // The CLI stays installed for users who set it up by hand, but the agent + // must not coach anyone back onto it. + expect(section).not.toContain('composio whoami'); + expect(section).not.toContain('composio link '); }); }); diff --git a/services/kiloclaw/controller/src/bootstrap.ts b/services/kiloclaw/controller/src/bootstrap.ts index b249ac74fe..4fe4baaed5 100644 --- a/services/kiloclaw/controller/src/bootstrap.ts +++ b/services/kiloclaw/controller/src/bootstrap.ts @@ -1303,13 +1303,11 @@ export const COMPOSIO_SECTION_CONFIG: ToolsMdSectionConfig = { ## Composio -The \`composio\` CLI is configured for this sandbox. Use it to discover and run Composio tools, or to create connection links for external services. +Composio is connected to this sandbox as an MCP server, so its toolkits (Gmail, Calendar, Slack, and ~1000 others) appear as regular tools you can call directly. There is nothing to install or log into. -- Check account: \`composio whoami\` -- Search tools: \`composio search "send email"\` -- List connections: \`composio connections list\` -- Connect a toolkit: \`composio link \` -- Run \`composio --help\` and \`composio --help\` for all available commands. +- Composio exposes a small set of meta-tools rather than one tool per toolkit. Use them to search for a capability, then execute it. +- If a tool returns an authentication error, that toolkit is not connected yet. Tell the user to connect it at https://dashboard.composio.dev — it cannot be done from inside this sandbox. +- Do NOT run \`composio login\`. The user's credential is supplied through KiloClaw Settings and signing in again with a different account will not change which tools you can reach. `, }; @@ -1469,11 +1467,7 @@ export async function bootstrapNonCritical( updateToolsMdSection(googleWorkspaceToolsEnabled, GOG_SECTION_CONFIG, deps); updateToolsMdSection(!!env.OP_SERVICE_ACCOUNT_TOKEN, OP_SECTION_CONFIG, deps); updateToolsMdSection(!!env.LINEAR_API_KEY, LINEAR_SECTION_CONFIG, deps); - updateToolsMdSection( - !!env.COMPOSIO_USER_API_KEY && !!env.COMPOSIO_ORG, - COMPOSIO_SECTION_CONFIG, - deps - ); + updateToolsMdSection(!!env.COMPOSIO_CONSUMER_KEY, COMPOSIO_SECTION_CONFIG, deps); // Always-on: agent context about KiloClaw-mitigated audit findings // and how to keep plugins.allow in sync on plugin installs. updateToolsMdSection(true, KILOCLAW_MITIGATIONS_SECTION_CONFIG, deps); diff --git a/services/kiloclaw/controller/src/config-writer.test.ts b/services/kiloclaw/controller/src/config-writer.test.ts index d2b7da3ab5..80effcd488 100644 --- a/services/kiloclaw/controller/src/config-writer.test.ts +++ b/services/kiloclaw/controller/src/config-writer.test.ts @@ -1795,6 +1795,187 @@ function mcporterFakeDeps(existingMcporterConfig?: string) { }; } +describe('Composio Connect MCP server', () => { + const KEY = 'ck_FAKE_TEST_KEY_1234567890'; + + function composioServer(config: Record) { + return config.mcp?.servers?.composio; + } + + it('registers the remote server with the consumer key header when the key is set', () => { + const { deps } = fakeDeps(); + const config = generateBaseConfig( + { ...minimalEnv(), COMPOSIO_CONSUMER_KEY: KEY }, + '/tmp/openclaw.json', + deps + ); + + expect(composioServer(config)).toEqual({ + kiloclawManaged: true, + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + headers: { 'x-consumer-api-key': KEY }, + }); + }); + + it('does not define the server when no key is configured', () => { + const { deps } = fakeDeps(); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(composioServer(config)).toBeUndefined(); + }); + + it('trims surrounding whitespace off a pasted key', () => { + const { deps } = fakeDeps(); + const config = generateBaseConfig( + { ...minimalEnv(), COMPOSIO_CONSUMER_KEY: ` ${KEY} ` }, + '/tmp/openclaw.json', + deps + ); + + expect(composioServer(config).headers).toEqual({ 'x-consumer-api-key': KEY }); + }); + + it('replaces a stale key on the next boot', () => { + const existing = JSON.stringify({ + mcp: { + servers: { + composio: { + kiloclawManaged: true, + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + headers: { 'x-consumer-api-key': 'ck_OLD_KEY_0987654321' }, + }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const config = generateBaseConfig( + { ...minimalEnv(), COMPOSIO_CONSUMER_KEY: KEY }, + '/tmp/openclaw.json', + deps + ); + + expect(composioServer(config).headers).toEqual({ 'x-consumer-api-key': KEY }); + }); + + // Taking over an existing definition must not inherit its connection fields. + it('replaces a hand-rolled stdio server outright rather than merging into it', () => { + const { deps } = fakeDeps( + JSON.stringify({ + mcp: { + servers: { + composio: { transport: 'stdio', command: 'composio', args: ['mcp', 'serve'] }, + }, + }, + }) + ); + const config = generateBaseConfig( + { ...minimalEnv(), COMPOSIO_CONSUMER_KEY: KEY }, + '/tmp/openclaw.json', + deps + ); + + expect(composioServer(config)).toEqual({ + kiloclawManaged: true, + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + headers: { 'x-consumer-api-key': KEY }, + }); + }); + + // A surviving `auth: 'oauth'` makes OpenClaw drop request headers entirely, + // so the pasted key would authenticate nothing and report no error. + it('drops a pre-existing oauth mode so the consumer key header is actually sent', () => { + const { deps } = fakeDeps( + JSON.stringify({ + mcp: { + servers: { + composio: { + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + auth: 'oauth', + }, + }, + }, + }) + ); + const config = generateBaseConfig( + { ...minimalEnv(), COMPOSIO_CONSUMER_KEY: KEY }, + '/tmp/openclaw.json', + deps + ); + + expect(composioServer(config).auth).toBeUndefined(); + expect(composioServer(config).headers).toEqual({ 'x-consumer-api-key': KEY }); + }); + + // openclaw.json lives on the volume, so removing the credential in Settings + // only revokes access if the server definition goes with it. + it('removes its own server definition once the key is cleared', () => { + const existing = JSON.stringify({ + mcp: { + servers: { + composio: { + kiloclawManaged: true, + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + headers: { 'x-consumer-api-key': KEY }, + }, + other: { url: 'https://mcp.example.com/mcp' }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(composioServer(config)).toBeUndefined(); + expect(config.mcp.servers.other).toEqual({ url: 'https://mcp.example.com/mcp' }); + }); + + // The rollout hazard: before this feature, the only way to use Composio + // Connect was to configure it by hand — same URL, same header, no marker. + // The first boot after rollout, before the user fills in the new Settings + // field, must not delete that working server. + it('leaves an unmarked Composio Connect server (same URL and header) alone when no key is set', () => { + const handConfigured = { + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + headers: { 'x-consumer-api-key': 'ck_users_own_key_123456' }, + }; + const { deps } = fakeDeps(JSON.stringify({ mcp: { servers: { composio: handConfigured } } })); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(composioServer(config)).toEqual(handConfigured); + }); + + // Users wired Composio up by hand long before this field existed. Their + // config is not ours to delete. + it('leaves a hand-rolled composio server alone when no key is configured', () => { + const handRolled = { + transport: 'stdio', + command: 'composio', + args: ['mcp', 'serve'], + }; + const { deps } = fakeDeps(JSON.stringify({ mcp: { servers: { composio: handRolled } } })); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(composioServer(config)).toEqual(handRolled); + }); + + it('leaves a differently-authenticated remote composio server alone', () => { + const oauthServer = { + transport: 'streamable-http', + url: 'https://connect.composio.dev/mcp', + auth: 'oauth', + }; + const { deps } = fakeDeps(JSON.stringify({ mcp: { servers: { composio: oauthServer } } })); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(composioServer(config)).toEqual(oauthServer); + }); +}); + describe('writeMcporterConfig', () => { it('adds Linear MCP server when LINEAR_API_KEY is set', () => { const { deps, written } = mcporterFakeDeps(); diff --git a/services/kiloclaw/controller/src/config-writer.ts b/services/kiloclaw/controller/src/config-writer.ts index 7cf17ed80d..cfe8d0ea8c 100644 --- a/services/kiloclaw/controller/src/config-writer.ts +++ b/services/kiloclaw/controller/src/config-writer.ts @@ -883,6 +883,13 @@ export function generateBaseConfig( config.plugins.entries.slack.enabled = true; } + // Composio Connect — a remote MCP server operated by Composio. Nothing is + // installed in the container: OpenClaw dials the URL over streamable HTTP + // and forwards the consumer key as a header, so the whole integration is + // this config block. Toolkit connections (Gmail, Calendar, ...) are made by + // the user in the Composio dashboard and never touch KiloClaw. + applyComposioConnectConfig(config, env.COMPOSIO_CONSUMER_KEY); + // Session — default DM scope to per-channel-peer so each channel+peer // combination gets its own session. OpenClaw's onboard sets this for new // instances, but legacy instances may not have it. @@ -1036,6 +1043,69 @@ export function generateBaseConfig( return config; } +/** Composio's hosted MCP endpoint. */ +export const COMPOSIO_CONNECT_MCP_URL = 'https://connect.composio.dev/mcp'; +/** Header Composio Connect authenticates consumer keys with. */ +export const COMPOSIO_CONNECT_HEADER = 'x-consumer-api-key'; +/** `mcp.servers` key we manage. */ +export const COMPOSIO_MCP_SERVER_NAME = 'composio'; +/** + * Marks the server definition as written by KiloClaw. + * + * Ownership cannot be inferred from the definition's contents: the URL and + * header are Composio's own published values, so a user who wired Composio + * Connect up by hand produces a byte-identical server. OpenClaw's + * McpServerSchema is `.catchall(z.unknown())`, so this extra key validates + * and round-trips untouched. + */ +export const COMPOSIO_MANAGED_MARKER = 'kiloclawManaged'; + +/** + * Add, update, or remove the Composio Connect MCP server definition. + * + * Setting a consumer key hands the whole definition to KiloClaw: it is + * replaced outright rather than merged. Merging would carry foreign fields + * from a hand-rolled server into ours, and a surviving `auth: 'oauth'` is + * worse than untidy — OpenClaw drops the request headers entirely for OAuth + * servers, so the pasted key would be silently ignored. + * + * Removal on an absent key is what makes clearing the credential in Settings + * actually revoke access, since openclaw.json lives on the volume and + * survives redeploys. Only a definition we marked as ours is removed; a + * server the user configured themselves is left alone even when it points at + * the same endpoint. + */ +export function applyComposioConnectConfig( + config: ConfigObject, + consumerKey: string | undefined +): void { + const key = consumerKey?.trim(); + + if (key) { + config.mcp = config.mcp ?? {}; + config.mcp.servers = config.mcp.servers ?? {}; + config.mcp.servers[COMPOSIO_MCP_SERVER_NAME] = { + [COMPOSIO_MANAGED_MARKER]: true, + transport: 'streamable-http', + url: COMPOSIO_CONNECT_MCP_URL, + headers: { [COMPOSIO_CONNECT_HEADER]: key }, + }; + console.log('Composio Connect MCP server configured'); + return; + } + + const existing = config.mcp?.servers?.[COMPOSIO_MCP_SERVER_NAME]; + if (!existing) return; + + if (existing[COMPOSIO_MANAGED_MARKER] !== true) { + console.log('Leaving user-managed Composio MCP server untouched'); + return; + } + + delete config.mcp.servers[COMPOSIO_MCP_SERVER_NAME]; + console.log('Composio Connect MCP server removed'); +} + /** * Set a value at a dot-notation path in a nested object, creating * intermediate objects as needed. diff --git a/services/kiloclaw/src/gateway/env.test.ts b/services/kiloclaw/src/gateway/env.test.ts index 547a4bb143..ad7e029a1f 100644 --- a/services/kiloclaw/src/gateway/env.test.ts +++ b/services/kiloclaw/src/gateway/env.test.ts @@ -111,6 +111,25 @@ describe('buildEnvVars', () => { expect(result.env.NODE_ENV).toBe('production'); }); + it('encrypts a retired Composio env var name set via the plaintext env editor', async () => { + const env = createMockEnv(); + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + envVars: { + COMPOSIO_USER_API_KEY: 'uak_legacy_credential_123', + COMPOSIO_ORG: 'org-1', + HARMLESS_VAR: 'plain', + }, + }); + + // Retired catalog names must stay sensitive so a legacy CLI credential is + // not written to the provider's plaintext env. + expect(result.sensitive.COMPOSIO_USER_API_KEY).toBe('uak_legacy_credential_123'); + expect(result.sensitive.COMPOSIO_ORG).toBe('org-1'); + expect(result.env.COMPOSIO_USER_API_KEY).toBeUndefined(); + expect(result.env.COMPOSIO_ORG).toBeUndefined(); + expect(result.env.HARMLESS_VAR).toBe('plain'); + }); + it('puts KILOCODE_API_KEY in sensitive, default model in env', async () => { const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey }); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { diff --git a/services/kiloclaw/src/gateway/env.ts b/services/kiloclaw/src/gateway/env.ts index 02c7d93bac..914314a9c2 100644 --- a/services/kiloclaw/src/gateway/env.ts +++ b/services/kiloclaw/src/gateway/env.ts @@ -1,6 +1,7 @@ import { ALL_SECRET_ENV_VARS, INTERNAL_SENSITIVE_ENV_VARS, + RETAINED_SENSITIVE_ENV_VARS, } from '@kilocode/kiloclaw-secret-catalog'; import type { KiloClawEnv } from '../types'; import type { @@ -85,6 +86,7 @@ const SENSITIVE_KEYS = new Set([ 'OPENCLAW_GATEWAY_TOKEN', ...ALL_SECRET_ENV_VARS, ...INTERNAL_SENSITIVE_ENV_VARS, + ...RETAINED_SENSITIVE_ENV_VARS, ]); /**