diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index e44e039a4..bbe6b4d55 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1635,6 +1635,7 @@ export async function createAdeRuntime(args: { ctoStateService, ctoMemoryService, linearCredentialService: headlessLinearServices.linearCredentialService, + linearOAuthService, getLinearIssueTracker: () => headlessLinearServices.linearIssueTracker, getExternalSessionsService: () => externalSessionsService, processService, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index a9264495d..f23480cf0 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -19,6 +19,7 @@ import type { } from "../../../../desktop/src/shared/types"; import { MOBILE_SYNC_COMPATIBILITY_CONTRACT_VERSION, + MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS, MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS, } from "../../../../desktop/src/shared/syncMobileCompatibility"; import { @@ -2993,6 +2994,92 @@ describe("PR snapshot invalidation push", () => { }); }); +describe("CTO-gated Linear sync commands", () => { + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + it("advertises them as optional, paired-controller-invocable capabilities (not forbidden at the gate)", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + discoveryEnabled: false, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + + try { + peer = await connectPeer( + await host.waitUntilListening(), + host.getBootstrapToken(), + "viewer-linear-controller", + ); + const hello = peer.envelopes.find((envelope) => envelope.type === "hello_ok"); + const actions = (hello?.payload as { + features?: { commandRouting?: { actions?: SyncRemoteCommandDescriptor[] } }; + })?.features?.commandRouting?.actions ?? []; + + expect(MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS).toEqual([ + "cto.startLinearMobileOAuth", + "cto.completeLinearMobileOAuth", + "cto.setLinearToken", + "cto.clearLinearToken", + ]); + expect(MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS).not.toEqual( + expect.arrayContaining([...MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS]), + ); + for (const action of MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS) { + expect(actions).toContainEqual({ + action, + scope: "project", + policy: { viewerAllowed: true }, + }); + + const requestId = `viewer-${action}`; + peer.ws.send(encodeSyncEnvelope({ + type: "command", + requestId, + projectId: "project-1", + payload: { + commandId: `command-${action}`, + projectId: "project-1", + action, + args: action === "cto.completeLinearMobileOAuth" + ? { sessionId: "session-1", code: "code-1", state: "state-1" } + : action === "cto.setLinearToken" + ? { token: "lin_api_viewer_must_not_write" } + : {}, + }, + })); + + const result = await waitForEnvelope(peer.envelopes, "command_result", requestId); + // A paired controller (the phone) must pass the authorization gate for + // these credential mutations — same trust level as lanes.create/delete. + // The result is never a forbidden_command rejection (it may fail + // downstream on a service not wired into this host harness, which is + // fine here — the handler success paths live in syncRemoteCommandService + // tests). + expect((result.payload as { error?: { code?: string } }).error?.code).not.toBe( + "forbidden_command", + ); + } + } finally { + peer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); +}); + describe("outbound changeset ack retries", () => { beforeEach(() => { publishMock.mockReset(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index fba4c1e12..a40bb77a8 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -35,6 +35,9 @@ function createService(options?: { getPairingConnectInfo?: () => SyncPairingConnectInfo | null; issueRuntimeHostPairingGrant?: () => string; isCloudRelayEnabled?: () => boolean; + linearCredentialService?: Record; + linearOAuthService?: Record; + getLinearIssueTracker?: () => Record | null; usageTrackingService?: Record; productAnalyticsService?: Record; personalChatScope?: { @@ -87,6 +90,9 @@ function createService(options?: { ? { issueRuntimeHostPairingGrant: options.issueRuntimeHostPairingGrant } : {}), ...(options?.isCloudRelayEnabled ? { isCloudRelayEnabled: options.isCloudRelayEnabled } : {}), + ...(options?.linearCredentialService ? { linearCredentialService: options.linearCredentialService } : {}), + ...(options?.linearOAuthService ? { linearOAuthService: options.linearOAuthService } : {}), + ...(options?.getLinearIssueTracker ? { getLinearIssueTracker: options.getLinearIssueTracker } : {}), ...(options?.usageTrackingService ? { usageTrackingService: options.usageTrackingService } : {}), ...(options?.productAnalyticsService ? { productAnalyticsService: options.productAnalyticsService } : {}), ...(options?.personalChatScope ? { personalChatScope: options.personalChatScope } : {}), @@ -241,6 +247,198 @@ describe("createSyncRemoteCommandService", () => { ); }); + it("registers CTO-gated Linear credential commands and refreshes status after each mutation", async () => { + let token: string | null = null; + let authMode: "manual" | "oauth" | null = null; + let tokenExpiresAt: string | null = null; + const linearCredentialService = { + getStatus: vi.fn(() => ({ + tokenStored: token != null, + authMode, + tokenExpiresAt, + oauthConfigured: true, + })), + setToken: vi.fn((nextToken: string) => { + token = nextToken; + authMode = "manual"; + tokenExpiresAt = null; + }), + setOAuthToken: vi.fn((next: { accessToken: string; expiresAt?: string | null }) => { + token = next.accessToken; + authMode = "oauth"; + tokenExpiresAt = next.expiresAt ?? null; + }), + clearToken: vi.fn(() => { + token = null; + authMode = null; + tokenExpiresAt = null; + }), + }; + const getConnectionStatus = vi.fn(async () => token === "invalid-token" + ? { + connected: false, + viewerId: null, + viewerName: null, + organizationId: null, + organizationName: null, + organizationUrlKey: null, + organizationLogoUrl: null, + message: "Linear rejected the API key.", + } + : { + connected: true, + viewerId: "viewer-1", + viewerName: "Ada", + organizationId: "org-1", + organizationName: "Acme", + organizationUrlKey: "acme", + organizationLogoUrl: "https://example.com/acme.png", + message: null, + }); + const startExternalSession = vi.fn(async () => ({ + sessionId: "linear-oauth-mobile-1", + authorizeUrl: "https://linear.app/oauth/authorize?state=state-1", + expiresAt: "2026-07-18T12:05:00.000Z", + })); + const completeExternalSession = vi.fn(async () => { + linearCredentialService.setOAuthToken({ + accessToken: "oauth-access-token", + expiresAt: "2026-07-18T14:00:00.000Z", + }); + return { ok: true as const }; + }); + const { service } = createService({ + linearCredentialService, + linearOAuthService: { startExternalSession, completeExternalSession }, + getLinearIssueTracker: () => ({ getConnectionStatus }), + }); + const mutationActions = [ + "cto.startLinearMobileOAuth", + "cto.completeLinearMobileOAuth", + "cto.setLinearToken", + "cto.clearLinearToken", + ] as const; + + for (const action of mutationActions) { + expect(service.getDescriptor(action)).toEqual({ + action, + scope: "project", + policy: { viewerAllowed: true }, + }); + } + + await expect(service.execute(makePayload("cto.startLinearMobileOAuth"))).resolves.toEqual({ + sessionId: "linear-oauth-mobile-1", + authorizeUrl: "https://linear.app/oauth/authorize?state=state-1", + expiresAt: "2026-07-18T12:05:00.000Z", + }); + expect(startExternalSession).toHaveBeenCalledWith({ + redirectUri: "https://ade-github-webhook-relay.arulsharma1028.workers.dev/linear/oauth/callback", + }); + + await expect(service.execute(makePayload("cto.completeLinearMobileOAuth", { + sessionId: "linear-oauth-mobile-1", + code: "authorization-code", + state: "state-1", + }))).resolves.toMatchObject({ + tokenStored: true, + connected: true, + authMode: "oauth", + viewerName: "Ada", + organizationName: "Acme", + }); + expect(completeExternalSession).toHaveBeenCalledWith({ + sessionId: "linear-oauth-mobile-1", + code: "authorization-code", + state: "state-1", + }); + + await expect(service.execute(makePayload("cto.setLinearToken", { + token: "manual-token", + }))).resolves.toMatchObject({ + tokenStored: true, + connected: true, + authMode: "manual", + }); + expect(linearCredentialService.setToken).toHaveBeenLastCalledWith("manual-token"); + + await expect(service.execute(makePayload("cto.setLinearToken", { + token: "invalid-token", + }))).resolves.toMatchObject({ + tokenStored: true, + connected: false, + authMode: "manual", + message: "Linear rejected the API key.", + }); + + await expect(service.execute(makePayload("cto.clearLinearToken"))).resolves.toMatchObject({ + tokenStored: false, + connected: false, + authMode: null, + message: "Linear token not configured.", + }); + expect(linearCredentialService.clearToken).toHaveBeenCalledTimes(1); + }); + + it("keeps the existing Linear connection when a mobile OAuth reconnect fails", async () => { + // Regression for the /quality F4 finding: an already-connected user taps + // Reconnect and the fresh exchange fails (state mismatch / transient). The + // stored token is untouched, so the command must report the ACTUAL status + // (still connected) with the failure reason attached — never a false + // disconnected status that wipes the connected UI. + const linearCredentialService = { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "oauth" as const, + tokenExpiresAt: "2026-07-18T14:00:00.000Z", + oauthConfigured: true, + })), + setToken: vi.fn(), + setOAuthToken: vi.fn(), + clearToken: vi.fn(), + }; + const getConnectionStatus = vi.fn(async () => ({ + connected: true, + viewerId: "viewer-1", + viewerName: "Ada", + organizationId: "org-1", + organizationName: "Acme", + organizationUrlKey: "acme", + organizationLogoUrl: "https://example.com/acme.png", + message: null, + })); + const startExternalSession = vi.fn(async () => ({ + sessionId: "linear-oauth-mobile-2", + authorizeUrl: "https://linear.app/oauth/authorize?state=state-2", + expiresAt: "2026-07-18T12:05:00.000Z", + })); + const completeExternalSession = vi.fn(async () => ({ + ok: false as const, + message: "Linear OAuth state did not match the active sign-in. Start a new sign-in and try again.", + })); + const { service } = createService({ + linearCredentialService, + linearOAuthService: { startExternalSession, completeExternalSession }, + getLinearIssueTracker: () => ({ getConnectionStatus }), + }); + + const result = await service.execute(makePayload("cto.completeLinearMobileOAuth", { + sessionId: "linear-oauth-mobile-2", + code: "authorization-code", + state: "stale-state", + })); + + expect(result).toMatchObject({ + tokenStored: true, + connected: true, + authMode: "oauth", + viewerName: "Ada", + organizationName: "Acme", + message: "Linear OAuth state did not match the active sign-in. Start a new sign-in and try again.", + }); + expect(linearCredentialService.clearToken).not.toHaveBeenCalled(); + }); + it("advertises and executes machine personal chats as runtime commands", async () => { const personalChatScope = { call: vi.fn(async (action: string, args: unknown) => ({ diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 8b95109ea..0ef236110 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -108,6 +108,7 @@ import type { ImportBranchLaneArgs, LandPrArgs, LandQueueNextArgs, + LinearConnectionStatus, PauseQueueAutomationArgs, PersonalChatScopeContract, PrGithubCoords, @@ -229,6 +230,10 @@ import { resolveCodexComputerUseMcpConfig } from "../../../../desktop/src/main/u import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; import type { CtoMemoryService } from "../../../../desktop/src/main/services/cto/ctoMemoryService"; import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import { + LINEAR_MOBILE_OAUTH_REDIRECT_URI, + type createLinearOAuthService, +} from "../../../../desktop/src/main/services/cto/linearOAuthService"; import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; import { matchLaneOverlayPolicies } from "../../../../desktop/src/main/services/config/laneOverlayMatcher"; import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; @@ -310,6 +315,7 @@ type SyncRemoteCommandServiceArgs = { ctoStateService?: ReturnType | null; ctoMemoryService?: CtoMemoryService | null; linearCredentialService?: ReturnType | null; + linearOAuthService?: ReturnType | null; /** * Resolvers for services created after createSyncService in main.ts. * Router handlers read them lazily so init order is not load-bearing. @@ -484,6 +490,77 @@ async function getConnectedLinearIssueTracker( return status?.connected ? linearIssueTracker : null; } +function buildDisconnectedLinearConnectionStatus( + args: SyncRemoteCommandServiceArgs, + message: string, +): LinearConnectionStatus { + const credentialStatus = args.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + tokenExpiresAt: null, + oauthConfigured: false, + }; + return { + tokenStored: Boolean(credentialStatus.tokenStored), + connected: false, + viewerId: null, + viewerName: null, + organizationId: null, + organizationName: null, + organizationUrlKey: null, + organizationLogoUrl: null, + checkedAt: new Date().toISOString(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message, + }; +} + +async function buildLinearConnectionStatus( + args: SyncRemoteCommandServiceArgs, + messageOverride?: string, +): Promise { + const credentialStatus = args.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + tokenExpiresAt: null, + oauthConfigured: false, + }; + const tokenStored = Boolean(credentialStatus.tokenStored); + const checkedAt = new Date().toISOString(); + const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; + if (!linearIssueTracker || !tokenStored) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt, + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: messageOverride ?? (tokenStored ? "Linear tracker service unavailable." : "Linear token not configured."), + }; + } + const status = await linearIssueTracker.getConnectionStatus(); + return { + tokenStored, + connected: status.connected, + viewerId: status.viewerId, + viewerName: status.viewerName, + organizationId: status.organizationId, + organizationName: status.organizationName, + organizationUrlKey: status.organizationUrlKey, + organizationLogoUrl: status.organizationLogoUrl, + checkedAt, + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: messageOverride ?? status.message, + }; +} + function asStringRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const entries = Object.entries(value) @@ -4237,45 +4314,54 @@ function registerCtoRemoteCommands({ args, register }: RemoteCommandRegistration // { memory, threadState, dailyLog, dailyLogDate, updatedAt }. return ctoMemoryService.getSnapshot(); }); - register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => { - const credentialStatus = args.linearCredentialService?.getStatus() ?? { - tokenStored: false, - authMode: null, - tokenExpiresAt: null, - oauthConfigured: false, - }; - const tokenStored = Boolean(credentialStatus.tokenStored); - const checkedAt = new Date().toISOString(); - const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; - if (!linearIssueTracker || !tokenStored) { - return { - tokenStored, - connected: false, - viewerId: null, - viewerName: null, - checkedAt, - authMode: credentialStatus.authMode, - oauthAvailable: credentialStatus.oauthConfigured, - tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", - }; - } - const status = await linearIssueTracker.getConnectionStatus(); - return { - tokenStored, - connected: status.connected, - viewerId: status.viewerId, - viewerName: status.viewerName, - organizationId: status.organizationId, - organizationName: status.organizationName, - organizationUrlKey: status.organizationUrlKey, - organizationLogoUrl: status.organizationLogoUrl, - checkedAt, - authMode: credentialStatus.authMode, - oauthAvailable: credentialStatus.oauthConfigured, - tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: status.message, - }; + register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => + buildLinearConnectionStatus(args)); + register("cto.startLinearMobileOAuth", { viewerAllowed: true }, async () => { + const linearOAuthService = requireService( + args.linearOAuthService, + "Linear OAuth service not available.", + ); + return linearOAuthService.startExternalSession({ + redirectUri: LINEAR_MOBILE_OAUTH_REDIRECT_URI, + }); + }); + register("cto.completeLinearMobileOAuth", { viewerAllowed: true }, async (payload) => { + const linearOAuthService = requireService( + args.linearOAuthService, + "Linear OAuth service not available.", + ); + const result = await linearOAuthService.completeExternalSession({ + sessionId: requireString( + payload.sessionId, + "cto.completeLinearMobileOAuth requires sessionId.", + ), + code: requireString(payload.code, "cto.completeLinearMobileOAuth requires code."), + state: requireString(payload.state, "cto.completeLinearMobileOAuth requires state."), + }); + // On failure, report the ACTUAL current status (the prior token may still + // be valid — e.g. a reconnect whose fresh exchange failed) with the failure + // reason attached, instead of forcing an unconditional disconnected status. + return result.ok + ? buildLinearConnectionStatus(args) + : buildLinearConnectionStatus(args, result.message); + }); + register("cto.setLinearToken", { viewerAllowed: true }, async (payload) => { + const linearCredentialService = requireService( + args.linearCredentialService, + "Linear credential service not available.", + ); + linearCredentialService.setToken( + requireString(payload.token, "cto.setLinearToken requires token."), + ); + return buildLinearConnectionStatus(args); + }); + register("cto.clearLinearToken", { viewerAllowed: true }, async () => { + const linearCredentialService = requireService( + args.linearCredentialService, + "Linear credential service not available.", + ); + linearCredentialService.clearToken(); + return buildDisconnectedLinearConnectionStatus(args, "Linear token not configured."); }); register("cto.getLinearQuickView", { viewerAllowed: true }, async () => { const credentialStatus = args.linearCredentialService?.getStatus() ?? { diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index e7e4ce399..5a8839bd9 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -26,6 +26,7 @@ import type { createAiIntegrationService } from "../../../../desktop/src/main/se import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; import type { CtoMemoryService } from "../../../../desktop/src/main/services/cto/ctoMemoryService"; import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearOAuthService } from "../../../../desktop/src/main/services/cto/linearOAuthService"; import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; import type { createComputerUseArtifactBrokerService } from "../../../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; @@ -143,6 +144,7 @@ type SyncServiceArgs = { ctoStateService?: ReturnType | null; ctoMemoryService?: CtoMemoryService | null; linearCredentialService?: ReturnType | null; + linearOAuthService?: ReturnType | null; /** * Resolvers for services that are constructed AFTER createSyncService in * main.ts. Using lazy getters lets the sync router forward remote commands @@ -741,6 +743,7 @@ export function createSyncService(args: SyncServiceArgs) { ctoStateService: args.ctoStateService, ctoMemoryService: args.ctoMemoryService, linearCredentialService: args.linearCredentialService, + linearOAuthService: args.linearOAuthService, getLinearIssueTracker: args.getLinearIssueTracker, getExternalSessionsService: args.getExternalSessionsService, projectConfigService: args.projectConfigService, diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 3d70d7961..0f0c7e1ed 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLinearClient } from "./linearClient"; import { createLinearCredentialService } from "./linearCredentialService"; -import { createLinearOAuthService } from "./linearOAuthService"; +import { + createLinearOAuthService, + LINEAR_MOBILE_OAUTH_REDIRECT_URI, +} from "./linearOAuthService"; import { LINEAR_OAUTH_TOKEN_URL, linearInvalidGrantLikelyStaleRotation, @@ -400,6 +403,7 @@ describe("linearOAuthService", () => { // admin for Linear to deliver its data-change webhooks (asserted below). expect(result.authUrl).toContain(`scope=${encodeURIComponent("read,write")}`); expect(result.authUrl).not.toContain("admin"); + expect(result.authUrl).toContain("actor=user"); expect(result.authUrl).toContain("prompt=consent"); expect(result.redirectUri).toContain("/oauth/callback"); @@ -687,6 +691,86 @@ describe("linearOAuthService", () => { expect(authUrl.searchParams.get("code_challenge_method")).toBeNull(); expect(authUrl.searchParams.get("code_challenge")).toBeNull(); }); + + it("completes an external PKCE session through the Worker redirect and stores the OAuth token", async () => { + const credentials = createCredentialsMock({ clientSecret: null, clientSource: "ade-app" }); + credentials.getOAuthClientCredentials.mockReturnValue({ + clientId: "public-client-id", + clientSecret: null as any, + }); + const mockFetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "linear-mobile-access-token", + refresh_token: "linear-mobile-refresh-token", + expires_in: 7200, + }), + })) as any; + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const started = await service.startExternalSession({ + redirectUri: LINEAR_MOBILE_OAUTH_REDIRECT_URI, + }); + const authorizeUrl = new URL(started.authorizeUrl); + const state = authorizeUrl.searchParams.get("state"); + + expect(started.sessionId).toMatch(/^linear-oauth-/); + expect(Date.parse(started.expiresAt)).toBeGreaterThan(Date.now()); + expect(authorizeUrl.searchParams.get("redirect_uri")).toBe(LINEAR_MOBILE_OAUTH_REDIRECT_URI); + expect(authorizeUrl.searchParams.get("client_id")).toBe("public-client-id"); + expect(authorizeUrl.searchParams.get("scope")).toBe("read,write,admin"); + expect(authorizeUrl.searchParams.get("response_type")).toBe("code"); + expect(authorizeUrl.searchParams.get("prompt")).toBe("consent"); + expect(authorizeUrl.searchParams.get("actor")).toBe("user"); + expect(authorizeUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorizeUrl.searchParams.get("code_challenge")).toBeTruthy(); + expect(state).toBeTruthy(); + + await expect(service.completeExternalSession({ + sessionId: started.sessionId, + code: "wrong-state-code", + state: "wrong-state", + })).resolves.toEqual({ + ok: false, + message: expect.stringContaining("state did not match"), + }); + expect(mockFetch).not.toHaveBeenCalled(); + + await expect(service.completeExternalSession({ + sessionId: started.sessionId, + code: "mobile-authorization-code", + state: state!, + })).resolves.toEqual({ ok: true }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.linear.app/oauth/token", + expect.objectContaining({ method: "POST" }), + ); + const tokenRequest = new URLSearchParams(String(mockFetch.mock.calls[0]![1]?.body)); + expect(tokenRequest.get("code")).toBe("mobile-authorization-code"); + expect(tokenRequest.get("redirect_uri")).toBe(LINEAR_MOBILE_OAUTH_REDIRECT_URI); + expect(tokenRequest.get("client_id")).toBe("public-client-id"); + expect(tokenRequest.get("code_verifier")).toBeTruthy(); + expect(tokenRequest.get("client_secret")).toBeNull(); + expect(credentials.setOAuthToken).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: "linear-mobile-access-token", + refreshToken: "linear-mobile-refresh-token", + })); + await expect(service.completeExternalSession({ + sessionId: started.sessionId, + code: "replayed-code", + state: state!, + })).resolves.toEqual({ + ok: false, + message: expect.stringContaining("not found or has expired"), + }); + }); }); // ===================================================================== diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index 409723e2f..9c80d5182 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -14,7 +14,11 @@ const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token"; const CALLBACK_PATH = "/oauth/callback"; const OAUTH_HOST = "127.0.0.1"; const OAUTH_PORT = 19836; -const SESSION_TTL_MS = 10 * 60 * 1000; +const LOOPBACK_SESSION_TTL_MS = 10 * 60 * 1000; +const EXTERNAL_SESSION_TTL_MS = 5 * 60 * 1000; + +export const LINEAR_MOBILE_OAUTH_REDIRECT_URI = + "https://ade-github-webhook-relay.arulsharma1028.workers.dev/linear/oauth/callback"; type LinearOAuthSessionState = { id: string; @@ -28,6 +32,26 @@ type LinearOAuthSessionState = { server: http.Server; }; +type LinearExternalOAuthSessionState = { + id: string; + state: string; + redirectUri: string; + authorizeUrl: string; + codeVerifier: string; + createdAt: number; + expiresAt: string; +}; + +export type LinearExternalOAuthStartResult = { + sessionId: string; + authorizeUrl: string; + expiresAt: string; +}; + +export type LinearExternalOAuthCompleteResult = + | { ok: true } + | { ok: false; message: string }; + function isAddressInUseError(error: unknown): boolean { if (!error || typeof error !== "object") return false; if ("code" in error && error.code === "EADDRINUSE") return true; @@ -53,6 +77,7 @@ export function createLinearOAuthService(args: { }) { const fetchImpl = args.fetchImpl ?? fetch; const sessions = new Map(); + const externalSessions = new Map(); const finalizeSession = (session: LinearOAuthSessionState, patch: { status: LinearOAuthSessionState["status"]; @@ -70,19 +95,62 @@ export function createLinearOAuthService(args: { const pruneExpiredSessions = () => { const now = Date.now(); for (const session of sessions.values()) { - if (session.status === "pending" && now - session.createdAt > SESSION_TTL_MS) { + if (session.status === "pending" && now - session.createdAt > LOOPBACK_SESSION_TTL_MS) { finalizeSession(session, { status: "expired", error: "Linear OAuth session expired before the callback completed.", }); } - if (session.status !== "pending" && now - session.createdAt > SESSION_TTL_MS * 2) { + if (session.status !== "pending" && now - session.createdAt > LOOPBACK_SESSION_TTL_MS * 2) { sessions.delete(session.id); } } }; - const exchangeCode = async (session: LinearOAuthSessionState, code: string): Promise => { + const pruneExpiredExternalSessions = () => { + const now = Date.now(); + for (const session of externalSessions.values()) { + if (now - session.createdAt >= EXTERNAL_SESSION_TTL_MS) { + externalSessions.delete(session.id); + } + } + }; + + const buildAuthorizeUrl = (input: { + clientId: string; + redirectUri: string; + state: string; + codeChallenge?: string | null; + }): string => { + const authorizeUrl = new URL(LINEAR_AUTHORIZE_URL); + authorizeUrl.searchParams.set("client_id", input.clientId); + authorizeUrl.searchParams.set("redirect_uri", input.redirectUri); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("state", input.state); + // The ADE app's data-change webhooks only deliver for a workspace whose + // authorization carries the admin scope (Linear's OAuth-app webhook rule). + // Custom OAuth clients keep the narrower grant. + authorizeUrl.searchParams.set( + "scope", + args.credentials.getOAuthClientSource() === "ade-app" ? "read,write,admin" : "read,write", + ); + // Keep authorization user-scoped. This is Linear's default, but making it + // explicit keeps the desktop and mobile authorize URLs byte-for-byte aligned. + authorizeUrl.searchParams.set("actor", "user"); + // Ask Linear for a consent screen; Linear still resolves the workspace + // from the user's active browser session/workspace switcher. + authorizeUrl.searchParams.set("prompt", "consent"); + if (input.codeChallenge) { + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("code_challenge", input.codeChallenge); + } + return authorizeUrl.toString(); + }; + + const exchangeCode = async ( + session: Pick, + code: string, + ): Promise => { const oauthClient = args.credentials.getOAuthClientCredentials(); if (!oauthClient) { throw new Error("Linear OAuth is not configured. Configure it in Settings > Linear."); @@ -245,31 +313,18 @@ export function createLinearOAuthService(args: { } const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`; - const authUrl = new URL(LINEAR_AUTHORIZE_URL); - authUrl.searchParams.set("client_id", oauthClient.clientId); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("state", state); - // The ADE app's data-change webhooks only deliver for a workspace whose - // authorization carries the admin scope (Linear's OAuth-app webhook rule). - // Custom OAuth clients keep the narrower grant. - authUrl.searchParams.set( - "scope", - args.credentials.getOAuthClientSource() === "ade-app" ? "read,write,admin" : "read,write", - ); - // Ask Linear for a consent screen; Linear still resolves the workspace - // from the user's active browser session/workspace switcher. - authUrl.searchParams.set("prompt", "consent"); - if (pkce) { - authUrl.searchParams.set("code_challenge_method", "S256"); - authUrl.searchParams.set("code_challenge", pkce.challenge); - } + const authUrl = buildAuthorizeUrl({ + clientId: oauthClient.clientId, + redirectUri, + state, + codeChallenge: pkce?.challenge, + }); session = { id: sessionId, state, redirectUri, - authUrl: authUrl.toString(), + authUrl, codeVerifier: pkce?.verifier ?? null, createdAt: Date.now(), status: "pending", @@ -300,9 +355,85 @@ export function createLinearOAuthService(args: { }; }; + const startExternalSession = async (input: { + redirectUri: string; + }): Promise => { + pruneExpiredExternalSessions(); + const oauthClient = args.credentials.getOAuthClientCredentials(); + if (!oauthClient) { + throw new Error("Linear OAuth is not configured. Configure it in Settings > Linear."); + } + + const sessionId = `linear-oauth-${randomUUID()}`; + const state = randomUUID(); + const pkce = createPkcePair(); + const createdAt = Date.now(); + const expiresAt = new Date(createdAt + EXTERNAL_SESSION_TTL_MS).toISOString(); + const authorizeUrl = buildAuthorizeUrl({ + clientId: oauthClient.clientId, + redirectUri: input.redirectUri, + state, + codeChallenge: pkce.challenge, + }); + + externalSessions.set(sessionId, { + id: sessionId, + state, + redirectUri: input.redirectUri, + authorizeUrl, + codeVerifier: pkce.verifier, + createdAt, + expiresAt, + }); + + return { sessionId, authorizeUrl, expiresAt }; + }; + + const completeExternalSession = async (input: { + sessionId: string; + code: string; + state: string; + }): Promise => { + pruneExpiredExternalSessions(); + const session = externalSessions.get(input.sessionId); + if (!session) { + return { + ok: false, + message: "Linear OAuth session was not found or has expired. Start a new sign-in and try again.", + }; + } + if (input.state !== session.state) { + args.logger?.warn("linear_sync.external_oauth_state_mismatch", { + sessionId: session.id, + hasReturnedState: input.state.length > 0, + }); + return { + ok: false, + message: "Linear OAuth state did not match the active sign-in. Start a new sign-in and try again.", + }; + } + + try { + await exchangeCode(session, input.code); + externalSessions.delete(session.id); + return { ok: true }; + } catch (error) { + const message = error instanceof Error && error.message + ? error.message + : "Linear OAuth token exchange failed."; + args.logger?.warn("linear_sync.external_oauth_exchange_failed", { + sessionId: session.id, + error: message, + }); + return { ok: false, message }; + } + }; + return { startSession, getSession, + startExternalSession, + completeExternalSession, dispose() { for (const session of sessions.values()) { try { @@ -312,6 +443,7 @@ export function createLinearOAuthService(args: { } } sessions.clear(); + externalSessions.clear(); }, }; } diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 34564f6cb..39635c697 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -18,6 +18,51 @@ import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; const LINEAR_BRAND = "#5E6AD2"; const LINEAR_API_SETTINGS_URL = "https://linear.app/settings/api"; + +function LinearWorkspaceAvatar({ + organizationName, + logoUrl, +}: { + organizationName: string | null | undefined; + logoUrl: string | null | undefined; +}) { + const normalizedLogoUrl = logoUrl?.trim() || null; + const [failedLogoUrl, setFailedLogoUrl] = useState(null); + const showLogo = normalizedLogoUrl != null && failedLogoUrl !== normalizedLogoUrl; + const monogram = organizationName?.trim().charAt(0).toUpperCase() || "L"; + + return ( + + ); +} + type GitHubAutolinkCandidate = { id: string; title: string; @@ -479,12 +524,18 @@ export function LinearSection({ embedded = false }: { embedded?: boolean }) { background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", border: `1px solid ${COLORS.border}`, }}> -
-
- Workspace -
-
- {workspaceLabel} +
+ +
+
+ Workspace +
+
+ {workspaceLabel} +
{connection?.organizationUrlKey ? ( diff --git a/apps/desktop/src/shared/syncMobileCompatibility.ts b/apps/desktop/src/shared/syncMobileCompatibility.ts index abe77f6d1..869c4cf3b 100644 --- a/apps/desktop/src/shared/syncMobileCompatibility.ts +++ b/apps/desktop/src/shared/syncMobileCompatibility.ts @@ -2,6 +2,16 @@ import type { SyncFileRequest, SyncRemoteCommandAction } from "./types"; export const MOBILE_SYNC_COMPATIBILITY_CONTRACT_VERSION = 1; +// Additive capabilities that newer mobile clients may feature-detect from +// hello_ok.features.commandRouting.actions. They must not become part of the +// required set below, because older mobile builds do not implement them. +export const MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS = [ + "cto.startLinearMobileOAuth", + "cto.completeLinearMobileOAuth", + "cto.setLinearToken", + "cto.clearLinearToken", +] as const satisfies readonly SyncRemoteCommandAction[]; + export const MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS = [ "lanes.presence.announce", "lanes.presence.release", diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index db8f0709b..224948bf8 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -16,6 +16,7 @@ import type { } from "./externalSessions"; import type { PtySendToSessionResult, TerminalSessionSummary } from "./sessions"; import type { PairedRuntimeSyncEnvelope } from "./pairedRuntime"; +import type { LinearConnectionStatus } from "./linearSync"; export type SyncScalarBytes = { type: "bytes"; @@ -1081,6 +1082,32 @@ export type SyncSendToSessionArgs = { export type SyncSendToSessionResult = PtySendToSessionResult; +export type CtoStartLinearMobileOAuthArgs = Record; + +export type CtoStartLinearMobileOAuthResult = { + sessionId: string; + authorizeUrl: string; + expiresAt: string; +}; + +export type CtoCompleteLinearMobileOAuthArgs = { + sessionId: string; + code: string; + state: string; +}; + +export type CtoCompleteLinearMobileOAuthResult = LinearConnectionStatus; + +export type CtoSetLinearTokenSyncArgs = { + token: string; +}; + +export type CtoSetLinearTokenResult = LinearConnectionStatus; + +export type CtoClearLinearTokenArgs = Record; + +export type CtoClearLinearTokenResult = LinearConnectionStatus; + export type SyncRemoteCommandAction = | "analytics.capture" | "analytics.flush" @@ -1200,6 +1227,10 @@ export type SyncRemoteCommandAction = | "cto.getState" | "cto.getMemory" | "cto.getLinearConnectionStatus" + | "cto.startLinearMobileOAuth" + | "cto.completeLinearMobileOAuth" + | "cto.setLinearToken" + | "cto.clearLinearToken" | "cto.getLinearQuickView" | "cto.getLinearIssuePickerData" | "cto.searchLinearIssues" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index d862bdfe7..dd1f50759 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ E20000000000000000000097 /* LinearLaunchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000097 /* LinearLaunchScreen.swift */; }; E20000000000000000000098 /* LinearPaneSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000098 /* LinearPaneSheet.swift */; }; E20000000000000000000099 /* LinearPaneToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000099 /* LinearPaneToolbarButton.swift */; }; + E2000000000000000000009A /* LinearOAuthRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000009A /* LinearOAuthRunner.swift */; }; + E2000000000000000000009B /* LinearConnectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000009B /* LinearConnectionScreen.swift */; }; AA1100000000000000000001 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; AA1100000000000000000002 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; AA1100000000000000000003 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; }; @@ -294,6 +296,8 @@ D20000000000000000000097 /* LinearLaunchScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearLaunchScreen.swift; path = ADE/Views/Linear/LinearLaunchScreen.swift; sourceTree = ""; }; D20000000000000000000098 /* LinearPaneSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearPaneSheet.swift; path = ADE/Views/Linear/LinearPaneSheet.swift; sourceTree = ""; }; D20000000000000000000099 /* LinearPaneToolbarButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearPaneToolbarButton.swift; path = ADE/Views/Linear/LinearPaneToolbarButton.swift; sourceTree = ""; }; + D2000000000000000000009A /* LinearOAuthRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearOAuthRunner.swift; path = ADE/Views/Linear/LinearOAuthRunner.swift; sourceTree = ""; }; + D2000000000000000000009B /* LinearConnectionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearConnectionScreen.swift; path = ADE/Views/Linear/LinearConnectionScreen.swift; sourceTree = ""; }; AA1000000000000000000001 /* ADESharedContainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedContainer.swift; path = ADE/Shared/ADESharedContainer.swift; sourceTree = ""; }; AA1000000000000000000002 /* ADESharedModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedModels.swift; path = ADE/Shared/ADESharedModels.swift; sourceTree = ""; }; AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = ""; }; @@ -582,6 +586,8 @@ D20000000000000000000097 /* LinearLaunchScreen.swift */, D20000000000000000000098 /* LinearPaneSheet.swift */, D20000000000000000000099 /* LinearPaneToolbarButton.swift */, + D2000000000000000000009A /* LinearOAuthRunner.swift */, + D2000000000000000000009B /* LinearConnectionScreen.swift */, ); name = Linear; sourceTree = ""; @@ -1305,6 +1311,8 @@ E20000000000000000000097 /* LinearLaunchScreen.swift in Sources */, E20000000000000000000098 /* LinearPaneSheet.swift in Sources */, E20000000000000000000099 /* LinearPaneToolbarButton.swift in Sources */, + E2000000000000000000009A /* LinearOAuthRunner.swift in Sources */, + E2000000000000000000009B /* LinearConnectionScreen.swift in Sources */, AA1100000000000000000001 /* ADESharedContainer.swift in Sources */, AA1100000000000000000002 /* ADESharedModels.swift in Sources */, AA1100000000000000000003 /* ADESharedTheme.swift in Sources */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 7a06471d8..c643faf42 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1402,6 +1402,16 @@ struct LinearIssueComment: Codable, Hashable, Identifiable { var userDisplayName: String? } +/// Response of `cto.startLinearMobileOAuth`: the pending desktop OAuth session +/// plus the Linear authorize URL the phone opens in `ASWebAuthenticationSession`. +/// The `code_verifier` never leaves the desktop; the phone only forwards the +/// authorization `code` back over sync for the desktop to exchange. +struct LinearMobileOAuthSession: Codable, Hashable { + var sessionId: String + var authorizeUrl: String + var expiresAt: String +} + struct AgentChatSession: Codable, Identifiable, Equatable { var id: String { sessionId } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 0b03ad4a5..238451042 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1983,6 +1983,15 @@ func performAuthorizedAccountPairingCommit( final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @Published private(set) var hostName: String? + + /// Human-facing name of the connected machine, or a neutral "your Mac" + /// fallback. Shared by Linear connect/status copy (and available to other + /// surfaces that otherwise re-derive the same fallback). + var machineDisplayName: String { + let trimmed = hostName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { return trimmed } + return "your Mac" + } /// Whether this phone currently holds a Tailscale-assigned address on a /// tunnel interface. Drives the "iPhone isn't on Tailscale" connection hint. @Published private(set) var phoneHasTailnetInterface = false @@ -5545,6 +5554,63 @@ final class SyncService: ObservableObject { ) } + // MARK: - Linear credential mutations (mobile connect / manage) + // + // These four commands are CTO-only credential mutations gated on the host + // (a read-only/viewer controller cannot invoke them — see Unit A). The token + // itself never crosses the wire: OAuth exchange and storage happen on the + // desktop; the phone only starts a PKCE-bound session and forwards the + // authorization code back. Each command that returns a fresh + // `LinearConnectionStatus` also updates the published property so the Work + // top-bar entry point and any open pane reflect the change immediately. + + /// Begins a worker-bounce OAuth session on the connected desktop and returns + /// the Linear authorize URL to open in `ASWebAuthenticationSession`. + func startLinearMobileOAuth() async throws -> LinearMobileOAuthSession { + try await sendDecodableCommand( + action: "cto.startLinearMobileOAuth", + as: LinearMobileOAuthSession.self + ) + } + + /// Forwards the captured authorization `code` + `state` to the desktop, which + /// exchanges them (with its stored PKCE verifier) and stores the OAuth token. + @MainActor + func completeLinearMobileOAuth(sessionId: String, code: String, state: String) async throws -> LinearConnectionStatus { + let status = try await sendDecodableCommand( + action: "cto.completeLinearMobileOAuth", + args: ["sessionId": sessionId, "code": code, "state": state], + as: LinearConnectionStatus.self + ) + linearConnectionStatus = status + return status + } + + /// Stores a manually-entered Linear API key on the desktop (authMode `manual`) + /// and returns the recomputed, viewer-validated connection status. + @MainActor + func setLinearToken(_ token: String) async throws -> LinearConnectionStatus { + let status = try await sendDecodableCommand( + action: "cto.setLinearToken", + args: ["token": token], + as: LinearConnectionStatus.self + ) + linearConnectionStatus = status + return status + } + + /// Clears the stored Linear credential on the desktop (affects the whole + /// machine) and returns the resulting disconnected status. + @MainActor + func clearLinearToken() async throws -> LinearConnectionStatus { + let status = try await sendDecodableCommand( + action: "cto.clearLinearToken", + as: LinearConnectionStatus.self + ) + linearConnectionStatus = status + return status + } + func updateCtoIdentity(patch: CtoIdentityPatch) async throws -> CtoSnapshot { let patchArgs = try encodedCommandArgs(from: patch) return try await sendDecodableCommand( diff --git a/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift b/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift new file mode 100644 index 000000000..cc5bf3553 --- /dev/null +++ b/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift @@ -0,0 +1,506 @@ +import SwiftUI + +/// Linear connection management screen, pushed from the pane's gear button. +/// When connected, shows the workspace (logo + name + viewer + auth/expiry +/// status) with Reconnect and Disconnect. When disconnected, offers the same +/// inline connect actions as the pane's empty state so the gear is never a +/// dead end. +struct LinearConnectionScreen: View { + @ObservedObject var store: LinearPaneStore + @EnvironmentObject private var syncService: SyncService + @Environment(\.dismiss) private var dismiss + + @StateObject private var runner = LinearOAuthRunner() + @State private var showDisconnectConfirm = false + @State private var disconnecting = false + @State private var actionError: String? + @State private var confirmation: String? + + private var status: LinearConnectionStatus? { + syncService.linearConnectionStatus ?? store.quickView?.connection + } + + private var isConnected: Bool { status?.connected == true } + + private var orgName: String { + store.quickView?.organization?.name + ?? status?.organizationName + ?? "Linear" + } + + private var orgLogoUrl: String? { + store.quickView?.organization?.logoUrl ?? status?.organizationLogoUrl + } + + private var viewerName: String? { + store.viewerLabel ?? status?.viewerName + } + + private var supportsReconnect: Bool { + syncService.supportsRemoteAction("cto.startLinearMobileOAuth") + } + + private var supportsDisconnect: Bool { + syncService.supportsRemoteAction("cto.clearLinearToken") + } + + var body: some View { + ScrollView { + VStack(spacing: 20) { + if isConnected { + connectedCard + } else { + disconnectedCard + } + + if let confirmation { + Label(confirmation, systemImage: "checkmark.circle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.success) + .frame(maxWidth: .infinity, alignment: .leading) + .transition(.opacity) + } + + if let actionError { + Text(actionError) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(20) + } + .scrollContentBackground(.hidden) + .background(ADEColor.pageBackground) + .navigationTitle("Linear") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + "Disconnect Linear?", + isPresented: $showDisconnectConfirm, + titleVisibility: .visible + ) { + Button("Disconnect Linear", role: .destructive) { + Task { await disconnect() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This clears Linear for the whole machine (\(machineName)). Every ADE surface on it will need to reconnect.") + } + } + + // MARK: Connected + + private var connectedCard: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 14) { + LinearOrgAvatar(logoUrl: orgLogoUrl, name: orgName, size: 46) + VStack(alignment: .leading, spacing: 3) { + Text(orgName) + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + if let viewerName { + Text(viewerName) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + } + } + Spacer(minLength: 0) + } + + HStack(spacing: 7) { + Circle() + .fill(ADEColor.success) + .frame(width: 8, height: 8) + Text(statusLine) + .font(.footnote.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Linear \(statusLine), workspace \(orgName)") + + VStack(spacing: 10) { + if supportsReconnect { + Button { + Task { await reconnect() } + } label: { + HStack(spacing: 8) { + if runner.isRunning { + ProgressView().controlSize(.small).tint(.white) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text(runner.isRunning ? "Reconnecting\u{2026}" : "Reconnect") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(LinearBrand.primary) + .disabled(runner.isRunning || disconnecting) + } + + if supportsDisconnect { + Button(role: .destructive) { + showDisconnectConfirm = true + } label: { + HStack(spacing: 8) { + if disconnecting { + ProgressView().controlSize(.small) + } else { + Image(systemName: "link.badge.minus") + } + Text("Disconnect") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.glass) + .tint(ADEColor.danger) + .disabled(runner.isRunning || disconnecting) + } + + if !supportsReconnect && !supportsDisconnect { + Text("Update ADE on your Mac to manage Linear connections from your phone.") + .font(.footnote) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .adeGlassCard(cornerRadius: 20, padding: 20) + } + + // MARK: Disconnected + + private var disconnectedCard: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + LinearMark(size: 26) + .foregroundStyle(LinearBrand.primaryBright) + VStack(alignment: .leading, spacing: 3) { + Text("Connect Linear") + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + Text("Sign in to browse and launch Linear issues from \(machineName).") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + + LinearConnectActions(store: store) { status in + ADEHaptics.success() + withAnimation { confirmation = "Connected to \(status.organizationName ?? orgName)" } + } + } + .adeGlassCard(cornerRadius: 20, padding: 20) + } + + // MARK: Derived copy + + private var machineName: String { syncService.machineDisplayName } + + private var statusLine: String { + guard let status, status.connected else { return "Not connected" } + switch status.authMode { + case "oauth": + if let expiry = linearTokenExpiryText(status.tokenExpiresAt) { + return "Connected \u{00B7} OAuth \u{00B7} \(expiry)" + } + return "Connected \u{00B7} OAuth" + case "manual": + return "Connected \u{00B7} API key" + default: + return "Connected" + } + } + + // MARK: Actions + + private func reconnect() async { + actionError = nil + let outcome = await runner.connect(using: syncService) + switch outcome { + case let .connected(status): + ADEHaptics.success() + withAnimation { confirmation = "Connected to \(status.organizationName ?? orgName)" } + await store.start() + case .canceled: + break + case let .failed(message): + ADEHaptics.warning() + actionError = message + } + } + + private func disconnect() async { + actionError = nil + disconnecting = true + defer { disconnecting = false } + do { + _ = try await syncService.clearLinearToken() + ADEHaptics.success() + await store.start() + dismiss() + } catch { + ADEHaptics.warning() + actionError = SyncUserFacingError.message(for: error) + } + } +} + +// MARK: - Connect actions (shared by the connection screen + disconnected pane) + +/// The two connect affordances: "Sign in with Linear" (worker-bounce OAuth) and +/// an expandable "Use an API key" path. Owns its own OAuth runner and reloads +/// the pane store on success before calling `onConnected`. +struct LinearConnectActions: View { + @ObservedObject var store: LinearPaneStore + var onConnected: (LinearConnectionStatus) -> Void = { _ in } + + @EnvironmentObject private var syncService: SyncService + @StateObject private var runner = LinearOAuthRunner() + @State private var showApiKey = false + @State private var apiKey = "" + @State private var submitting = false + @State private var errorMessage: String? + @FocusState private var apiKeyFocused: Bool + + private var busy: Bool { runner.isRunning || submitting } + + private var supportsOAuth: Bool { + syncService.supportsRemoteAction("cto.startLinearMobileOAuth") + } + + private var supportsApiKey: Bool { + syncService.supportsRemoteAction("cto.setLinearToken") + } + + var body: some View { + VStack(spacing: 12) { + if supportsOAuth { + Button { + Task { await runOAuth() } + } label: { + HStack(spacing: 8) { + if runner.isRunning { + ProgressView().controlSize(.small).tint(.white) + } else { + LinearMark(size: 16).foregroundStyle(.white) + } + Text(runner.isRunning ? "Signing in\u{2026}" : "Sign in with Linear") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(LinearBrand.primary) + .disabled(busy) + } + + if supportsApiKey { + Button { + withAnimation { showApiKey.toggle() } + if showApiKey { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { apiKeyFocused = true } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "key.horizontal") + Text("Use an API key") + Spacer() + Image(systemName: showApiKey ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(ADEColor.textSecondary) + } + .buttonStyle(.glass) + .disabled(busy) + } + + if supportsApiKey && showApiKey { + apiKeyForm + } + + if !supportsOAuth && !supportsApiKey { + Text("Update ADE on your Mac to manage Linear connections from your phone.") + .font(.footnote) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var apiKeyForm: some View { + VStack(alignment: .leading, spacing: 10) { + SecureField("Linear API key", text: $apiKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .focused($apiKeyFocused) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(ADEColor.surfaceBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(ADEColor.glassBorder, lineWidth: 1)) + + HStack { + if let url = URL(string: "https://linear.app/settings/api") { + Link(destination: url) { + HStack(spacing: 4) { + Text("Create a key") + Image(systemName: "arrow.up.right") + } + .font(.caption.weight(.semibold)) + .foregroundStyle(LinearBrand.primaryBright) + } + } + Spacer() + Button { + Task { await submitApiKey() } + } label: { + HStack(spacing: 6) { + if submitting { ProgressView().controlSize(.small).tint(.white) } + Text("Connect").fontWeight(.semibold) + } + } + .buttonStyle(.glassProminent) + .tint(LinearBrand.primary) + .controlSize(.small) + .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || busy) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + private func runOAuth() async { + errorMessage = nil + let outcome = await runner.connect(using: syncService) + switch outcome { + case let .connected(status): + await store.start() + onConnected(status) + case .canceled: + break + case let .failed(message): + ADEHaptics.warning() + errorMessage = message + // Graceful fallback: surface Linear's message and expose the API-key path. + if supportsApiKey { + withAnimation { showApiKey = true } + } + } + } + + private func submitApiKey() async { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + errorMessage = nil + submitting = true + defer { submitting = false } + apiKeyFocused = false + do { + let status = try await syncService.setLinearToken(trimmed) + if status.connected { + apiKey = "" + await store.start() + onConnected(status) + } else { + ADEHaptics.warning() + errorMessage = status.message ?? "That API key didn\u{2019}t work. Check it and try again." + } + } catch { + ADEHaptics.warning() + errorMessage = SyncUserFacingError.message(for: error) + } + } +} + +// MARK: - Org avatar + +/// Workspace logo as a small rounded avatar, with a letter-monogram fallback +/// (first letter of the org name) when no logo URL is present or it fails to +/// load. Used everywhere connection status is shown. +struct LinearOrgAvatar: View { + let logoUrl: String? + let name: String + var size: CGFloat = 40 + + private var cornerRadius: CGFloat { size * 0.28 } + + var body: some View { + Group { + if let logoUrl, let url = URL(string: logoUrl) { + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image.resizable().scaledToFill() + case .failure: + monogram + case .empty: + ZStack { LinearBrand.surface; ProgressView().controlSize(.small) } + @unknown default: + monogram + } + } + } else { + monogram + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(LinearBrand.border, lineWidth: 1) + ) + .accessibilityHidden(true) + } + + private var monogram: some View { + ZStack { + LinearBrand.surface + Text(monogramLetter) + .font(.system(size: size * 0.42, weight: .bold)) + .foregroundStyle(LinearBrand.primaryBright) + } + } + + private var monogramLetter: String { + let first = name.trimmingCharacters(in: .whitespaces).first + return String(first ?? "L").uppercased() + } +} + +// MARK: - Expiry helpers + +/// "expires in N days" / "expires today" / "expired" for an OAuth token expiry +/// ISO timestamp; nil when there's no parseable expiry. +func linearTokenExpiryText(_ iso: String?) -> String? { + guard let date = linearParseISODate(iso) else { return nil } + let seconds = date.timeIntervalSinceNow + if seconds <= 0 { return "expired" } + let days = Int(seconds / 86_400) + switch days { + case 0: return "expires today" + case 1: return "expires in 1 day" + default: return "expires in \(days) days" + } +} + +func linearParseISODate(_ iso: String?) -> Date? { + guard let iso, !iso.isEmpty else { return nil } + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: iso) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: iso) +} diff --git a/apps/ios/ADE/Views/Linear/LinearIssueListScreen.swift b/apps/ios/ADE/Views/Linear/LinearIssueListScreen.swift index 6f9f7b1dc..e458e8637 100644 --- a/apps/ios/ADE/Views/Linear/LinearIssueListScreen.swift +++ b/apps/ios/ADE/Views/Linear/LinearIssueListScreen.swift @@ -6,6 +6,9 @@ enum LinearRoute: Hashable { /// Push the launch config. `laneOnly` = "Link to new lane" (create a lane /// attached to the issue, no agent); otherwise = "Launch in new lane" (chat/CLI). case launch(issue: NormalizedLinearIssue, laneOnly: Bool) + /// Push the Linear connection management screen (connect / reconnect / + /// disconnect), reached from the pane's gear button. + case connection } /// The Linear pane's root screen: a grouped-by-state, searchable, filterable @@ -56,16 +59,27 @@ struct LinearIssueListScreen: View { .onChange(of: store.priority) { _, _ in store.scheduleReload() } .navigationTitle(store.workspaceTitle) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { onClose() } label: { - Image(systemName: "xmark").font(.system(size: 13, weight: .semibold)) - } - .accessibilityLabel("Close Linear") + .toolbar { linearToolbar } + } + + // Extracted from `body` to keep the SwiftUI type-checker under its budget + // (the List + searchable + onChange chain + toolbar together tip it over). + @ToolbarContentBuilder + private var linearToolbar: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { onClose() } label: { + Image(systemName: "xmark").font(.system(size: 13, weight: .semibold)) } - ToolbarItem(placement: .topBarTrailing) { - LinearFiltersMenu(store: store) + .accessibilityLabel("Close Linear") + } + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: LinearRoute.connection) { + Image(systemName: "gearshape") } + .accessibilityLabel("Linear connection settings") + } + ToolbarItem(placement: .topBarTrailing) { + LinearFiltersMenu(store: store) } } diff --git a/apps/ios/ADE/Views/Linear/LinearOAuthRunner.swift b/apps/ios/ADE/Views/Linear/LinearOAuthRunner.swift new file mode 100644 index 000000000..024f4b7ea --- /dev/null +++ b/apps/ios/ADE/Views/Linear/LinearOAuthRunner.swift @@ -0,0 +1,160 @@ +import AuthenticationServices +import SwiftUI +import UIKit + +/// Outcome of a worker-bounce Linear OAuth attempt from the phone. +enum LinearOAuthOutcome { + /// Token exchanged + stored on the desktop; carries the fresh status. + case connected(LinearConnectionStatus) + /// User dismissed the `ASWebAuthenticationSession` (`.canceledLogin`). + case canceled + /// A user-facing failure message (Linear error, transport error, or a + /// disconnected status coming back from the desktop). Drives the "use an API + /// key instead" fallback UX. + case failed(String) +} + +/// Drives the phone side of the worker-bounce OAuth flow: +/// +/// `startLinearMobileOAuth()` (desktop mints PKCE session) +/// → open `authorizeUrl` in `ASWebAuthenticationSession(callbackURLScheme:"ade")` +/// → capture `ade://linear-oauth?code&state` in-session +/// → `completeLinearMobileOAuth(...)` (desktop exchanges + stores the token) +/// +/// The authorization code is PKCE-bound to a verifier that never leaves the +/// desktop, so it is useless in transit. The callback is captured by the web +/// auth session itself, so it never reaches `DeepLinkRouter` (which ignores the +/// `linear-oauth` host anyway). +@MainActor +final class LinearOAuthRunner: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { + @Published private(set) var isRunning = false + + /// Retained for the lifetime of the presentation so the callback fires. + private var webSession: ASWebAuthenticationSession? + + func connect(using sync: SyncService) async -> LinearOAuthOutcome { + guard !isRunning else { return .canceled } + isRunning = true + defer { isRunning = false } + + let session: LinearMobileOAuthSession + do { + session = try await sync.startLinearMobileOAuth() + } catch { + return .failed(SyncUserFacingError.message(for: error)) + } + + guard let authorizeUrl = URL(string: session.authorizeUrl) else { + return .failed("Linear returned an invalid sign-in link.") + } + + let callback: URL + do { + callback = try await presentWebAuth(url: authorizeUrl) + } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { + return .canceled + } catch { + return .failed(SyncUserFacingError.message(for: error)) + } + + let params = LinearOAuthCallback(url: callback) + if let errorMessage = params.errorMessage { + return .failed(errorMessage) + } + guard let code = params.code, !code.isEmpty, + let state = params.state, !state.isEmpty else { + return .failed("Linear sign-in didn\u{2019}t return an authorization code.") + } + + do { + let status = try await sync.completeLinearMobileOAuth( + sessionId: session.sessionId, + code: code, + state: state + ) + // A failed exchange can still come back `connected: true` when a prior + // token survives (the desktop preserves it rather than wiping the UI) — + // but with the failure reason in `message`. Treat any non-empty message + // as a failure so a reconnect that didn't actually re-auth never shows a + // success confirmation. A genuine success carries no message. + let failureMessage = status.message?.trimmingCharacters(in: .whitespacesAndNewlines) + if let failureMessage, !failureMessage.isEmpty { + return .failed(failureMessage) + } + if status.connected { + return .connected(status) + } + return .failed("Couldn\u{2019}t finish connecting to Linear.") + } catch { + return .failed(SyncUserFacingError.message(for: error)) + } + } + + private func presentWebAuth(url: URL) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + let webSession = ASWebAuthenticationSession( + url: url, + callbackURLScheme: "ade" + ) { callbackURL, error in + if let error { + continuation.resume(throwing: error) + } else if let callbackURL { + continuation.resume(returning: callbackURL) + } else { + continuation.resume(throwing: ASWebAuthenticationSessionError(.canceledLogin)) + } + } + // Reuse an existing Linear login when present rather than an ephemeral + // session, so the user often lands straight on the authorize screen. + webSession.prefersEphemeralWebBrowserSession = false + webSession.presentationContextProvider = self + self.webSession = webSession + if !webSession.start() { + continuation.resume(throwing: ASWebAuthenticationSessionError(.canceledLogin)) + } + } + } + + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + MainActor.assumeIsolated { + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first + let window = scene?.keyWindow ?? scene?.windows.first + return window ?? ASPresentationAnchor() + } + } +} + +/// Parses the `ade://linear-oauth?...` callback the Worker bounces back. +private struct LinearOAuthCallback { + let code: String? + let state: String? + let error: String? + let errorDescription: String? + + init(url: URL) { + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + func value(_ name: String) -> String? { + // URLComponents already percent-decodes query-item values; decoding again + // would corrupt an opaque code/state that contains a literal %XX sequence. + items.first(where: { $0.name == name })?.value + } + code = value("code") + state = value("state") + error = value("error") + errorDescription = value("error_description") + } + + /// A user-facing message when Linear reported an error, else nil. + var errorMessage: String? { + guard let error, !error.isEmpty else { return nil } + if let description = errorDescription, !description.isEmpty { + return description + } + return "Linear sign-in failed (\(error))." + } +} diff --git a/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift b/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift index d3d1a1d87..42a2fc1e9 100644 --- a/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift +++ b/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift @@ -8,6 +8,9 @@ struct LinearPaneSheet: View { @StateObject private var store: LinearPaneStore @State private var path: [LinearRoute] = [] @State private var started = false + /// Brief "Connected to " confirmation shown on the empty state before the + /// store reload flips the pane into the issue list. + @State private var justConnectedOrg: String? init(syncService: SyncService) { _store = StateObject(wrappedValue: LinearPaneStore(sync: syncService)) @@ -32,6 +35,8 @@ struct LinearPaneSheet: View { LinearIssueDetailScreen(issue: issue, hasLane: store.attachedIssueIds.contains(issue.id)) case let .launch(issue, laneOnly): LinearLaunchScreen(issue: issue, laneOnly: laneOnly) + case .connection: + LinearConnectionScreen(store: store) } } } @@ -48,18 +53,47 @@ struct LinearPaneSheet: View { } } + /// Machine name for connect copy — the connected host's display name when + /// known, else a neutral "your Mac". + private var machineName: String { syncService.machineDisplayName } + private var connectPrompt: some View { - ADEEmptyStateView( - symbol: "link.badge.plus", - title: "Linear isn\u{2019}t connected", - message: "Connect Linear from ADE on your machine (Settings \u{203A} Integrations) to browse and launch issues here." - ) { - Button("Close", action: close) - .buttonStyle(.glassProminent) - .tint(LinearBrand.primary) + ScrollView { + VStack(spacing: 20) { + VStack(spacing: 12) { + LinearMark(size: 40) + .foregroundStyle(LinearBrand.primaryBright) + VStack(spacing: 6) { + Text("Connect Linear") + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Sign in to browse and launch Linear issues from \(machineName). The token stays on your machine.") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity) + + LinearConnectActions(store: store) { status in + ADEHaptics.success() + justConnectedOrg = status.organizationName + } + .adeGlassCard(cornerRadius: 20, padding: 20) + + if let justConnectedOrg { + Label("Connected to \(justConnectedOrg)", systemImage: "checkmark.circle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.success) + .transition(.opacity) + } + } + .padding(24) + .frame(maxWidth: .infinity) } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .scrollContentBackground(.hidden) + .background(ADEColor.pageBackground) .navigationTitle("Linear") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -69,6 +103,12 @@ struct LinearPaneSheet: View { } .accessibilityLabel("Close Linear") } + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: LinearRoute.connection) { + Image(systemName: "gearshape") + } + .accessibilityLabel("Linear connection settings") + } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ced1586dc..31be11a5c 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -4998,6 +4998,83 @@ final class ADETests: XCTestCase { XCTAssertTrue(analyticsOptOutAcknowledged) } + @MainActor + func testLegacyHostWithoutLinearCommandsGatesMobileLinearActions() throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + defer { UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) } + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-1", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + "commandRouting": [ + "mode": "allowlisted", + "actions": [[ + "action": "chat.send", + "policy": ["viewerAllowed": true, "queueable": true], + ]], + ], + ], + ]) + + XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.hostCompatibilityMode, .limited) + XCTAssertFalse(service.supportsRemoteAction("cto.startLinearMobileOAuth")) + XCTAssertFalse(service.supportsRemoteAction("cto.setLinearToken")) + XCTAssertFalse(service.supportsRemoteAction("cto.clearLinearToken")) + } + + @MainActor + func testHostAdvertisingLinearCommandsEnablesMobileLinearActions() throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + defer { UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) } + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + + let linearActions = [ + "cto.startLinearMobileOAuth", + "cto.completeLinearMobileOAuth", + "cto.setLinearToken", + "cto.clearLinearToken", + ] + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-1", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + "commandRouting": [ + "mode": "allowlisted", + "actions": linearActions.map { action in + [ + "action": action, + "policy": ["viewerAllowed": true, "queueable": false], + ] + }, + ], + "mobileCompatibility": [ + "contractVersion": 1, + "mode": "full", + "requiredActions": [], + "missingActions": [], + ], + ], + ]) + + XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.hostCompatibilityMode, .full) + XCTAssertTrue(service.supportsRemoteAction("cto.startLinearMobileOAuth")) + XCTAssertTrue(service.supportsRemoteAction("cto.completeLinearMobileOAuth")) + XCTAssertTrue(service.supportsRemoteAction("cto.setLinearToken")) + XCTAssertTrue(service.supportsRemoteAction("cto.clearLinearToken")) + } + @MainActor func testSyncServiceReadsExplicitFullMobileCompatibilityFromHello() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 16dcea100..ca7391238 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -2017,6 +2017,36 @@ async function handleLinearOrganizationRegister(request: Request, env: RelayEnv) return json({ organizationId: auth.organizationId }); } +/** + * Bounces Linear's OAuth redirect (an https URL Linear accepts) to the ADE app's + * custom scheme so `ASWebAuthenticationSession` can capture it. Stateless: the + * PKCE `state` is validated on the desktop, never here. The authorization `code` + * is PKCE-bound and useless in transit, but MUST NOT be logged regardless. + */ +function handleLinearOAuthCallback(request: Request): Response { + if (request.method !== "GET") return text("method not allowed", 405); + const params = new URL(request.url).searchParams; + const callback = new URLSearchParams(); + const error = params.get("error"); + if (error) { + callback.set("error", error); + const description = params.get("error_description"); + if (description) callback.set("error_description", description); + } else { + callback.set("code", params.get("code") ?? ""); + } + callback.set("state", params.get("state") ?? ""); + // URLSearchParams serializes spaces as "+", but the iOS callback parser reads + // the custom-scheme URL with URLComponents, which does NOT turn "+" back into + // a space — so an error like "User declined" would render as "User+declined". + // Emit %20 for spaces to keep Linear's user-facing error text readable. + const query = callback.toString().replace(/\+/g, "%20"); + return new Response(null, { + status: 302, + headers: { location: `ade://linear-oauth?${query}` }, + }); +} + async function pruneOldLinearEvents(env: RelayEnv): Promise { const days = Number(env.EVENT_RETENTION_DAYS ?? DEFAULT_RETENTION_DAYS); const retentionDays = Number.isFinite(days) ? Math.max(1, Math.trunc(days)) : DEFAULT_RETENTION_DAYS; @@ -2290,6 +2320,7 @@ export async function handleRequest(request: Request, env: RelayEnv): Promise { + it("302-bounces a success code/state to the app scheme", async () => { + const response = await handleRequest( + new Request("https://relay.example/linear/oauth/callback?code=abc123&state=xyz789"), + env, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("ade://linear-oauth?code=abc123&state=xyz789"); + }); + + it("URL-encodes code and state values", async () => { + const response = await handleRequest( + new Request("https://relay.example/linear/oauth/callback?code=a%2Fb%20c&state=s%2Bt"), + env, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("ade://linear-oauth?code=a%2Fb%20c&state=s%2Bt"); + }); + + it("emits %20 (not +) for spaces so iOS URLComponents renders readable text", async () => { + const response = await handleRequest( + new Request( + "https://relay.example/linear/oauth/callback?error=access_denied&error_description=User%20declined%20access&state=s1", + ), + env, + ); + const location = response.headers.get("location") ?? ""; + // iOS reads this with URLComponents, which leaves "+" intact, so spaces must + // be %20 or the connect screen would show "User+declined+access". + expect(location).toContain("error_description=User%20declined%20access"); + expect(location).not.toContain("+"); + }); + + it("bounces an error through without a code", async () => { + const response = await handleRequest( + new Request( + "https://relay.example/linear/oauth/callback?error=access_denied&error_description=User%20declined&state=xyz789", + ), + env, + ); + expect(response.status).toBe(302); + const location = response.headers.get("location") ?? ""; + expect(location.startsWith("ade://linear-oauth?")).toBe(true); + const params = new URLSearchParams(location.slice("ade://linear-oauth?".length)); + expect(params.get("error")).toBe("access_denied"); + expect(params.get("error_description")).toBe("User declined"); + expect(params.get("state")).toBe("xyz789"); + expect(params.has("code")).toBe(false); + }); + + it("rejects non-GET methods", async () => { + const response = await handleRequest( + new Request("https://relay.example/linear/oauth/callback", { method: "POST" }), + env, + ); + expect(response.status).toBe(405); + }); +}); diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index d2581e19b..e1d8f70a0 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -124,7 +124,8 @@ Registered by `registerCtoRemoteCommands` in `apps/ade-cli/src/services/sync/syn - `cto.ensureSession`, `cto.getState`, `cto.updateIdentity`. - `cto.getMemory` — returns the `CtoMemorySnapshot` (durable memory + thread state + today's daily log) the iOS Memory card decodes. -- `cto.getLinearConnectionStatus`, `cto.getLinearQuickView`, `cto.getLinearIssuePickerData`, `cto.searchLinearIssues`, `cto.getLinearIssueComments`. +- `cto.getLinearConnectionStatus`, `cto.getLinearQuickView`, `cto.getLinearIssuePickerData`, `cto.searchLinearIssues`, `cto.getLinearIssueComments` — the Linear read surface. +- `cto.startLinearMobileOAuth`, `cto.completeLinearMobileOAuth`, `cto.setLinearToken`, `cto.clearLinearToken` — the Linear **connection-management** surface the iOS Linear pane uses to connect (worker-bounce OAuth or API key), reconnect, and disconnect. All four are `viewerAllowed` and advertised as **optional** mobile capabilities (`MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS` in `syncMobileCompatibility.ts`), so older brains omit them and the phone gates the affordances locally. See [Linear integration](../linear-integration/README.md#connecting-and-managing-from-mobile). The legacy `cto.getBudgetSnapshot` and `cto.runLinearSyncNow` commands were removed. diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index 2db136dc5..910c06770 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -12,7 +12,7 @@ The Linear services live under the `cto/` service directory as shared plumbing; - `linearAppClient.ts` — the ADE Linear OAuth app constants: the bundled public `ADE_LINEAR_APP_CLIENT_ID` and the `LinearOAuthClientSource` (`"ade-app" | "custom"`) type. This app is the default sign-in client; its authorization auto-provisions the workspace webhook the automations Linear ingress consumes. - `linearCredentialService.ts` — personal API key + OAuth client + auth-mode storage in the active project's `.ade/secrets`, with `ensureFreshToken()` for automatic OAuth refresh. `getOAuthClientCredentials()` falls back to the bundled ADE app client (secretless) when no custom client is configured, and `getOAuthClientSource()` reports which is in effect. -- `linearOAuthService.ts` / `linearOAuthRefreshLock.ts` / `linearTokenRefresh.ts` — PKCE loopback OAuth flow (port 19836), the cross-process refresh lock, and the token-refresh exchange. The authorize URL requests `read,write,admin` for the ADE app client and `read,write` for a custom client. +- `linearOAuthService.ts` / `linearOAuthRefreshLock.ts` / `linearTokenRefresh.ts` — the OAuth flows, the cross-process refresh lock, and the token-refresh exchange. Two authorize paths share the same PKCE code exchange and token storage: `startSession`/`getSession` run the desktop **loopback** flow (ephemeral server on port 19836), while `startExternalSession`/`completeExternalSession` run the **worker-bounce** flow used by mobile — no loopback server; the redirect target is `LINEAR_MOBILE_OAUTH_REDIRECT_URI` (the `ade-github-webhook-relay` Cloudflare worker's `/linear/oauth/callback`, which 302-bounces to `ade://linear-oauth`). In both flows the PKCE verifier and the resulting token stay desktop-side. The authorize URL requests `read,write,admin` for the ADE app client and `read,write` for a custom client. - `linearClient.ts` — the GraphQL client shared by desktop and the headless ADE CLI (reads plus the lightweight `updateIssueState` / `updateIssueAssignee` / `createComment` / `addIssueLabel` writes, and the `listWebhooks` / `createWebhook` / `deleteWebhook` methods the automations Linear ingress uses to manage a per-workspace webhook). - `linearIssueTracker.ts` / `issueTracker.ts` — normalization into `NormalizedLinearIssue` and the read shims + write helpers renderer/CLI surfaces call through. - `linearGraphQLInput.ts` — GraphQL input builders shared by client and tracker. @@ -23,9 +23,15 @@ The Linear services live under the `cto/` service directory as shared plumbing; - `renderer/components/app/LinearQuickViewButton.tsx`, `LinearIssueBrowser.tsx`, `LinearIssueSelectModal.tsx`, `LinearIssueResolveModals.tsx` — the top-bar quick view, the filter/search browser, and the single-issue select/resolve dialogs. - `renderer/components/app/BatchLaunchModal.tsx`, `BatchLaunchStatusToast.tsx`, `renderer/components/shared/SessionLaunchModelControls.tsx`, and `renderer/lib/linearBatchLaunch.ts` — multi-select batch launch: per-issue configuration, the single canonical model-picker Fast control, bounded-parallel create-lane → session → kickoff, and lifecycle-backed readiness/attention status. -- `renderer/components/settings/LinearSection.tsx` — Settings → Integrations connect/disconnect panel. +- `renderer/components/settings/LinearSection.tsx` — Settings → Integrations connect/disconnect panel; renders the connected workspace's org logo alongside its name. - `renderer/components/lanes/LinearIssueBadge.tsx` (+ `linearBrand.tsx`, `linearIssueDisplay.ts`, `linearProjectIcon.tsx`) — the lane-list badge and brand/display helpers. +### iOS Linear surface (`apps/ios/ADE/Views/Linear/`) + +- `LinearPaneSheet.swift`, `LinearIssueListScreen.swift`, `LinearIssueDetailScreen.swift`, `LinearLaunchScreen.swift` / `LinearLaunchModel.swift`, `LinearPaneStore.swift`, `LinearPaneToolbarButton.swift`, `LinearBrand.swift` / `LinearSVGPath.swift` — the Work top-bar Linear pane: grouped issue browser, detail view, lane/agent launcher, pane store, and brand/logo assets. Reads and launches go over the existing `cto.*` read RPCs and the existing lane/chat/CLI launch primitives. +- `LinearConnectionScreen.swift` — the connection-management screen pushed from the pane's gear button. Shows the connected workspace (org logo + name + viewer + auth-mode/expiry) with **Reconnect** and **Disconnect**; when disconnected it hosts the same inline connect affordances as the pane's empty state, so the gear is never a dead end. `LinearConnectActions` (in the same file) is the shared connect UI — "Sign in with Linear" (worker-bounce OAuth) plus an expandable "Use an API key" form — reused by both the connection screen and the disconnected pane. `LinearOrgAvatar` renders the workspace logo with a monogram fallback. +- `LinearOAuthRunner.swift` — drives the phone side of worker-bounce OAuth: calls `cto.startLinearMobileOAuth` (desktop mints the PKCE session), opens the `authorizeUrl` in an `ASWebAuthenticationSession(callbackURLScheme:"ade")`, captures `ade://linear-oauth?code&state` in-session (so it never reaches `DeepLinkRouter`, which ignores the `linear-oauth` host), then calls `cto.completeLinearMobileOAuth` for the desktop-side exchange + store. Each connect surface (screen and disconnected pane) owns its own runner; every capability is feature-gated on `supportsRemoteAction(...)` so the affordance is hidden on older brains that don't advertise the command. + ### Shared and CLI - `apps/desktop/src/shared/linearMagicWords.ts` — `Refs`/`Fixes` commit and PR magic-word injection (`ensureLinearCommitReference`, `ensureLinearPrReference`, `buildLinearPrTitle`, multi-issue linkage block). @@ -44,6 +50,15 @@ Credentials are owned by `apps/desktop/src/main/services/cto/linearCredentialSer Until a token is stored, nothing binds and no background work runs — connecting is a deliberate act of storing a token. +### Connecting and managing from mobile + +The phone can connect, reconnect, and disconnect Linear against its paired Mac without a loopback server. Both mobile paths store the token **desktop-side** and are exposed as four `viewerAllowed` sync commands (`cto.startLinearMobileOAuth`, `cto.completeLinearMobileOAuth`, `cto.setLinearToken`, `cto.clearLinearToken`): + +- **Worker-bounce OAuth.** `cto.startLinearMobileOAuth` has the desktop mint a PKCE session (`linearOAuthService.startExternalSession`) whose `redirect_uri` is the `ade-github-webhook-relay` Cloudflare worker's `/linear/oauth/callback`. The phone opens the returned `authorizeUrl` in an `ASWebAuthenticationSession`; after the user authorizes, Linear redirects to the worker, which 302-bounces to `ade://linear-oauth?code&state`. The web-auth session (scheme `ade`) captures that callback in-process, so the phone hands `code` + `state` back through `cto.completeLinearMobileOAuth`, and the desktop performs the token exchange (`completeExternalSession`) and stores it. The PKCE verifier and the token never leave the Mac, so the authorization code is useless in transit. +- **API key.** `cto.setLinearToken` forwards a pasted personal API key to the desktop `linearCredentialService`; `cto.clearLinearToken` disconnects the workspace for the whole machine. + +Because the four commands are advertised as optional capabilities, the iOS UI gates each affordance on `supportsRemoteAction(...)` — an older brain that never advertises them simply shows no connect/reconnect/disconnect buttons (with a short "update ADE on your Mac" hint) instead of erroring. + ## Read surface - `linearClient.ts` — the GraphQL client (shared by desktop and the headless ADE CLI). Reads: `fetchIssueById`, `listProjects`, `searchIssues` (paginated), `getQuickView` (workspace + active-project counters), `fetchIssueComments`, `listLabels`, `listUsers`. The shared issue fragment also carries cycle metadata, label colors, and child-issue fields. @@ -60,10 +75,10 @@ Renderer surfaces over these reads: Mobile surfaces over the same reads: -- `apps/ios/ADE/Views/Linear/` — the Work top-bar Linear pane: grouped issue browser, detail view with description/comments/sub-issues, and a launcher for "New lane" or "Launch agent". The pane is UI orchestration only; it reuses the existing `cto.*` read RPCs and the existing lane/chat/CLI launch primitives exposed through sync. -- `apps/ios/ADE/App/DeepLinkRouter.swift` — routes `ade://linear-issue/` and the matching `https://ade-app.dev/open?type=linear-issue` handoff into the mobile pane when a project is open, otherwise bouncing the link to the paired Mac. +- `apps/ios/ADE/Views/Linear/` — the Work top-bar Linear pane: grouped issue browser, detail view with description/comments/sub-issues, and a launcher for "New lane" or "Launch agent". The pane is UI orchestration only; it reuses the existing `cto.*` read RPCs and the existing lane/chat/CLI launch primitives exposed through sync. The pane's gear opens `LinearConnectionScreen` for **managing** the connection from the phone — connect (worker-bounce OAuth or API key), reconnect, and disconnect — and the disconnected pane surfaces the same connect actions inline. These write paths drive the four `cto.*` connection commands below and are feature-gated so they simply don't appear against an older brain that doesn't advertise them. +- `apps/ios/ADE/App/DeepLinkRouter.swift` — routes `ade://linear-issue/` and the matching `https://ade-app.dev/open?type=linear-issue` handoff into the mobile pane when a project is open, otherwise bouncing the link to the paired Mac. It deliberately ignores the `linear-oauth` host: the OAuth callback is captured inside the `ASWebAuthenticationSession`, never as an app deeplink. -IPC (named in `apps/desktop/src/shared/ipc.ts`, registered in `registerIpc.ts`, reached via `window.ade.cto.*`): `ctoGetLinearConnectionStatus`, `ctoSetLinearToken`, `ctoClearLinearToken`, `ctoStartLinearOAuth`, `ctoGetLinearOAuthSession`, `ctoSetLinearOAuthClient`, `ctoClearLinearOAuthClient`, `ctoGetLinearProjects`, `ctoGetLinearQuickView`, `ctoGetLinearIssuePickerData`, `ctoSearchLinearIssues`, `ctoGetLinearIssueComments`. The mobile client drives the read subset through `cto.*` sync commands (see [`../cto/README.md`](../cto/README.md#sync-command-surface)). +IPC (named in `apps/desktop/src/shared/ipc.ts`, registered in `registerIpc.ts`, reached via `window.ade.cto.*`): `ctoGetLinearConnectionStatus`, `ctoSetLinearToken`, `ctoClearLinearToken`, `ctoStartLinearOAuth`, `ctoGetLinearOAuthSession`, `ctoSetLinearOAuthClient`, `ctoClearLinearOAuthClient`, `ctoGetLinearProjects`, `ctoGetLinearQuickView`, `ctoGetLinearIssuePickerData`, `ctoSearchLinearIssues`, `ctoGetLinearIssueComments`. The mobile client drives the read subset through `cto.*` sync commands, plus four **connection-management** sync commands — `cto.startLinearMobileOAuth`, `cto.completeLinearMobileOAuth`, `cto.setLinearToken`, `cto.clearLinearToken` — that let the phone connect, reconnect, and disconnect Linear (see [`../cto/README.md`](../cto/README.md#sync-command-surface) and [`../sync-and-multi-device/remote-commands.md`](../sync-and-multi-device/remote-commands.md)). All four are `viewerAllowed` and advertised as **optional** mobile capabilities, so older brains that omit them leave the phone's connect/manage affordances hidden rather than failing. ## Lane attachment, commit references, and PR magic words @@ -123,7 +138,7 @@ State lives in `.ade/ade.db` and replicates through cr-sqlite. Tables the live L ## Gotchas - **Dormant until connected.** Until a token is stored, nothing fires and no listener binds. Tests should stub `getStatus().tokenStored` accordingly. -- **OAuth is loopback-only.** Port 19836 must be free; the service does not pick alternatives. Collisions surface as a startup error in the panel. +- **Desktop OAuth is loopback-only.** The desktop sign-in flow needs port 19836 free; the service does not pick alternatives, and collisions surface as a startup error in the panel. The mobile worker-bounce flow does not use a loopback server (it relies on the relay worker + `ade://` callback), so it is unaffected by port 19836. - **Token storage is per-app, not per-project.** `LinearConnectionStatus.storageScope` is `app`; switching projects does not change which workspace is attached unless the token is rotated. - **Live status is off by default.** Nothing writes back to Linear on launch / PR / merge unless `ADE_LINEAR_LIVE_STATUS_ROUNDTRIP=1`; every hook short-circuits when the flag is unset. - **CRR strips non-PK uniqueness.** Linear tables don't rely on secondary UNIQUE constraints for upserts; use explicit select-then-update or the delete-then-insert pattern instead of `ON CONFLICT(some_unique_col)`. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 461d725e9..ad78ef9c8 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -104,6 +104,17 @@ queueing/sending them, and shows update guidance from the host state. When a new mobile release adds required host behavior, update the shared contract and the iOS compatibility tests in the same branch. +Alongside the required set the file keeps an **optional** list +(`MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS`) for additive commands newer +phones feature-detect but that older mobile builds never call — so their absence +must not put a host in `limited`. The four Linear connection commands +(`cto.startLinearMobileOAuth`, `cto.completeLinearMobileOAuth`, +`cto.setLinearToken`, `cto.clearLinearToken`) that let the phone connect, +reconnect, and disconnect Linear are optional: a brain that predates them simply +doesn't advertise them, and the iOS Linear pane hides those affordances locally +instead of erroring. See `remote-commands.md` and +`../linear-integration/README.md`. + ## What syncs, what does not | Data category | Sync mechanism | Devices | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 6b92b5523..dff02b942 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -323,6 +323,13 @@ apps/ios/ │ │ │ # launch config, brand/logo paths, pane store │ │ │ # and toolbar button. Uses existing cto.* read │ │ │ # RPCs plus lane/chat/CLI launch primitives. +│ │ │ # LinearConnectionScreen (gear → connect via +│ │ │ # OAuth/API key, reconnect, disconnect; +│ │ │ # shared LinearConnectActions on the +│ │ │ # disconnected pane) + +│ │ │ # LinearOAuthRunner (worker-bounce OAuth via +│ │ │ # ASWebAuthenticationSession, ade:// capture), +│ │ │ # all gated on supportsRemoteAction. │ │ ├── PRs/ # PrsRootScreen, PrDetailScreen │ │ │ # (PrDetailView — Overview emitted as │ │ │ # sibling List rows, not a monolith), diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 6946879b7..2cb67ea33 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -120,7 +120,8 @@ accordingly — the brain's policy and scope are always authoritative. Mobile compatibility is a separate product contract layered on top of those descriptors. The required iOS action set lives in -`apps/desktop/src/shared/syncMobileCompatibility.ts`; the brain evaluates the +`apps/desktop/src/shared/syncMobileCompatibility.ts` +(`MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS`); the brain evaluates the actual registry against that list and advertises `features.mobileCompatibility` in `hello_ok`. A missing action must produce `mode: "limited"` and a `missingActions` list, not a rejected connection. iOS @@ -129,6 +130,17 @@ surfaces update guidance. When adding, renaming, or removing a remote command that ADE Mobile uses, update the shared compatibility list and iOS tests in the same change. +The same file also holds `MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS`: additive +commands that newer phones may feature-detect from +`hello_ok.features.commandRouting.actions` but that must **not** join the +required set, because older mobile builds never call them. The four Linear +connection commands (`cto.startLinearMobileOAuth`, +`cto.completeLinearMobileOAuth`, `cto.setLinearToken`, `cto.clearLinearToken`) +live here: a brain that predates them omits them from its advertised actions, so +the phone leaves Linear connect/reconnect/disconnect hidden (falling back to the +API-key path or an "update ADE on your Mac" hint) rather than treating their +absence as a broken connection. Their omission never flips a host to `limited`. + ## Registry Commands are registered by calling `register(action, policy, handler, @@ -406,6 +418,18 @@ a boolean. — the Linear read surface. The former worker-management commands (`removeAgent`, `setAgentStatus`, `triggerAgentWakeup`, `rollbackAgentRevision`) were removed with the worker subsystem. +- `startLinearMobileOAuth`, `completeLinearMobileOAuth`, `setLinearToken`, + `clearLinearToken` — the Linear **connection-management** surface the iOS + Linear pane uses to connect, reconnect, and disconnect a workspace from the + phone. `startLinearMobileOAuth` mints a desktop PKCE session for the + worker-bounce OAuth flow (redirect through the `ade-github-webhook-relay` + worker back to `ade://linear-oauth`); `completeLinearMobileOAuth` hands the + captured `code`/`state` back for the desktop-side token exchange; + `setLinearToken` stores a pasted API key; `clearLinearToken` disconnects. All + four are `viewerAllowed` and are **optional** mobile capabilities (see the + compatibility note above) — older brains omit them, and iOS gates the connect + UI on the advertised action set. See + [Linear integration](../linear-integration/README.md#connecting-and-managing-from-mobile). The canonical list is typed as `SyncRemoteCommandAction` in `apps/desktop/src/shared/types/sync.ts`.