Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ import {
import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService";
import { createHeadlessLinearServices } from "./headlessLinearServices";
import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
import type { AccountAuthService } from "./services/account/accountAuthService";
import {
getSignedInAccountAccessToken,
type AccountAuthService,
} from "./services/account/accountAuthService";
import {
getSharedAccountAuthService,
registerAccountConfigProjectRoot,
Expand Down Expand Up @@ -692,6 +695,7 @@ export async function createAdeRuntime(args: {
projectRoots: () => [projectRoot],
logger,
});
const getAccountAccessToken = () => getSignedInAccountAccessToken(accountAuthService);
const onboardingService = createOnboardingService({
db,
logger,
Expand Down Expand Up @@ -1045,6 +1049,7 @@ export async function createAdeRuntime(args: {
openExternal: async () => {},
onGitHubStatusChanged: (status) =>
pushEvent("runtime", { type: "github_status_changed", event: status }),
getAccountAccessToken,
});
linearIssueTrackerRef = headlessLinearServices.linearIssueTracker;
githubServiceRef = headlessLinearServices.githubService as ReturnType<typeof createGithubService>;
Expand Down Expand Up @@ -1201,6 +1206,7 @@ export async function createAdeRuntime(args: {
onPrStateIngested: () => prPollingServiceForIngress?.poke(),
secretService: automationSecretService,
githubService: headlessLinearServices.githubService,
getAccountAccessToken,
listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []),
ingressCursorStore: createKvIngressCursorStore(db),
// 30s halves worst-case webhook latency. Each poll is one request to our
Expand All @@ -1221,6 +1227,7 @@ export async function createAdeRuntime(args: {
}),
getLinearClient: () => headlessLinearServices.linearClient,
getLinearAccessToken: createLinearAccessTokenGetter(headlessLinearServices.linearCredentialService),
getAccountAccessToken,
cursorStore: createKvIngressCursorStore(db),
hasEnabledLinearRules: () => automationService?.hasEnabledLinearRules() ?? false,
isAdeAppConnection: () => {
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ type HeadlessLinearDeps = {
conflictService: ReturnType<typeof createConflictService>;
openExternal?: (url: string) => Promise<void>;
onGitHubStatusChanged?: (status: HeadlessGitHubStatus) => void;
getAccountAccessToken?: () => Promise<string | null>;
};

type HeadlessLinearServices = {
Expand Down Expand Up @@ -438,6 +439,7 @@ export function createHeadlessGitHubService(
options: {
onStatusChanged?: (status: HeadlessGitHubStatus) => void;
githubRelaySecretReader?: GitHubRelaySecretReader | null;
getAccountAccessToken?: (() => Promise<string | null>) | null;
} = {},
): HeadlessGitHubService {
const credentialStore = new EncryptedFileCredentialStore();
Expand Down Expand Up @@ -997,11 +999,15 @@ export function createHeadlessGitHubService(
const name = args.name?.trim();
const repo = owner && name ? { owner, name } : detectGitHubRepo(projectRoot);
const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null);
const accountAccessToken = options.getAccountAccessToken
? await options.getAccountAccessToken().catch(() => null)
: null;
return fetchGitHubAppInstallationStatus({
repo,
secretReader: options.githubRelaySecretReader,
forceRefresh: args.forceRefresh === true,
githubAppUserToken,
accountAccessToken,
auditLog: appUserAuth.auditLog,
});
},
Expand Down Expand Up @@ -1933,6 +1939,7 @@ export function createHeadlessLinearServices(
{
onStatusChanged: args.onGitHubStatusChanged,
githubRelaySecretReader: (ref) => automationSecretService.getSecret(ref),
getAccountAccessToken: args.getAccountAccessToken,
},
);
const linearClient = createLinearClientImpl({
Expand Down
21 changes: 21 additions & 0 deletions apps/ade-cli/src/services/account/accountAuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ACCOUNT_SESSION_CREDENTIAL_KEY,
createAccountAuthService,
derivePkceChallenge,
getSignedInAccountAccessToken,
type AccountAuthService,
type AccountSessionRecord,
} from "./accountAuthService";
Expand Down Expand Up @@ -409,4 +410,24 @@ describe("AccountAuthService refresh and sign-out", () => {
expect(service.signOut()).toMatchObject({ signedIn: false, email: null });
expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull();
});

it("returns an account relay token only for a usable signed-in account", async () => {
const getAccessToken = vi.fn(async () => "clerk-access-token");
await expect(getSignedInAccountAccessToken({
getStatus: () => ({ signedIn: true, userId: "user_1", email: null, name: null, expiresAt: null }),
getAccessToken,
})).resolves.toBe("clerk-access-token");
await expect(getSignedInAccountAccessToken({
getStatus: () => ({ signedIn: false, userId: null, email: null, name: null, expiresAt: null }),
getAccessToken,
})).resolves.toBeNull();
expect(getAccessToken).toHaveBeenCalledTimes(1);
});

it("leaves legacy integration auth available when account token refresh fails", async () => {
await expect(getSignedInAccountAccessToken({
getStatus: () => ({ signedIn: true, userId: "user_1", email: null, name: null, expiresAt: null }),
getAccessToken: async () => { throw new Error("refresh failed"); },
})).resolves.toBeNull();
});
});
14 changes: 14 additions & 0 deletions apps/ade-cli/src/services/account/accountAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ export type AccountActionDomainService = {
getToken(): Promise<string>;
};

export async function getSignedInAccountAccessToken(
service: Pick<AccountAuthService, "getStatus" | "getAccessToken">,
): Promise<string | null> {
const status = service.getStatus();
if (!status.signedIn || !status.userId) return null;
try {
return (await service.getAccessToken()).trim() || null;
} catch {
// Relay account credentials are additive. An unavailable refresh must not
// block the independent GitHub, Linear, or ade_proj_ authorization path.
return null;
}
}

export const ACCOUNT_ACTION_NAMES = [
"startLogin",
"pollLogin",
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron";

Check warning on line 1 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'shell' is defined but never used. Allowed unused vars must match /^_/u
import { AsyncLocalStorage } from "node:async_hooks";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -140,6 +140,11 @@
} from "../../../ade-cli/src/jsonrpc";
import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout";
import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots";
import { getSignedInAccountAccessToken } from "../../../ade-cli/src/services/account/accountAuthService";
import {
getSharedAccountAuthService,
registerAccountConfigProjectRoot,
} from "../../../ade-cli/src/services/account/sharedAccountAuthService";
import { uninstallRuntimeService } from "../../../ade-cli/src/serviceManager";
import {
ElectronSafeStorageCredentialStore,
Expand Down Expand Up @@ -2069,6 +2074,12 @@
credentialStore: createDesktopCredentialStore(machineAdeLayout.secretsDir),
});
const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl"));
registerAccountConfigProjectRoot(projectRoot);
const accountAuthService = getSharedAccountAuthService({
projectRoots: () => [projectRoot],
logger,
});
const getAccountAccessToken = () => getSignedInAccountAccessToken(accountAuthService);
const diskPressureMonitor = createDiskPressureMonitor({
roots: [projectRoot, machineAdeLayout.adeDir],
logger,
Expand Down Expand Up @@ -2541,6 +2552,7 @@
appDataDir: app.getPath("userData"),
credentialStore: createDesktopCredentialStore(machineAdeLayout.secretsDir),
githubRelaySecretReader: (ref) => githubRelaySecretService?.getSecret(ref) ?? null,
getAccountAccessToken,
});

const projectScaffoldService = createProjectScaffoldService({
Expand Down Expand Up @@ -3073,7 +3085,7 @@
}
},
});
agentChatServiceRef = agentChatService;

Check warning on line 3088 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'agentChatServiceRef' is assigned a value but never used. Allowed unused vars must match /^_/u
laneTeardownDeps.agentChatService = {
countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId),
disposeForLane: (laneId) => agentChatService.disposeForLane(laneId),
Expand Down Expand Up @@ -3167,6 +3179,7 @@
onPrStateIngested: () => prPollingServiceRef?.poke(),
secretService: automationSecretService,
githubService,
getAccountAccessToken,
listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []),
ingressCursorStore: createKvIngressCursorStore(db),
// 30s halves worst-case webhook latency. Each poll is one request to
Expand All @@ -3190,6 +3203,7 @@
credentialStore: linearCredentialStore,
getLinearClient: () => linearClient,
getLinearAccessToken: createLinearAccessTokenGetter(linearCredentialService),
getAccountAccessToken,
cursorStore: createKvIngressCursorStore(db),
hasEnabledLinearRules: () => automationService?.hasEnabledLinearRules() ?? false,
isAdeAppConnection: () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe("automationIngressService", () => {
return null;
},
} as never,
getAccountAccessToken: vi.fn(async () => "clerk-account-token"),
listRules: () => [],
});

