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
6 changes: 3 additions & 3 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,9 @@ ade --socket browser open http://localhost:5173 --new-tab --text
ade --socket update status --text
ade --socket update check --text
ade --socket update install --text
ade sync relay status --text # cloud relay: wss URL phones dial + on/off state
ade sync relay enable # route phones through the tunnel relay (headless brains have no Settings UI)
ade sync relay disable
ade sync relay status --text # cloud relay: wss URL phones dial + on/off state (on by default)
ade sync relay enable # re-enable after a disable (relay is on by default; headless brains have no Settings UI)
ade sync relay disable # kill-switch: never route sync through the relay
ade sync security status --text # machine sync security posture (require-DPoP)
ade sync security require-dpop on # reject paired hellos from devices without a Secure Enclave key
ade secrets list --text
Expand Down
54 changes: 34 additions & 20 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import { createPushRegistrationStore } from "./services/push/pushRegistrationStore";
import { createPushRelayClient } from "./services/push/pushRelayClient";
import { getSharedPushPublisherService, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService";
import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService";
import type { createFileService } from "../../desktop/src/main/services/files/fileService";
import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types";
import type { PrEventPayload } from "../../desktop/src/shared/types/prs";
Expand Down Expand Up @@ -1178,7 +1178,7 @@ export async function createAdeRuntime(args: {
// push-identity file), so a run in one project doesn't clobber the phone's
// single "agent-runs" Live Activity for another. Each scope wires its own
// chat/pty/PR signals via attachSources; the aggregate merges runs across all.
const pushRelayFilePath = path.join(resolveMachineAdeLayout().secretsDir, "push-relay.json");
const pushRelayFilePath = resolvePushRelayStateFile(resolveMachineAdeLayout().secretsDir);
const pushPublisherService = getSharedPushPublisherService(pushRelayFilePath, () => {
const store = createPushRegistrationStore({ filePath: pushRelayFilePath });
return {
Expand Down Expand Up @@ -1223,24 +1223,33 @@ export async function createAdeRuntime(args: {
projectConfigService,
usageTrackingService,
});
// Cloud tunnel relay (phone → Cloudflare DO → this brain). Off by default;
// the Settings toggle flips the shared store and the client follows. The
// store instance is shared with the sync service so the relay candidate in
// pairingConnectInfo and the tunnel client always agree on one config file.
// Cloud tunnel relay (phone → Cloudflare DO → this brain). On by default
// the Settings kill-switch flips the shared store and the client follows.
// The store instance is shared with the sync service so the relay candidate
// in pairingConnectInfo and the tunnel client always agree on one config file.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { createSyncCloudRelayStore } = await import("./services/sync/syncCloudRelayStore");
const { createSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService");
const cloudRelayStore = createSyncCloudRelayStore({
filePath: path.join(
resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir,
"sync-cloud-relay.json",
),
});
const syncTunnelClientService = createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
});
if (cloudRelayStore.isEnabled()) {
const { createSyncTunnelClientService, getSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService");
const cloudRelayFilePath = path.join(
resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir,
"sync-cloud-relay.json",
);
const cloudRelayStore = createSyncCloudRelayStore({ filePath: cloudRelayFilePath });
// ONE tunnel client per machine (keyed by the config file): per-scope
// instances would re-register the same machineKey with the relay on every
// project open and churn the connection paired phones dial through.
const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () =>
createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
}));
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
// host socket per machineKey (last wins), so a headless one-shot CLI
// runtime or embedded fallback starting the tunnel would steal the relay
// from `ade serve` and then fail every phone /connect (no sync port).
const canHostRelayTunnel = resolvedArgs.syncRuntime?.sharedSyncListener != null;
if (canHostRelayTunnel && cloudRelayStore.isEnabled()) {
Comment on lines +1231 to +1252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== bootstrap slice ==\n'
sed -n '1200,1335p' apps/ade-cli/src/bootstrap.ts

printf '\n== syncTunnelClientService file map ==\n'
ast-grep outline apps/ade-cli/src/services/sync/syncTunnelClientService.ts --view expanded

printf '\n== syncTunnelClientService source ==\n'
cat -n apps/ade-cli/src/services/sync/syncTunnelClientService.ts | sed -n '1,260p'

printf '\n== search shared tunnel helpers usages ==\n'
rg -n "getSharedSyncTunnelClientService|peekSharedSyncTunnelClientService|createSyncTunnelClientService|syncTunnelClientService" apps/ade-cli/src -g '!**/dist/**'

Repository: arul28/ADE

Length of output: 19869


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== syncTunnelClientService tail ==\n'
sed -n '255,380p' apps/ade-cli/src/services/sync/syncTunnelClientService.ts

printf '\n== cli.ts usage around peekSharedSyncTunnelClientService ==\n'
sed -n '13545,13605p' apps/ade-cli/src/cli.ts

printf '\n== search for syncRuntime/sharedSyncListener in bootstrap and cli ==\n'
rg -n "syncRuntime|sharedSyncListener|phonePairingStateDir|initializeInBackground|runtimeKind" apps/ade-cli/src/bootstrap.ts apps/ade-cli/src/cli.ts

Repository: arul28/ADE

Length of output: 9576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== bootstrap call sites ==\n'
rg -n "bootstrap\(" apps/ade-cli/src

printf '\n== serve/runtime selection around syncRuntime ==\n'
rg -n "sharedSyncListener|initializeInBackground|runtimeKind|headless|desktop" apps/ade-cli/src/cli.ts apps/ade-cli/src/bootstrap.ts

Repository: arul28/ADE

Length of output: 176


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== bootstrap file outline ==\n'
ast-grep outline apps/ade-cli/src/bootstrap.ts --view expanded | sed -n '1,220p'

printf '\n== top of bootstrap.ts ==\n'
sed -n '1,140p' apps/ade-cli/src/bootstrap.ts

Repository: arul28/ADE

Length of output: 10847


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1330,1395p' apps/ade-cli/src/bootstrap.ts

Repository: arul28/ADE

Length of output: 1845


Move shared tunnel creation behind canHostRelayTunnel. getSharedSyncTunnelClientService(...) runs unconditionally, so a non-host runtime can cache a client whose getSyncPort() is permanently null; a later host then reuses that singleton and brings up the relay without a sync port. Keep the disable path on peekSharedSyncTunnelClientService(...) so an existing host can still be stopped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ade-cli/src/bootstrap.ts` around lines 1231 - 1252, Move the shared
tunnel client setup in bootstrap behind the canHostRelayTunnel check so only the
runtime with sharedSyncListener creates or reuses the singleton from
getSharedSyncTunnelClientService. Right now createSyncTunnelClientService can be
cached with a getSyncPort() that always returns null for non-host runtimes,
causing later hosts to reuse a bad client. Keep the existing disable/stop path
using peekSharedSyncTunnelClientService so an already-running host can still be
torn down cleanly.

Source: Coding guidelines

void syncTunnelClientService.start().catch((error) => {
logger.warn("sync.tunnel_start_failed", {
error: error instanceof Error ? error.message : String(error),
Expand Down Expand Up @@ -1294,6 +1303,9 @@ export async function createAdeRuntime(args: {
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
onCloudRelayEnabledChanged: (enabled) => {
// Same gate as startup: only the sync-hosting runtime may register
// the relay tunnel (see canHostRelayTunnel above).
if (enabled && !canHostRelayTunnel) return;
const action = enabled ? syncTunnelClientService.start() : syncTunnelClientService.stop();
void action.catch((error) => {
logger.warn("sync.tunnel_toggle_failed", {
Expand Down Expand Up @@ -1394,7 +1406,9 @@ export async function createAdeRuntime(args: {
swallow(() => prPollingService.dispose());
// Detach only this scope's signals; the shared publisher outlives the scope.
swallow(() => detachPushSources());
void syncTunnelClientService.dispose().catch(() => {});
// The tunnel client is machine-level and shared across scopes — closing
// one project must not sever the relay for the others. The daemon's
// shutdown path (disposeServeResources) stops it.
swallow(() => automationIngressService?.dispose());
swallow(() => automationService?.dispose());
swallow(() => usageTrackingService.dispose());
Expand Down
32 changes: 30 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11727,9 +11727,9 @@ Usage:
ade sync name <name> Name this runtime for easy identification
ade sync name get
ade sync name clear
ade sync relay status Cloud relay (phones dial this machine via a tunnel)
ade sync relay status ADE relay (phones dial this machine via a tunnel; on by default)
ade sync relay enable
ade sync relay disable
ade sync relay disable Kill-switch: never route sync through the relay
ade sync security status Machine sync security posture (require-DPoP)
ade sync security require-dpop <on|off>
`,
Expand Down Expand Up @@ -13131,6 +13131,7 @@ async function runServe(
{ resolveMobileProjectIconDataUrl },
{ createBrainProjectActionsSyncHandler },
{ buildRosterSnapshot, createForeignChatTranscriptResolver },
{ createSyncCloudRelayStore },
] = await Promise.all([
import("./services/projects/machineLayout"),
import("./services/projects/projectRegistry"),
Expand All @@ -13140,6 +13141,7 @@ async function runServe(
import("../../desktop/src/main/services/projects/projectIconThumbnail"),
import("./services/sync/brainProjectActionsSyncHandler"),
import("./services/sync/rosterBuilder"),
import("./services/sync/syncCloudRelayStore"),
]);

const layout = resolveMachineAdeLayout();
Expand Down Expand Up @@ -13473,6 +13475,11 @@ async function runServe(
const machineCredentialStore = new EncryptedFileCredentialStore({
secretsDir: layout.secretsDir,
});
// Same file the per-scope sync services read; another store instance is fine
// because every read reloads the file.
const machineCloudRelayStore = createSyncCloudRelayStore({
filePath: path.join(layout.secretsDir, "sync-cloud-relay.json"),
});
sharedSyncListener?.setFallbackConnectionHandler(
createBrainProjectActionsSyncHandler({
logger: headlessProjectLogger,
Expand All @@ -13483,6 +13490,8 @@ async function runServe(
pinPath: path.join(layout.secretsDir, "sync-pin.json"),
localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"),
localSiteIdPath: path.join(layout.secretsDir, "sync-site-id"),
getCloudRelayWssUrl: () =>
machineCloudRelayStore.isEnabled() ? machineCloudRelayStore.getRelayWssUrl() : null,
}),
);
scopeRegistry = new ProjectScopeRegistry(projectRegistry, {
Expand Down Expand Up @@ -13551,6 +13560,25 @@ async function runServe(
return null;
};
const disposeServeResources = async () => {
// Before scopes detach (which clears the run map): best-effort Live
// Activity `end` so the lock screen doesn't show dead agents until the
// stale-date dim. Bounded by the publisher's internal timeout.
try {
const { peekSharedPushPublisherService, resolvePushRelayStateFile } = await import("./services/push/pushPublisherService");
await peekSharedPushPublisherService(
resolvePushRelayStateFile(layout.secretsDir),
)?.shutdown();
} catch {
// Shutdown must never hang or fail on push cleanup.
}
try {
const { peekSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService");
await peekSharedSyncTunnelClientService(
path.join(layout.secretsDir, "sync-cloud-relay.json"),
)?.dispose();
} catch {
// Best-effort tunnel teardown; the process exit closes sockets anyway.
}
await scopeRegistry.disposeAll();
if (sharedSyncListener) {
await sharedSyncListener.close().catch(() => {});
Expand Down
171 changes: 168 additions & 3 deletions apps/ade-cli/src/services/push/pushPublisherService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { createHash, createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat";
import type { PushQuietHours } from "../../../../desktop/src/shared/types/push";
import { createPushRegistrationStore, type PushRegistrationStore } from "./pushRegistrationStore";
import { createPushRelayClient } from "./pushRelayClient";
import {
Expand All @@ -25,6 +26,7 @@ function run(overrides: Partial<AgentRunState>): AgentRunState {
agent: "Codex",
phase: "running",
detail: null,
itemId: null,
startedAt: 0,
lastActiveAt: 0,
metaResolved: true,
Expand Down Expand Up @@ -125,7 +127,7 @@ describe("createPushPublisherService flush", () => {
pushToStartToken: "b".repeat(64),
bundleId: "com.ade.ios",
apsEnvironment: "sandbox" as const,
prefs: { enabled: true, liveActivitiesEnabled: true, mutedSessionIds: [] as string[], quietHours: null },
prefs: { enabled: true, liveActivitiesEnabled: true, mutedSessionIds: [] as string[], quietHours: null as PushQuietHours | null },
updatedAt: "",
};

Expand Down Expand Up @@ -217,7 +219,162 @@ describe("createPushPublisherService flush", () => {
publisher.dispose();
});

it("suppresses a muted alert but still updates the Live Activity", async () => {
it("carries actionable fields: category + sessionId/itemId on the alert, itemId on the waiting LA row, badge count", async () => {
const { publisher, publish, emit } = makeHarness();
await publisher.start();

emit(approval);
await vi.advanceTimersByTimeAsync(200);

const payload = publish.mock.calls[0][0];
expect(payload.notifications[0].category).toBe("ADE_APPROVAL");
expect(payload.notifications[0].sessionId).toBe("s-1");
expect(payload.notifications[0].itemId).toBe("i-1");
expect(payload.notifications[0].badge).toBe(1);
const laRun = payload.liveActivity[0].contentState.runs[0];
expect(laRun.phase).toBe("waiting_for_approval");
expect(laRun.itemId).toBe("i-1");

// Resolution clears the pending item id from later content states.
emit({
sessionId: "s-1",
timestamp: "",
event: { type: "pending_input_resolved", itemId: "i-1", resolution: "accepted" },
});
await vi.advanceTimersByTimeAsync(2_500);
const lastPayload = publish.mock.calls.at(-1)?.[0];
const resolvedRun = lastPayload.liveActivity?.[0]?.contentState.runs[0];
expect(resolvedRun?.phase).toBe("running");
expect(resolvedRun?.itemId).toBeUndefined();

publisher.dispose();
});

it("badge-syncs devices whose alert was muted while the alert covers the rest", async () => {
const deviceB = { ...device, deviceId: "dev-2", prefs: { ...device.prefs, mutedSessionIds: ["s-1"] } };
const publish = vi.fn().mockResolvedValue({ ok: true });
const store = {
hasRegisteredDevices: () => true,
getStatusSnapshot: () => ({ enabled: true, claimed: true, registeredDeviceCount: 2, lastPublishAt: null, lastPublishError: null, lastRelayContactAt: null }),
listDevices: () => [device, deviceB],
getDevice: () => device,
recordPublishResult: vi.fn(),
recordRelayContact: vi.fn(),
};
const relayClient = { publish, health: vi.fn().mockResolvedValue({ ok: true, apnsConfigured: true }), baseUrl: "https://relay.test" };
let chatCb: ((env: AgentChatEventEnvelope) => void) | null = null;
const emit = (env: AgentChatEventEnvelope) => chatCb?.(env);
const publisher = createPushPublisherService({
logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() } as never,
store: store as never,
relayClient: relayClient as never,
machineName: "MacBook",
flushDebounceMs: 2_000,
promptFlushMs: 150,
});
publisher.attachSources("scope-1", {
agentChatService: {
subscribeToEvents: (cb: (env: AgentChatEventEnvelope) => void) => {
chatCb = cb;
return () => {};
},
getSessionSummary: vi.fn().mockResolvedValue(null),
} as never,
});
await publisher.start();

emit(approval);
await vi.advanceTimersByTimeAsync(200);

const payload = publish.mock.calls[0][0];
const alertItem = payload.notifications.find((item: { title: string }) => item.title.length > 0);
const badgeItem = payload.notifications.find((item: { title: string; badge?: number | null }) => item.title === "" && item.badge != null);
// The audible alert reaches only the unmuted device...
expect(alertItem.deviceIds).toEqual(["dev-1"]);
expect(alertItem.badge).toBe(1);
// ...while the muted device still gets the new badge count silently.
expect(badgeItem.deviceIds).toEqual(["dev-2"]);
expect(badgeItem.badge).toBe(1);

publisher.dispose();
});

it("suppresses the badge-only item for a device inside quiet hours", async () => {
// System time is 12:00 UTC (beforeEach); a 00:00→23:59 UTC window is active.
const quiet = {
...device,
prefs: { ...device.prefs, quietHours: { start: "00:00", end: "23:59", timezone: "UTC" } },
};
const { publisher, publish, emit } = makeHarness(quiet);
await publisher.start();

emit(approval);
await vi.advanceTimersByTimeAsync(200);

// Alert is quiet-hours-filtered AND no badge-only item may replace it —
// "mute pushes on a schedule" covers silent badge pushes too. Only the
// Live Activity (quiet-hours-exempt) goes out.
expect(publish).toHaveBeenCalledTimes(1);
const payload = publish.mock.calls[0][0];
expect(payload.notifications).toBeUndefined();
expect(payload.liveActivity[0].event).toBe("start");

publisher.dispose();
});

it("sends a silent badge-only item when the awaiting count drops with no alert", async () => {
const { publisher, publish, emit } = makeHarness();
await publisher.start();

emit(approval);
await vi.advanceTimersByTimeAsync(200);
expect(publish.mock.calls[0][0].notifications[0].badge).toBe(1);

emit({
sessionId: "s-1",
timestamp: "",
event: { type: "pending_input_resolved", itemId: "i-1", resolution: "accepted" },
});
await vi.advanceTimersByTimeAsync(2_500);
const lastPayload = publish.mock.calls.at(-1)?.[0];
const badgeItem = (lastPayload.notifications ?? []).find(
(item: { title: string; badge?: number | null }) => item.title === "" && item.badge != null,
);
expect(badgeItem).toMatchObject({ title: "", badge: 0, sound: null });

publisher.dispose();
});

it("publishes a best-effort Live Activity end on shutdown, marking active runs stale", async () => {
const { publisher, publish, emit } = makeHarness();
await publisher.start();

emit(approval);
await vi.advanceTimersByTimeAsync(200);
expect(publish).toHaveBeenCalledTimes(1);

await publisher.shutdown();
const lastPayload = publish.mock.calls.at(-1)?.[0];
expect(lastPayload.liveActivity[0].event).toBe("end");
expect(lastPayload.liveActivity[0].dismissalDate).toBe(
Math.floor(Date.parse("2026-07-05T12:00:00.000Z") / 1000) + 60,
);
expect(lastPayload.liveActivity[0].contentState.runs[0].phase).toBe("stale");

// Disposed: no further flush can fire.
emit(approval);
await vi.advanceTimersByTimeAsync(5_000);
expect(publish.mock.calls.at(-1)?.[0]).toBe(lastPayload);
});

it("shutdown is a no-op publish when no Live Activity start was committed", async () => {
const { publisher, publish } = makeHarness();
await publisher.start();
await publisher.shutdown();
expect(publish).not.toHaveBeenCalled();
});

it("suppresses a muted alert but still updates the Live Activity and badge", async () => {
const muted = { ...device, prefs: { ...device.prefs, mutedSessionIds: ["s-1"] } };
const { publisher, publish, emit } = makeHarness(muted);
await publisher.start();
Expand All @@ -227,7 +384,15 @@ describe("createPushPublisherService flush", () => {

expect(publish).toHaveBeenCalledTimes(1);
const payload = publish.mock.calls[0][0];
expect(payload.notifications).toBeUndefined();
// The muted alert itself is suppressed, but a muted session still awaits
// attention, so a silent badge-only item keeps the app icon honest.
expect(payload.notifications).toHaveLength(1);
expect(payload.notifications[0].title).toBe("");
expect(payload.notifications[0].badge).toBe(1);
expect(payload.notifications[0].sound).toBeNull();
// No relay dedupeKey: the suppression hash ignores deviceIds, so a shared
// key would starve other devices of the same count.
expect(payload.notifications[0].dedupeKey).toBeUndefined();
expect(payload.liveActivity[0].event).toBe("start");

publisher.dispose();
Expand Down
Loading
Loading