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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ affiliated with the OpenCode team or Anomaly.
- [Specification](docs/SPEC.md)
- [Deployment configuration](docs/CONFIGURATION.md)
- [Self-hosted push notifications](docs/NOTIFICATIONS.md)
- [Enable server push with an agent](docs/PUSH_AGENT_RUNBOOK.md)
- [Implementation TODO](TODO.md)

## Architecture
Expand All @@ -30,7 +31,8 @@ the signed-build workflow and pairing trust model.

The app supports saved connections, followed projects, session management,
paginated transcripts, text prompts, permission and form responses, encrypted
drafts, and optional self-hosted notifications. It stores Basic or Bearer
drafts, and optional self-hosted permission, form, and session-completion
notifications. It stores Basic or Bearer
credentials only in SecureStore. `TODO.md` tracks the remaining product and
device-verification work.

Expand Down
6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ networks or encrypted overlays such as Tailscale.

Notification pairing codes are two-minute bearer bootstrap secrets. Display a
code only in a trusted terminal, scan it immediately, and revoke unexpected
device registrations. Expo, APNs, and FCM receive generic text and encrypted
routing data, not OpenCode credentials.
device registrations. Expo, APNs, and FCM receive text selected from a finite
notification-category allowlist and encrypted routing data, not OpenCode
credentials. Raw permission actions, resources, paths, prompts, form titles,
session titles, errors, and identifiers are excluded from visible text.

OpenCode's built-in `/pair` QR is different: it contains the server's actual
Basic-auth password. Do not capture, log, or share it. The self-hosted broker's
Expand Down
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,8 @@ without exposing credentials or depending on undocumented endpoints.
- [ ] Add deep links for connection, project, session, file, and pending request.
- [ ] Add share-sheet input for text, images, and files.
- [ ] Add app quick actions for new session and recent sessions.
- [ ] Add local best-effort completion and blocked-work notifications.
- [ ] Add local best-effort completion and blocked-work notifications. Remote
successful completion is covered by self-hosted push.
- [x] Define and implement the self-hosted push architecture.
- [ ] Add privacy-mode app-switcher shielding.
- [x] Add optional biometric lock.
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const config: ExpoConfig = {
usesNonExemptEncryption: false,
},
infoPlist: {
NSUserNotificationUsageDescription: `${appName} notifies you when an OpenCode session needs a permission or form response.`,
NSUserNotificationUsageDescription: `${appName} notifies you when OpenCode needs a permission or form response, or finishes a session.`,
NSLocalNetworkUsageDescription: `${appName} connects to development servers that you approve on your private network.`,
NSAppTransportSecurity: {
NSAllowsArbitraryLoads: allowDevelopmentHttp,
Expand Down
32 changes: 25 additions & 7 deletions apps/mobile/src/notifications/notification-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,39 @@ test("posts a device command when Hermes does not provide AbortSignal.timeout",
value: undefined,
});
const fetch = jest.fn(async () => ({
json: async () => ({ ok: true, operation: "status" }),
json: async () => ({ enabled: true, updatedAtMs: 1_000, v: 1 }),
ok: true,
}));
globalThis.fetch = fetch as unknown as typeof globalThis.fetch;

await sendNotificationDeviceCommand({
bindingID: "binding-1",
brokerOrigin: "https://push.test",
deviceKey: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
operation: "status",
});
await expect(
sendNotificationDeviceCommand({
bindingID: "binding-1",
brokerOrigin: "https://push.test",
deviceKey: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
operation: "status",
}),
).resolves.toEqual({ enabled: true, updatedAtMs: 1_000, v: 1 });

expect(fetch).toHaveBeenCalledTimes(1);
});

test("rejects malformed shared notification state", async () => {
globalThis.fetch = jest.fn(async () => ({
json: async () => ({ enabled: "yes", updatedAtMs: 1_000, v: 1 }),
ok: true,
})) as unknown as typeof globalThis.fetch;

await expect(
sendNotificationDeviceCommand({
bindingID: "binding-1",
brokerOrigin: "https://push.test",
deviceKey: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
operation: "status",
}),
).rejects.toThrow("INVALID_NOTIFICATION_STATE");
});