Expand All @@ -202,6 +203,8 @@ describe("automationIngressService", () => {
}),
}),
);
const headers = new Headers(fetchSpy.mock.calls[0]?.[1]?.headers);
expect(headers.get("x-ade-account-token")).toBeNull();
expect(ingestGithubWebhook).toHaveBeenCalledWith(expect.objectContaining({
eventName: "pull_request",
deliveryId: "delivery-2",
Expand Down Expand Up @@ -454,6 +457,46 @@ describe("automationIngressService", () => {
}));
});

it("polls the hosted repo relay with only the signed-in account credential", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
events: [],
nextCursor: null,
}), { headers: { "content-type": "application/json" } }));

service = createAutomationIngressService({
logger: makeLogger() as never,
automationService: {
updateIngressStatus: vi.fn(),
dispatchIngressTrigger: vi.fn(),
getIngressCursor: () => null,
setIngressCursor: vi.fn(),
getIngressStatus: () => ({}),
} as never,
secretService: { getSecret: () => null } as never,
githubService: {
detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })),
getAppUserTokenForRelay: vi.fn(async () => {
throw new Error("GitHub App user auth is absent on this machine.");
}),
},
getAccountAccessToken: vi.fn(async () => "clerk-account-token"),
listRules: () => [],
});

await service.pollNow();

