diff --git a/README.md b/README.md index 062e5a1e..090e342c 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan. - `manage_extensions` - List and delete uploaded browser extensions. - `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results. -- `manage_auth_connections` - Create, list, get, delete, login, submit, and wait for managed-auth connections in every client. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too. +- `manage_auth_connections` - Create, list, get, update, delete, login, submit, inspect timelines, and wait for managed-auth connections in every client. Supports health-check and automatic re-auth settings, managed-auth browser configuration, and canonical interaction-bound field/choice submissions. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too. - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. @@ -380,7 +380,7 @@ Returns: { success: true, result: "Example Domain" } Example: “Log me into my Hacker News account and update my profile to add a random emoji at the bottom.” The agent should discover `news.ycombinator.com`, open the App when needed, wait for authentication, then continue the profile edit without asking for credentials or a profile name in chat. -The secure App defaults `record_session` and `browser_telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. The programmatic `manage_auth_connections` create/login actions preserve the API’s opt-in and inheritance behavior when these parameters are omitted. +The secure App defaults `record_session` and `browser_telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. The programmatic `manage_auth_connections` create, update, and login actions pass browser telemetry through the API’s current nested `browser.telemetry` configuration while preserving defaults and inheritance when the MCP parameter is omitted. ### Set up browser profiles for authentication diff --git a/bun.lock b/bun.lock index 1f07b3c8..57c052b8 100644 --- a/bun.lock +++ b/bun.lock @@ -9,8 +9,8 @@ "@clerk/nextjs": "^7.7.1", "@clerk/themes": "^2.4.19", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/managed-auth-react": "0.4.1", - "@onkernel/sdk": "^0.90.0", + "@onkernel/managed-auth-react": "0.5.1", + "@onkernel/sdk": "^0.97.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -147,9 +147,9 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w=="], - "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.4.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-8p+pMljBRQMKLiBFWbyJQ2ohYflsomWYGgQtlF2sbb4b2w/z+CBsnxUiBs1q23h/W1OtHsbw/jKGX51ZCjNYzA=="], + "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.5.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-tRnx91QTqlop2otlXyOxmI+jHodAMCW0dytWyk4hvu26cjACqH0387S0nP4IfgBVkZW6rwdNoBYKOheQfIxMgA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.90.0", "", {}, "sha512-KvOusR4JVrb1ifT1sqjUlZkoj5EFcxVu+nKrBWLYWGyZgJSu626f7uJEphigCrDVUYpuzimOGNsvXS9ILtjx6Q=="], + "@onkernel/sdk": ["@onkernel/sdk@0.97.0", "", {}, "sha512-Mi9//b9l81tl+eBPV3TpYwU9noriW7c0lFdJ8z5dj1SOQwdPB3nwdQDIfpewxzMeOBNng/qA2jkLkhopXWLbDw=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], diff --git a/package.json b/package.json index 73061f56..a649ff76 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "@clerk/nextjs": "^7.7.1", "@clerk/themes": "^2.4.19", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/managed-auth-react": "0.4.1", - "@onkernel/sdk": "^0.90.0", + "@onkernel/managed-auth-react": "0.5.1", + "@onkernel/sdk": "^0.97.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index e1bf9f40..c2cf1b50 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +1,2 @@ // Generated by scripts/build-managed-auth-app.mjs. Do not edit. -export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-terminal.test.ts b/src/lib/mcp/apps/managed-auth-terminal.test.ts index 9b3b82e0..e92a2308 100644 --- a/src/lib/mcp/apps/managed-auth-terminal.test.ts +++ b/src/lib/mcp/apps/managed-auth-terminal.test.ts @@ -4,7 +4,7 @@ import { isTerminalFailure, } from "@/lib/mcp/apps/managed-auth-terminal"; -// Mirrors the hosted UI (@onkernel/managed-auth-react 0.4.1): FAILED/CANCELED +// Mirrors the hosted UI (@onkernel/managed-auth-react): FAILED/CANCELED // render StepError with the actual safe error code so ERROR_DISPLAY copy // survives; EXPIRED renders StepExpired. describe("managed-auth terminal view", () => { diff --git a/src/lib/mcp/tools/apps.test.ts b/src/lib/mcp/tools/apps.test.ts index df0d57e6..a4a96cb4 100644 --- a/src/lib/mcp/tools/apps.test.ts +++ b/src/lib/mcp/tools/apps.test.ts @@ -108,6 +108,7 @@ describe("manage_apps invocation contract", () => { created_at: "2026-08-08T00:00:00Z", headless: false, stealth: true, + memory: "2GiB", timeout_seconds: 600, region: "us-east", }, diff --git a/src/lib/mcp/tools/auth-connections.test-fixtures.ts b/src/lib/mcp/tools/auth-connections.test-fixtures.ts index c520f548..ede19bd6 100644 --- a/src/lib/mcp/tools/auth-connections.test-fixtures.ts +++ b/src/lib/mcp/tools/auth-connections.test-fixtures.ts @@ -25,6 +25,22 @@ export function connection(overrides: Partial = {}): ManagedAuth { hosted_url: "https://managed-auth.onkernel.com/login/conn_1?code=secret", live_view_url: "https://live.example/secret", browser_session_id: "browser_secret", + interaction_id: "mai_secret", + fields: [ + { + id: "field_password", + ref: "password", + type: "password", + reason: "missing", + }, + ], + choices: [ + { + id: "work-account", + label: "Work account", + type: "account", + }, + ], discovered_fields: [ { label: "Password", @@ -129,6 +145,9 @@ const forbiddenKeys = [ "jwt", "authorization", "credential", + "interaction_id", + "fields", + "choices", "discovered_fields", "mfa_options", "pending_sso_buttons", diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index cc2f9614..58808d73 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -15,37 +15,65 @@ import { describe("manage_auth_connections programmatic surface", () => { afterEach(resetKernelClientFactory); - test("keeps every legacy action and adds wait", () => { + test("exposes every managed-auth action and current input", () => { const { schema } = captureHandler(); expect(schema?.action.safeParse("list").success).toBe(true); expect(schema?.action.safeParse("get").success).toBe(true); expect(schema?.action.safeParse("create").success).toBe(true); + expect(schema?.action.safeParse("update").success).toBe(true); expect(schema?.action.safeParse("delete").success).toBe(true); expect(schema?.action.safeParse("login").success).toBe(true); expect(schema?.action.safeParse("submit").success).toBe(true); + expect(schema?.action.safeParse("timeline").success).toBe(true); expect(schema?.action.safeParse("wait").success).toBe(true); + expect(schema?.interaction_id).toBeDefined(); + expect(schema?.field_values).toBeDefined(); + expect(schema?.selected_choice_id).toBeDefined(); expect(schema?.fields).toBeDefined(); expect(schema?.mfa_option_id).toBeDefined(); expect(schema?.sso_button_selector).toBeDefined(); expect(schema?.allowed_domains).toBeDefined(); expect(schema?.login_url).toBeDefined(); - expect(schema?.credential_name).toBeDefined(); - expect(schema?.credential_provider).toBeDefined(); - expect(schema?.credential_path).toBeDefined(); + expect(schema?.credential_name.description).toContain("create, update"); + expect(schema?.credential_provider.description).toContain("create, update"); + expect(schema?.credential_path.description).toContain("create, update"); + expect(schema?.credential_auto.description).toContain("create, update"); + expect(schema?.health_checks).toBeDefined(); + expect(schema?.auto_reauth).toBeDefined(); + expect(schema?.browser_stealth).toBeDefined(); + expect(schema?.query).toBeDefined(); + expect(schema?.timeline_type).toBeDefined(); expect(schema?.record_session).toBeDefined(); expect(schema?.flow_checkpoint).toBeDefined(); expect(schema?.proxy_id.safeParse("").success).toBe(false); expect(schema?.proxy_name.safeParse("").success).toBe(false); + expect(schema?.proxy_mode.safeParse("direct").success).toBe(true); expect(schema?.browser_telemetry.safeParse({ enabled: true }).success).toBe( true, ); + expect( + schema?.browser_telemetry.safeParse({ + enabled: true, + browser: { + platform: { enabled: true }, + control: { + cdp: { excluded_methods: ["Input.dispatchMouseEvent"] }, + }, + }, + export: { + otlp: { destination: { name: "managed-auth-telemetry" } }, + }, + }).success, + ).toBe(true); expect( schema?.browser_telemetry.safeParse({ enabled: false, browser: { network: { enabled: true } }, }).success, ).toBe(false); - expect(schema?.sign_in_option_id).toBeUndefined(); + expect(schema?.selected_choice_id.safeParse("").success).toBe(false); + expect(schema?.sign_in_option_id.safeParse("").success).toBe(false); + expect(schema?.sso_provider).toBeDefined(); }); test("publishes browser telemetry categories without JSON Schema references", async () => { @@ -137,7 +165,7 @@ describe("manage_auth_connections programmatic surface", () => { extra, ); expect(createBody).not.toHaveProperty("record_session"); - expect(createBody).not.toHaveProperty("browser_telemetry"); + expect(createBody).not.toHaveProperty("browser"); await handler({ action: "login", id: "conn_1" }, extra); expect(loginBody).toBeUndefined(); @@ -157,9 +185,11 @@ describe("manage_auth_connections programmatic surface", () => { ); expect(createBody).toMatchObject({ record_session: true, - browser_telemetry: { - enabled: true, - browser: { network: { enabled: true } }, + browser: { + telemetry: { + enabled: true, + browser: { network: { enabled: true } }, + }, }, }); @@ -174,13 +204,236 @@ describe("manage_auth_connections programmatic surface", () => { ); expect(loginBody).toEqual({ record_session: false, - browser_telemetry: { enabled: false }, + browser: { telemetry: { enabled: false } }, }); } finally { kernelClientMock.factory = () => unusedKernelClient; } }); + test("forwards current connection settings on update", async () => { + const { handler } = captureHandler(); + let updateBody: unknown; + kernelClientMock.factory = () => ({ + auth: { + connections: { + update: async (_id: string, body: unknown) => { + updateBody = body; + return connection(); + }, + }, + }, + }); + try { + await handler( + { + action: "update", + id: "conn_1", + allowed_domains: [], + credential_provider: "team-1password", + credential_path: "Vault/Login", + login_url: "", + health_check_interval: 1200, + health_checks: false, + auto_reauth: false, + save_credentials: false, + record_session: true, + browser_stealth: false, + proxy_mode: "direct", + browser_telemetry: { + enabled: true, + browser: { platform: { enabled: true } }, + export: { + otlp: { destination: { name: "managed-auth-telemetry" } }, + }, + }, + }, + { authInfo: { token: "test-token" } }, + ); + expect(updateBody).toEqual({ + allowed_domains: [], + credential: { + provider: "team-1password", + path: "Vault/Login", + }, + login_url: "", + health_check_interval: 1200, + health_checks: false, + auto_reauth: false, + save_credentials: false, + record_session: true, + browser: { + stealth: false, + proxy: { mode: "direct" }, + telemetry: { + enabled: true, + browser: { platform: { enabled: true } }, + export: { + otlp: { destination: { name: "managed-auth-telemetry" } }, + }, + }, + }, + }); + } finally { + kernelClientMock.factory = () => unusedKernelClient; + } + }); + + test("submits canonical fields and choices with interaction identity", async () => { + const { handler } = captureHandler(); + const submitBodies: unknown[] = []; + kernelClientMock.factory = () => ({ + auth: { + connections: { + submit: async (_id: string, body: unknown) => { + submitBodies.push(body); + return { accepted: true }; + }, + }, + }, + }); + try { + const extra = { authInfo: { token: "test-token" } }; + await handler( + { + action: "submit", + id: "conn_1", + interaction_id: "mai_1", + field_values: { field_email: "user@example.com" }, + }, + extra, + ); + await handler( + { + action: "submit", + id: "conn_1", + interaction_id: "mai_2", + selected_choice_id: "work-account", + }, + extra, + ); + await handler( + { + action: "submit", + id: "conn_1", + interaction_id: "mai_3", + field_values: { field_email: "user@example.com" }, + selected_choice_id: "work-account", + }, + extra, + ); + expect(submitBodies).toEqual([ + { + interaction_id: "mai_1", + field_values: { field_email: "user@example.com" }, + }, + { + interaction_id: "mai_2", + selected_choice_id: "work-account", + }, + { + interaction_id: "mai_3", + field_values: { field_email: "user@example.com" }, + selected_choice_id: "work-account", + }, + ]); + + const missingInteraction = await handler( + { + action: "submit", + id: "conn_1", + selected_choice_id: "work-account", + }, + extra, + ); + expect(missingInteraction.isError).toBe(true); + expect(missingInteraction.content[0].text).toContain( + "interaction_id is required", + ); + } finally { + kernelClientMock.factory = () => unusedKernelClient; + } + }); + + test.each([ + [ + { interaction_id: "mai_1", fields: { email: "user@example.com" } }, + "interaction_id requires field_values or selected_choice_id", + ], + [ + { + interaction_id: "mai_1", + field_values: { field_email: "user@example.com" }, + fields: { email: "user@example.com" }, + }, + "field_values and selected_choice_id cannot be combined with legacy input fields", + ], + [ + { + interaction_id: "mai_1", + selected_choice_id: "work-account", + sso_button_selector: "xpath=//button", + }, + "field_values and selected_choice_id cannot be combined with legacy input fields", + ], + [ + { + interaction_id: "mai_1", + selected_choice_id: "work-account", + sso_provider: "google", + }, + "field_values and selected_choice_id cannot be combined with legacy input fields", + ], + [ + { + interaction_id: "mai_1", + selected_choice_id: "work-account", + mfa_option_id: "sms", + }, + "field_values and selected_choice_id cannot be combined with legacy input fields", + ], + [ + { + interaction_id: "mai_1", + selected_choice_id: "work-account", + sign_in_option_id: "personal-account", + }, + "field_values and selected_choice_id cannot be combined with legacy input fields", + ], + [ + { sso_button_selector: "xpath=//button", sso_provider: "google" }, + "sso_button_selector cannot be combined with other input types", + ], + [ + { sso_button_selector: "xpath=//button", mfa_option_id: "sms" }, + "sso_button_selector cannot be combined with other input types", + ], + [ + { sso_provider: "google", mfa_option_id: "sms" }, + "sso_provider cannot be combined with mfa_option_id or sign_in_option_id", + ], + [ + { sign_in_option_id: "work-account", fields: { email: "value" } }, + "sign_in_option_id cannot be combined with fields or mfa_option_id", + ], + [ + { sign_in_option_id: "work-account", mfa_option_id: "sms" }, + "sign_in_option_id cannot be combined with fields or mfa_option_id", + ], + ])( + "rejects incompatible submit shapes", + async (submitParams, expectedError) => { + const { handler } = captureHandler(); + const result = await handler( + { action: "submit", id: "conn_1", ...submitParams }, + { authInfo: { token: "test-token" } }, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(expectedError); + }, + ); + test("legacy actions keep their established raw response shapes", async () => { const { handler } = captureHandler(); const calls = { create: 0, login: 0, submit: 0, delete: 0, retrieve: 0 }; @@ -236,8 +489,10 @@ describe("manage_auth_connections programmatic surface", () => { const got = await handler({ action: "get", id: "conn_1" }, extra); const gotJson = JSON.parse(got.content[0].text); expect(gotJson.status).toBe("AUTHENTICATED"); + expect(gotJson.interaction_id).toBe("mai_secret"); + expect(gotJson.fields).toHaveLength(1); + expect(gotJson.choices).toHaveLength(1); expect(gotJson.discovered_fields).toHaveLength(1); - expect(gotJson.interaction).toBeUndefined(); // login returns the raw hosted flow response. const login = await handler({ action: "login", id: "conn_1" }, extra); @@ -279,6 +534,7 @@ describe("manage_auth_connections programmatic surface", () => { connections: { list: async (params: Record) => { expect(params.domain).toBe("example.com"); + expect(params.query).toBe("work"); return { getPaginatedItems: () => [connection()], has_more: false, @@ -290,7 +546,11 @@ describe("manage_auth_connections programmatic surface", () => { }); try { const result = await handler( - { action: "list", domain_filter: "example.com" }, + { + action: "list", + domain_filter: "example.com", + query: "work", + }, { authInfo: { token: "test-token" } }, ); const json = JSON.parse(result.content[0].text); @@ -306,6 +566,53 @@ describe("manage_auth_connections programmatic surface", () => { } }); + test("timeline returns the paginated event history", async () => { + const { handler } = captureHandler(); + kernelClientMock.factory = () => ({ + auth: { + connections: { + timeline: async (id: string, params: Record) => { + expect(id).toBe("conn_1"); + expect(params).toEqual({ + type: "health_check", + limit: 10, + offset: 20, + }); + return { + getPaginatedItems: () => [ + { + id: "check_1", + type: "health_check", + status: "AUTHENTICATED", + timestamp: "2026-08-31T00:00:00Z", + }, + ], + has_more: false, + next_offset: null, + }; + }, + }, + }, + }); + try { + const result = await handler( + { + action: "timeline", + id: "conn_1", + timeline_type: "health_check", + limit: 10, + offset: 20, + }, + { authInfo: { token: "test-token" } }, + ); + const json = JSON.parse(result.content[0].text); + expect(json.items).toHaveLength(1); + expect(json.items[0].id).toBe("check_1"); + } finally { + kernelClientMock.factory = () => unusedKernelClient; + } + }); + test("legacy validation errors are preserved", async () => { const { handler } = captureHandler(); const extra = { authInfo: { token: "test-token" } }; @@ -331,9 +638,21 @@ describe("manage_auth_connections programmatic surface", () => { "require credential_provider", ], [{ action: "get" }, "id is required for get"], + [{ action: "update" }, "id is required for update"], + [{ action: "update", id: "conn_1" }, "at least one connection setting"], [{ action: "delete" }, "id is required for delete"], [{ action: "login" }, "id is required for login"], + [{ action: "timeline" }, "id is required for timeline"], [{ action: "submit", id: "conn_1" }, "submit requires at least one of"], + [ + { + action: "login", + id: "conn_1", + proxy_id: "proxy_1", + proxy_mode: "direct", + }, + "exactly one of proxy_id, proxy_name, or proxy_mode", + ], ]; for (const [params, message] of cases) { const result = await handler(params, extra); diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 192055a0..9230d7a6 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -37,16 +37,26 @@ export function registerAuthConnectionTools(server: McpServer) { // manage_auth_connections -- Manage Kernel managed auth connections server.tool( "manage_auth_connections", - 'Manage reusable authenticated profiles for third-party websites. Before a browser task that needs a user account, call "list" with the exact domain_filter and inspect every page. If one relevant connection is AUTHENTICATED, create the browser with its profile_name. If multiple relevant accounts exist, ask the user which one to use. If authentication is needed and open_auth_login is available, prefer that secure App so credentials and MFA never enter chat: a direct user request to log in is already consent; if login is only discovered incidentally, ask first. For a new App login, choose a concise stable profile name derived from the service unless the user specified one. The programmatic actions remain available for every client: "create" a connection, "login" to start a hosted flow, "submit" fields/MFA/SSO, "get" status, "delete", or "wait" for completion. After authentication, resume the original task with manage_browsers using the verified profile_name.', + 'Manage reusable authenticated profiles for third-party websites. Before a browser task that needs a user account, call "list" with the exact domain_filter and inspect every page. If one relevant connection is AUTHENTICATED, create the browser with its profile_name. If multiple relevant accounts exist, ask the user which one to use. If authentication is needed and open_auth_login is available, prefer that secure App so credentials and MFA never enter chat: a direct user request to log in is already consent; if login is only discovered incidentally, ask first. For a new App login, choose a concise stable profile name derived from the service unless the user specified one. The programmatic actions remain available for every client: "create" or "update" a connection, "login" to start a hosted flow, "submit" fields or choices, "get" status, inspect the "timeline", "delete", or "wait" for completion. Prefer interaction_id with canonical field_values or selected_choice_id when the connection returns fields or choices. After authentication, resume the original task with manage_browsers using the verified profile_name.', { ...projectSelectionInputSchema(), action: z - .enum(["create", "list", "get", "delete", "login", "submit", "wait"]) + .enum([ + "create", + "list", + "get", + "update", + "delete", + "login", + "submit", + "timeline", + "wait", + ]) .describe("Operation to perform."), id: z .string() .describe( - "Auth connection ID. Required for get, delete, login, submit.", + "Auth connection ID. Required for get, update, delete, login, submit, and timeline.", ) .optional(), domain: z @@ -62,79 +72,133 @@ export function registerAuthConnectionTools(server: McpServer) { allowed_domains: z .array(z.string()) .describe( - "(create) Additional domains valid for this auth flow. Common SSO providers (Google, Microsoft, Okta, Auth0, Apple, GitHub, Facebook, LinkedIn, Cognito, OneLogin, Ping) are allowed by default.", + "(create, update) Additional hostname roots valid for credential entry. Exact hostnames and their subdomains are allowed; leading www. and *. are normalized away. An omitted or empty list leaves credential entry unrestricted.", ) .optional(), credential_name: z .string() .describe( - "(create) Name of a pre-stored Kernel credential to use for automatic login.", + "(create, update) Name of a pre-stored Kernel credential to use for automatic login.", ) .optional(), credential_provider: z .string() .describe( - "(create) External credential provider name (e.g. '1password'). Use with credential_path or credential_auto.", + "(create, update) External credential provider name (e.g. '1password'). Use with credential_path or credential_auto.", ) .optional(), credential_path: z .string() .describe( - "(create) Provider-specific item path (e.g. 'VaultName/ItemName').", + "(create, update) Provider-specific item path (e.g. 'VaultName/ItemName').", ) .optional(), credential_auto: z .boolean() .describe( - "(create) If true, the provider auto-looks up credentials by domain.", + "(create, update) If true, the provider auto-looks up credentials by domain.", ) .optional(), login_url: z .string() .describe( - "(create) Optional explicit login page URL to skip discovery.", + "(create, update) Optional explicit login page URL to skip discovery. On update, use an empty string to clear it.", ) .optional(), health_check_interval: z .number() .int() + .min(300) + .max(86400) .describe( - "(create) Seconds between automatic re-auth checks. Plan-dependent minimum, max 86400.", + "(create, update) Seconds between automatic health checks. Plan-dependent minimum, max 86400.", + ) + .optional(), + health_checks: z + .boolean() + .describe( + "(create, update) Enable scheduled authentication health checks. Defaults to true on create.", + ) + .optional(), + auto_reauth: z + .boolean() + .describe( + "(create, update) Permit automatic re-authentication after a scheduled health check detects an expired session. Defaults to true on create and has no effect when health_checks is false.", ) .optional(), save_credentials: z .boolean() .describe( - "(create) Save credentials after each successful login. Default true.", + "(create, update) Save credentials after each successful login. Defaults to true on create.", ) .optional(), record_session: z .boolean() .describe( - "(create) Set the connection default for recording replay video of future login, reauth, and health-check browser sessions. (login) Override that default for this login only. Omitted preserves the API default/inheritance behavior.", + "(create, update) Set the connection default for recording replay video of future login, reauth, and health-check browser sessions. (login) Override that default for this login only. Omitted preserves the API default or inherited value.", ) .optional(), browser_telemetry: managedAuthBrowserTelemetrySchema .describe( - "(create) Set the connection default for browser telemetry. (login) Override it for this login only. Use { enabled: true } for the default operational categories (control, connection, system, captcha); add browser category flags to opt into console, network, page, interaction, or screenshot capture. Omitted preserves API default/inheritance behavior.", + "(create, update) Set the connection default for browser telemetry. (login) Override it for this login only. Use { enabled: true } for the default operational categories (control, connection, system, captcha); browser category settings can opt into console, network, page, interaction, screenshot, or platform capture, tune control CDP exclusions, and configure OTLP export. Omitted preserves the API default or inherited value.", + ) + .optional(), + browser_stealth: z + .boolean() + .describe( + "(create, update, login) Whether managed-auth browser sessions use stealth mode. Defaults to true on create; omitted on update or login preserves or inherits the connection setting.", ) .optional(), proxy_id: z .string() .min(1) - .describe("(create, login) Proxy ID to route the auth flow through.") + .describe( + "(create, update, login) Proxy ID to route managed-auth browser sessions through.", + ) .optional(), proxy_name: z .string() .min(1) - .describe("(create, login) Proxy name to route the auth flow through.") + .describe( + "(create, update, login) Proxy name to route managed-auth browser sessions through.", + ) + .optional(), + proxy_mode: z + .enum(["direct", "default"]) + .describe( + "(create, update, login) Proxy mode. direct disables proxy egress; default restores the stealth-derived default. Cannot be combined with proxy_id or proxy_name.", + ) .optional(), domain_filter: z.string().describe("(list) Filter by domain.").optional(), + query: z + .string() + .describe("(list) Search by connection ID, domain, or profile name.") + .optional(), ...paginationParams, + interaction_id: z + .string() + .min(1) + .describe( + "(submit) Opaque interaction ID returned with canonical fields and choices. Required with field_values or selected_choice_id.", + ) + .optional(), + field_values: z + .record(z.string(), z.string()) + .describe( + "(submit) Canonical map of field ID to value. Use with interaction_id when `get` returns fields.", + ) + .optional(), + selected_choice_id: z + .string() + .min(1) + .describe( + "(submit) Canonical choice ID. Use with interaction_id when `get` returns choices.", + ) + .optional(), fields: z .record(z.string(), z.string()) .describe( - "(submit) Map of field name to value (e.g. { mfa_code: '123456' }). Look at discovered_fields from `get` to know what to provide.", + "(submit, legacy) Map of discovered field name to value. Prefer interaction_id and field_values when canonical fields are present.", ) .optional(), mfa_option_id: z @@ -143,12 +207,29 @@ export function registerAuthConnectionTools(server: McpServer) { "(submit) ID of the MFA option to use, from mfa_options on the connection.", ) .optional(), + sign_in_option_id: z + .string() + .min(1) + .describe( + "(submit, legacy) Sign-in option ID from sign_in_options. Prefer selected_choice_id when canonical choices are present.", + ) + .optional(), sso_button_selector: z .string() .describe( - "(submit) XPath of an SSO button to click instead of submitting fields.", + "(submit, legacy) XPath of an ODA SSO button. Cannot be combined with sso_provider.", ) .optional(), + sso_provider: z + .string() + .describe( + "(submit, legacy) Provider from pending_sso_buttons for a CUA SSO choice. Cannot be combined with sso_button_selector.", + ) + .optional(), + timeline_type: z + .enum(["login", "reauth", "health_check"]) + .describe("(timeline) Filter events by type.") + .optional(), wait_seconds: z .number() .int() @@ -182,15 +263,84 @@ export function registerAuthConnectionTools(server: McpServer) { projectForOperation(extra.authInfo, params), ); + const proxySelectors = [ + params.proxy_id, + params.proxy_name, + params.proxy_mode, + ].filter((value) => value !== undefined); const buildProxy = () => - params.proxy_id || params.proxy_name + proxySelectors.length === 1 ? { ...(params.proxy_id && { id: params.proxy_id }), ...(params.proxy_name && { name: params.proxy_name }), + ...(params.proxy_mode && { mode: params.proxy_mode }), } : undefined; + const buildBrowser = () => { + const proxy = buildProxy(); + return params.browser_stealth !== undefined || + params.browser_telemetry !== undefined || + proxy + ? { + ...(params.browser_stealth !== undefined && { + stealth: params.browser_stealth, + }), + ...(params.browser_telemetry !== undefined && { + telemetry: params.browser_telemetry, + }), + ...(proxy && { proxy }), + } + : undefined; + }; + const buildCredential = () => { + const hasName = !!params.credential_name; + const hasProvider = !!params.credential_provider; + const hasPath = !!params.credential_path; + const autoTrue = params.credential_auto === true; + if (hasName && (hasProvider || hasPath || autoTrue)) { + return { + error: + "credential_name cannot be combined with credential_provider, credential_path, or credential_auto. Use one of: { credential_name } for Kernel credentials, { credential_provider, credential_path } for an external provider item, or { credential_provider, credential_auto: true } for provider domain lookup.", + }; + } + if ((hasPath || autoTrue) && !hasProvider) { + return { + error: + "credential_path and credential_auto require credential_provider.", + }; + } + if (hasPath && autoTrue) { + return { + error: + "credential_path and credential_auto: true are alternatives — provide exactly one.", + }; + } + if (hasProvider && !hasPath && !autoTrue) { + return { + error: + "credential_provider requires either credential_path or credential_auto: true.", + }; + } + return { + credential: + hasName || hasProvider + ? { + ...(hasName && { name: params.credential_name }), + ...(hasProvider && { provider: params.credential_provider }), + ...(hasPath && { path: params.credential_path }), + ...(autoTrue && { auto: true }), + } + : undefined, + }; + }; try { + if (proxySelectors.length > 1) { + return errorResponse( + "Error: provide exactly one of proxy_id, proxy_name, or proxy_mode.", + ); + } + switch (params.action) { case "create": { if (!params.domain || !params.profile_name) { @@ -198,46 +348,13 @@ export function registerAuthConnectionTools(server: McpServer) { "Error: domain and profile_name are required for create.", ); } - const hasName = !!params.credential_name; - const hasProvider = !!params.credential_provider; - const hasPath = !!params.credential_path; - const autoTrue = params.credential_auto === true; - if (hasName && (hasProvider || hasPath || autoTrue)) { - return errorResponse( - "Error: credential_name cannot be combined with credential_provider, credential_path, or credential_auto. Use one of: { credential_name } for Kernel credentials, { credential_provider, credential_path } for an external provider item, or { credential_provider, credential_auto: true } for provider domain lookup.", - ); - } - if ((hasPath || autoTrue) && !hasProvider) { - return errorResponse( - "Error: credential_path and credential_auto require credential_provider.", - ); - } - if (hasPath && autoTrue) { - return errorResponse( - "Error: credential_path and credential_auto: true are alternatives — provide exactly one.", - ); - } - if (hasProvider && !hasPath && !autoTrue) { - return errorResponse( - "Error: credential_provider requires either credential_path or credential_auto: true.", - ); - } - const credential = - hasName || hasProvider - ? { - ...(hasName && { name: params.credential_name }), - ...(hasProvider && { - provider: params.credential_provider, - }), - ...(hasPath && { path: params.credential_path }), - ...(autoTrue && { auto: true }), - } - : undefined; - const proxy = buildProxy(); + const { credential, error } = buildCredential(); + if (error) return errorResponse(`Error: ${error}`); + const browser = buildBrowser(); const connection = await client.auth.connections.create({ domain: params.domain, profile_name: params.profile_name, - ...(params.allowed_domains && { + ...(params.allowed_domains !== undefined && { allowed_domains: params.allowed_domains, }), ...(credential && { credential }), @@ -245,16 +362,19 @@ export function registerAuthConnectionTools(server: McpServer) { ...(params.health_check_interval !== undefined && { health_check_interval: params.health_check_interval, }), + ...(params.health_checks !== undefined && { + health_checks: params.health_checks, + }), + ...(params.auto_reauth !== undefined && { + auto_reauth: params.auto_reauth, + }), ...(params.save_credentials !== undefined && { save_credentials: params.save_credentials, }), ...(params.record_session !== undefined && { record_session: params.record_session, }), - ...(params.browser_telemetry !== undefined && { - browser_telemetry: params.browser_telemetry, - }), - ...(proxy && { proxy }), + ...(browser && { browser }), }); if (!connection) return errorResponse("Failed to create auth connection"); @@ -264,6 +384,7 @@ export function registerAuthConnectionTools(server: McpServer) { const page = await client.auth.connections.list({ ...(params.profile_name && { profile_name: params.profile_name }), ...(params.domain_filter && { domain: params.domain_filter }), + ...(params.query && { query: params.query }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); @@ -277,6 +398,54 @@ export function registerAuthConnectionTools(server: McpServer) { ); return jsonResponse(connection); } + case "update": { + if (!params.id) + return errorResponse("Error: id is required for update."); + const { credential, error } = buildCredential(); + if (error) return errorResponse(`Error: ${error}`); + const browser = buildBrowser(); + const hasUpdate = + params.allowed_domains !== undefined || + credential !== undefined || + params.login_url !== undefined || + params.health_check_interval !== undefined || + params.health_checks !== undefined || + params.auto_reauth !== undefined || + params.save_credentials !== undefined || + params.record_session !== undefined || + browser !== undefined; + if (!hasUpdate) { + return errorResponse( + "Error: update requires at least one connection setting.", + ); + } + const connection = await client.auth.connections.update(params.id, { + ...(params.allowed_domains !== undefined && { + allowed_domains: params.allowed_domains, + }), + ...(credential && { credential }), + ...(params.login_url !== undefined && { + login_url: params.login_url, + }), + ...(params.health_check_interval !== undefined && { + health_check_interval: params.health_check_interval, + }), + ...(params.health_checks !== undefined && { + health_checks: params.health_checks, + }), + ...(params.auto_reauth !== undefined && { + auto_reauth: params.auto_reauth, + }), + ...(params.save_credentials !== undefined && { + save_credentials: params.save_credentials, + }), + ...(params.record_session !== undefined && { + record_session: params.record_session, + }), + ...(browser && { browser }), + }); + return jsonResponse(connection); + } case "delete": { if (!params.id) return errorResponse("Error: id is required for delete."); @@ -286,22 +455,17 @@ export function registerAuthConnectionTools(server: McpServer) { case "login": { if (!params.id) return errorResponse("Error: id is required for login."); - const proxy = buildProxy(); + const browser = buildBrowser(); const hasOverrides = - !!proxy || - params.record_session !== undefined || - params.browser_telemetry !== undefined; + browser !== undefined || params.record_session !== undefined; const response = await client.auth.connections.login( params.id, hasOverrides ? { - ...(proxy && { proxy }), + ...(browser && { browser }), ...(params.record_session !== undefined && { record_session: params.record_session, }), - ...(params.browser_telemetry !== undefined && { - browser_telemetry: params.browser_telemetry, - }), } : undefined, ); @@ -310,27 +474,99 @@ export function registerAuthConnectionTools(server: McpServer) { case "submit": { if (!params.id) return errorResponse("Error: id is required for submit."); - const hasFields = + const hasCanonicalFields = + !!params.field_values && + Object.keys(params.field_values).length > 0; + const hasLegacyFields = !!params.fields && Object.keys(params.fields).length > 0; + const hasCanonicalSubmission = + hasCanonicalFields || !!params.selected_choice_id; + const hasLegacySubmission = + hasLegacyFields || + !!params.mfa_option_id || + !!params.sign_in_option_id || + !!params.sso_button_selector || + !!params.sso_provider; + if (!hasCanonicalSubmission && !hasLegacySubmission) { + return errorResponse( + "Error: submit requires at least one of field_values, selected_choice_id, fields, mfa_option_id, sign_in_option_id, sso_button_selector, or sso_provider.", + ); + } + if (params.interaction_id && !hasCanonicalSubmission) { + return errorResponse( + "Error: interaction_id requires field_values or selected_choice_id.", + ); + } + if (hasCanonicalSubmission && hasLegacySubmission) { + return errorResponse( + "Error: field_values and selected_choice_id cannot be combined with legacy input fields.", + ); + } + if (hasCanonicalSubmission && !params.interaction_id) { + return errorResponse( + "Error: interaction_id is required with field_values or selected_choice_id.", + ); + } if ( - !hasFields && - !params.mfa_option_id && - !params.sso_button_selector - ) + params.sso_button_selector && + (params.sso_provider || + params.mfa_option_id || + params.sign_in_option_id) + ) { return errorResponse( - "Error: submit requires at least one of fields (non-empty), mfa_option_id, or sso_button_selector.", + "Error: sso_button_selector cannot be combined with other input types.", ); + } + if ( + params.sso_provider && + (params.mfa_option_id || params.sign_in_option_id) + ) { + return errorResponse( + "Error: sso_provider cannot be combined with mfa_option_id or sign_in_option_id.", + ); + } + if ( + params.sign_in_option_id && + (hasLegacyFields || params.mfa_option_id) + ) { + return errorResponse( + "Error: sign_in_option_id cannot be combined with fields or mfa_option_id.", + ); + } const response = await client.auth.connections.submit(params.id, { - ...(hasFields && { fields: params.fields }), + ...(params.interaction_id && { + interaction_id: params.interaction_id, + }), + ...(hasCanonicalFields && { field_values: params.field_values }), + ...(params.selected_choice_id && { + selected_choice_id: params.selected_choice_id, + }), + ...(hasLegacyFields && { fields: params.fields }), ...(params.mfa_option_id && { mfa_option_id: params.mfa_option_id, }), + ...(params.sign_in_option_id && { + sign_in_option_id: params.sign_in_option_id, + }), ...(params.sso_button_selector && { sso_button_selector: params.sso_button_selector, }), + ...(params.sso_provider && { + sso_provider: params.sso_provider, + }), }); return jsonResponse(response); } + case "timeline": { + if (!params.id) + return errorResponse("Error: id is required for timeline."); + const page = await client.auth.connections.timeline(params.id, { + ...(params.timeline_type && { type: params.timeline_type }), + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + }); + return paginatedJsonResponse(page); + } case "wait": { if (!params.id && (!params.domain_filter || !params.profile_name)) { return errorResponse( diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index a5f99bc6..51ce4acf 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -509,6 +509,9 @@ describe("managed-auth MCP App registration", () => { expect(MANAGED_AUTH_APP_HTML).not.toContain("kernel-app-loading"); expect(MANAGED_AUTH_APP_HTML).toContain("MutationObserver"); expect(MANAGED_AUTH_APP_HTML).toContain("preventScroll"); + expect(MANAGED_AUTH_APP_HTML).toContain("interaction_id"); + expect(MANAGED_AUTH_APP_HTML).toContain("field_values"); + expect(MANAGED_AUTH_APP_HTML).toContain("selected_choice_id"); expect(MANAGED_AUTH_APP_HTML).toContain( 'input:not([type="hidden"]):not([type="submit"])', ); diff --git a/src/lib/mcp/tools/managed-auth-start.test.ts b/src/lib/mcp/tools/managed-auth-start.test.ts index 55f50675..d7b59011 100644 --- a/src/lib/mcp/tools/managed-auth-start.test.ts +++ b/src/lib/mcp/tools/managed-auth-start.test.ts @@ -58,11 +58,11 @@ describe("managed-auth start/resume state machine", () => { }); expect(calls.createParams).toMatchObject({ record_session: true, - browser_telemetry: { enabled: true }, + browser: { telemetry: { enabled: true } }, }); expect(calls.loginParams).toEqual({ record_session: true, - browser_telemetry: { enabled: true }, + browser: { telemetry: { enabled: true } }, }); }); @@ -78,11 +78,11 @@ describe("managed-auth start/resume state machine", () => { }); expect(calls.createParams).toMatchObject({ record_session: false, - browser_telemetry: { enabled: false }, + browser: { telemetry: { enabled: false } }, }); expect(calls.loginParams).toEqual({ record_session: false, - browser_telemetry: { enabled: false }, + browser: { telemetry: { enabled: false } }, }); }); diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 1f654fb2..5ac500d4 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -446,6 +446,13 @@ export async function beginAuthLogin( const recordSession = input.record_session ?? true; const browserTelemetry = input.browser_telemetry ?? { enabled: true }; + const proxy = + input.proxy_id || input.proxy_name + ? { + ...(input.proxy_id && { id: input.proxy_id }), + ...(input.proxy_name && { name: input.proxy_name }), + } + : undefined; let connection: ManagedAuth; if (input.mode === "new_login") { @@ -457,13 +464,10 @@ export async function beginAuthLogin( save_credentials: input.save_credentials, }), record_session: recordSession, - browser_telemetry: browserTelemetry, - ...((input.proxy_id || input.proxy_name) && { - proxy: { - ...(input.proxy_id && { id: input.proxy_id }), - ...(input.proxy_name && { name: input.proxy_name }), - }, - }), + browser: { + telemetry: browserTelemetry, + ...(proxy && { proxy }), + }, }); } catch (error) { const existingId = conflictExistingId(error); @@ -498,14 +502,6 @@ export async function beginAuthLogin( }; } - const proxy = - input.proxy_id || input.proxy_name - ? { - ...(input.proxy_id && { id: input.proxy_id }), - ...(input.proxy_name && { name: input.proxy_name }), - } - : undefined; - // Capture the latest server timeline identity before starting. A signed // "after" checkpoint then identifies the new event even if it reaches a // terminal state before either the App or model polls once. @@ -518,8 +514,10 @@ export async function beginAuthLogin( try { const login = await client.auth.connections.login(connection.id, { record_session: recordSession, - browser_telemetry: browserTelemetry, - ...(proxy && { proxy }), + browser: { + telemetry: browserTelemetry, + ...(proxy && { proxy }), + }, }); let current = withLoginState(connection, login); try { diff --git a/src/lib/mcp/tools/managed-auth-telemetry.ts b/src/lib/mcp/tools/managed-auth-telemetry.ts index 615bd4c4..efa6f4b8 100644 --- a/src/lib/mcp/tools/managed-auth-telemetry.ts +++ b/src/lib/mcp/tools/managed-auth-telemetry.ts @@ -6,22 +6,97 @@ function telemetryCategorySchema() { }); } +const cdpCommandMethodSchema = z.enum([ + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Input.imeSetComposition", + "Input.dispatchTouchEvent", + "Input.dispatchDragEvent", + "Input.cancelDragging", + "Input.emulateTouchFromMouseEvent", + "Input.synthesizePinchGesture", + "Input.synthesizeScrollGesture", + "Input.synthesizeTapGesture", + "DOM.setFileInputFiles", + "DOM.focus", + "DOM.scrollIntoViewIfNeeded", + "Page.bringToFront", + "Page.captureScreenshot", + "Page.captureSnapshot", + "Page.handleJavaScriptDialog", + "Page.navigate", + "Page.navigateToHistoryEntry", + "Page.reload", + "Page.printToPDF", + "Page.startScreencast", + "Page.stopScreencast", + "Page.stopLoading", + "Page.close", + "Page.setWebLifecycleState", + "Target.activateTarget", + "Target.closeTarget", + "Target.createTarget", + "Target.createBrowserContext", + "Target.disposeBrowserContext", + "Target.openDevTools", + "Browser.cancelDownload", + "Browser.close", + "Browser.setWindowBounds", + "Browser.setContentsSize", + "Autofill.trigger", +]); + +const telemetryControlSchema = z.object({ + enabled: z.boolean().optional(), + cdp: z + .object({ + excluded_methods: z.array(cdpCommandMethodSchema).optional(), + }) + .optional(), +}); + const telemetryCategoriesSchema = z.object({ captcha: telemetryCategorySchema().optional(), connection: telemetryCategorySchema().optional(), console: telemetryCategorySchema().optional(), - control: telemetryCategorySchema().optional(), + control: telemetryControlSchema.optional(), interaction: telemetryCategorySchema().optional(), network: telemetryCategorySchema().optional(), page: telemetryCategorySchema().optional(), + platform: telemetryCategorySchema().optional(), screenshot: telemetryCategorySchema().optional(), system: telemetryCategorySchema().optional(), }); +const telemetryDestinationSchema = z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1).optional(), + }) + .superRefine((destination, context) => { + if (!!destination.id === !!destination.name) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "telemetry destination requires exactly one of id or name.", + }); + } + }); + export const managedAuthBrowserTelemetrySchema = z .object({ enabled: z.boolean().optional(), browser: telemetryCategoriesSchema.optional(), + export: z + .object({ + otlp: z + .object({ + enabled: z.boolean().optional(), + destination: telemetryDestinationSchema.optional(), + }) + .optional(), + }) + .optional(), }) .superRefine((telemetry, context) => { if (