test("prepares the first non-loopback URL from an OpenCode pair QR", () => {
expect(
prepareOpenCodeDevicePairing({
Expand Down
10 changes: 3 additions & 7 deletions apps/mobile/src/notifications/notification-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
notificationPushAdditionalData,
openNotificationJson,
parseNotificationConnectionBootstrap,
parseNotificationDeliveryState,
parseNotificationPairingCode,
parseNotificationPairingIssueRequest,
parseNotificationPairingResponse,
Expand Down Expand Up @@ -165,7 +166,7 @@ export async function sendNotificationDeviceCommand(input: {
operation: input.operation,
v: 1,
};
await postJson(
return postJson(
`${input.brokerOrigin}/v1/device/${input.operation}`,
{
bindingID: input.bindingID,
Expand All @@ -178,7 +179,7 @@ export async function sendNotificationDeviceCommand(input: {
nonce: encodeNotificationBytes(nonce),
v: 1,
},
parseOk,
parseNotificationDeliveryState,
);
}

Expand Down Expand Up @@ -235,11 +236,6 @@ function assertUsableBroker(code: NotificationPairingCode) {
}
}

function parseOk(value: unknown) {
if (!isRecord(value) || value.ok !== true) throw new Error("INVALID_BROKER_RESPONSE");
return value;
}

function isUnusableDeviceHost(hostname: string) {
const normalized = hostname
.toLowerCase()
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/notifications/notification-pairing-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,17 @@ export async function getNotificationPairingByBindingID(db: SQLiteDatabase, bind
return row ? decodePairing(row) : undefined;
}

export async function getNotificationPairingByConnectionID(
db: SQLiteDatabase,
connectionId: string,
) {
const row = await db.getFirstAsync<NotificationPairingRow>(
"SELECT * FROM notification_pairings WHERE connection_id = ?",
connectionId,
);
return row ? decodePairing(row) : undefined;
}

export async function readNotificationPairingSecret(pairing: NotificationPairing) {
if (!(await SecureStore.isAvailableAsync())) throw new Error("SECURE_STORE_UNAVAILABLE");
const value = await SecureStore.getItemAsync(pairing.secretRef, notificationSecretStoreOptions);
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/notifications/notification-routing-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, jest, test } from "@jest/globals";
import * as Notifications from "expo-notifications";
import "./notification-routing-context";

jest.mock("expo-notifications", () => ({
addNotificationResponseReceivedListener: jest.fn(),
clearLastNotificationResponseAsync: jest.fn(),
getLastNotificationResponseAsync: jest.fn(),
setNotificationHandler: jest.fn(),
}));

jest.mock("@opencode2-mobile/opencode-adapter", () => ({
getOpenCodeSession: jest.fn(),
listOpenCodeFormRequests: jest.fn(),
listOpenCodePermissionRequests: jest.fn(),
}));
jest.mock("../connections/connections-context", () => ({ useConnections: jest.fn() }));
jest.mock("../state/connection-runtime-context", () => ({ useConnectionRuntime: jest.fn() }));

test("suppresses notification presentation while the app is foregrounded", async () => {
const handler = jest.mocked(Notifications.setNotificationHandler).mock.calls[0]?.[0];

await expect(handler?.handleNotification({} as never)).resolves.toEqual({
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: false,
shouldShowList: false,
});
});
85 changes: 61 additions & 24 deletions apps/mobile/src/notifications/notification-routing-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,33 @@ import {

Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
shouldShowBanner: false,
shouldShowList: false,
}),
});

type PendingRoute = {
bindingID: string;
connectionId: string;
eventID: string;
expiresAtMs: number;
interaction: "form" | "permission";
location?: { directory: string; workspaceID?: string };
requestID: string;
sessionID: string;
};
type PendingRoute =
| {
bindingID: string;
connectionId: string;
eventID: string;
expiresAtMs: number;
interaction: "form" | "permission";
kind: "interaction";
location?: { directory: string; workspaceID?: string };
requestID: string;
sessionID: string;
}
| {
bindingID: string;
connectionId: string;
eventID: string;
expiresAtMs: number;
kind: "session-done";
sessionID: string;
};

export function NotificationRoutingProvider({ children }: { children: ReactNode }) {
const db = useSQLiteContext();
Expand Down Expand Up @@ -103,16 +113,28 @@ export function NotificationRoutingProvider({ children }: { children: ReactNode
return;
}
processingEvents.current.add(replayKey);
setPending({
bindingID: pairing.bindingID,
connectionId: pairing.connectionId,
eventID: route.eventID,
expiresAtMs: route.expiresAtMs,
interaction: route.interaction,
...(route.location ? { location: route.location } : {}),
requestID: route.requestID,
sessionID: route.sessionID,
});
setPending(
route.kind === "session-done"
? {
bindingID: pairing.bindingID,
connectionId: pairing.connectionId,
eventID: route.eventID,
expiresAtMs: route.expiresAtMs,
kind: "session-done",
sessionID: route.sessionID,
}
: {
bindingID: pairing.bindingID,
connectionId: pairing.connectionId,
eventID: route.eventID,
expiresAtMs: route.expiresAtMs,
interaction: route.interaction,
kind: "interaction",
...(route.location ? { location: route.location } : {}),
requestID: route.requestID,
sessionID: route.sessionID,
},
);
if (connections.selectedProfileId !== pairing.connectionId) {
await connections.select(pairing.connectionId);
}
Expand Down Expand Up @@ -155,7 +177,22 @@ export function NotificationRoutingProvider({ children }: { children: ReactNode
processingEvents.current.delete(`${route.bindingID}\u0000${route.eventID}`);
if (active) setPending(undefined);
};
if (route.sessionID === "global") {
if (route.kind === "session-done") {
const session = await getOpenCodeSession(restClient, route.sessionID);
if (!active) return;
queryClient.setQueryData(
openCodeQueryKeys.session(route.connectionId, session.location, session.id),
session,
);
await waitForNavigation();
if (active) {
rootNavigationRef.navigate("Session", {
connectionId: route.connectionId,
location: session.location,
sessionID: session.id,
});
}
} else if (route.sessionID === "global") {
if (!route.location) throw new Error("NOTIFICATION_LOCATION_REQUIRED");
const forms = await listOpenCodeFormRequests(restClient, route.location);
queryClient.setQueryData(
Expand Down
Loading