expect(fetchSpy).toHaveBeenCalledWith(
"https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/arul28/ADE/events",
expect.objectContaining({
headers: expect.objectContaining({
"x-ade-account-token": "clerk-account-token",
}),
}),
);
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
expect(headers.authorization).toBeUndefined();
});

it("polls the hosted repo relay with a GitHub App user token", async () => {
const updates: Array<Record<string, unknown>> = [];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { AutomationSecretService } from "./automationSecretService";
import type { createPrService } from "../prs/prService";
import {
createGitHubRelayAuthAuditLog,
ACCOUNT_RELAY_TOKEN_HEADER,
gitHubRelayAuthorizationToken,
readGitHubRelayConfig,
resolveHostedGitHubRelayAuthToken,
Expand Down Expand Up @@ -52,6 +53,7 @@ type AutomationIngressServiceArgs = {
detectRepo: () => Promise<GitHubRepoRef | null> | GitHubRepoRef | null;
getAppUserTokenForRelay: () => Promise<string>;
} | null;
getAccountAccessToken?: () => Promise<string | null>;
listRules: () => AutomationRule[];
// Cursor persistence fallback. REQUIRED when automationService is null —
// without it the relay cursor would silently reset on every restart
Expand Down Expand Up @@ -496,9 +498,12 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg
const pollGithubRelay = async () => {
const config = buildGithubRelayConfig();
const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config);
const accountAccessToken = args.getAccountAccessToken
? await args.getAccountAccessToken().catch(() => null)
: null;
// Skip before the "polling" status write so the "disabled" + auth-error
// status reported at cooldown entry stays accurate for the whole window.
if (config.configured && !useLegacyProjectRoute && Date.now() < hostedAuthPendingUntilMs) return;
if (config.configured && !useLegacyProjectRoute && !accountAccessToken && Date.now() < hostedAuthPendingUntilMs) return;
updateGithubRelayStatus({
configured: config.configured,
apiBaseUrl: config.apiBaseUrl,
Expand Down Expand Up @@ -533,31 +538,39 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg
try {
githubAppUserToken = (await args.githubService?.getAppUserTokenForRelay()) ?? null;
} catch (error) {
hostedAuthPendingUntilMs = Date.now() + HOSTED_RELAY_AUTH_PENDING_RETRY_MS;
const message = error instanceof Error ? error.message : String(error);
if (!hostedAuthPendingLogged) {
hostedAuthPendingLogged = true;
args.logger.info("automations.github_relay_auth_pending", { error: message });
if (accountAccessToken) {
githubAppUserToken = null;
} else {
hostedAuthPendingUntilMs = Date.now() + HOSTED_RELAY_AUTH_PENDING_RETRY_MS;
const message = error instanceof Error ? error.message : String(error);
if (!hostedAuthPendingLogged) {
hostedAuthPendingLogged = true;
args.logger.info("automations.github_relay_auth_pending", { error: message });
}
updateGithubRelayStatus({
healthy: false,
status: "disabled",
lastPolledAt: new Date().toISOString(),
lastError: message,
});
return;
}
updateGithubRelayStatus({
healthy: false,
status: "disabled",
lastPolledAt: new Date().toISOString(),
lastError: message,
});
return;
}
}
const hostedAuth = useLegacyProjectRoute
? null
: resolveHostedGitHubRelayAuthToken({ githubAppUserToken });
if (hostedAuth && !hostedAuth.ok) {
if (hostedAuth && !hostedAuth.ok && !accountAccessToken) {
throw new Error(hostedAuth.error);
}
hostedAuthPendingUntilMs = 0;
hostedAuthPendingLogged = false;
const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.token ?? null;
if (!authToken) {
const authToken = useLegacyProjectRoute
? legacyAuthToken
: hostedAuth?.ok
? hostedAuth.token
: null;
if (!authToken && (!accountAccessToken || useLegacyProjectRoute)) {
throw new Error("GitHub auth is required for relay polling.");
}
if (hostedAuth?.ok && repo) {
Expand All @@ -581,7 +594,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg
{
headers: {
accept: "application/json",
authorization: `Bearer ${authToken}`,
...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
...(accountAccessToken && !useLegacyProjectRoute
? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken }
: {}),
},
signal: controller.signal,
}
Expand Down
Loading
Loading