Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Expand Up @@ -140,6 +140,11 @@ import {
} 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 @@ app.whenReady().then(async () => {
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 @@ app.whenReady().then(async () => {
appDataDir: app.getPath("userData"),
credentialStore: createDesktopCredentialStore(machineAdeLayout.secretsDir),
githubRelaySecretReader: (ref) => githubRelaySecretService?.getSecret(ref) ?? null,
getAccountAccessToken,
});

const projectScaffoldService = createProjectScaffoldService({
Expand Down Expand Up @@ -3167,6 +3179,7 @@ app.whenReady().then(async () => {
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 @@ app.whenReady().then(async () => {
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 @@ -454,6 +454,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,8 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg
{
headers: {
accept: "application/json",
authorization: `Bearer ${authToken}`,
...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
...(accountAccessToken ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } : {}),
Comment thread
arul28 marked this conversation as resolved.
Outdated
},
signal: controller.signal,
}
Expand Down
Loading