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
2 changes: 2 additions & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"pipeline.pack.remove": { type: "pipeline.pack.remove", id: "r41", packId: "aif-sdlc" },
"pipeline.pack.trust": { type: "pipeline.pack.trust", id: "r42", packId: "aif-sdlc", trusted: true },
"pipeline.pack.select": { type: "pipeline.pack.select", id: "r43", packId: "aif-sdlc" },
"push.register": { type: "push.register", id: "r44", token: "ExponentPushToken[abc]", platform: "ios" },
"push.unregister": { type: "push.unregister", id: "r45", token: "ExponentPushToken[abc]" },
};

describe("fidelity — valid samples round-trip unchanged", () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ export const sessionApproveSchema = z.object({
updatedInput: z.record(z.string(), z.unknown()).optional(),
});

const pushTokenField = z.string().min(1).max(LIMITS.PUSH_TOKEN_MAX);

export const pushRegisterSchema = z.object({
...base,
type: z.literal("push.register"),
token: pushTokenField,
platform: z.enum(["ios", "android"]),
});

export const pushUnregisterSchema = z.object({
...base,
type: z.literal("push.unregister"),
token: pushTokenField,
});

export const sessionUiResponseSchema = z
.object({
...base,
Expand Down Expand Up @@ -584,6 +599,8 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
pipelinePackRemoveSchema,
pipelinePackTrustSchema,
pipelinePackSelectSchema,
pushRegisterSchema,
pushUnregisterSchema,
]);

/**
Expand Down
35 changes: 34 additions & 1 deletion packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export const CAPABILITIES = {
* full-buffer replay.
*/
SCROLLBACK_PAGING: "scrollback.paging",
/**
* Push notifications. Declared by the DAEMON when a push transport is
* configured (so clients can feature-detect before registering) and by
* CLIENTS that can receive them. A client registers a device token via
* `push.register`; the daemon then sends content-blind wake-ups (only opaque
* ids, never tool args) when one of that user's sessions blocks on approval.
*/
PUSH: "push",
} as const;

export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
Expand Down Expand Up @@ -126,6 +134,8 @@ export const LIMITS = {
* governs concurrency at run time.
*/
COLLABORATION_ROLE_COUNT_MAX: 8,
/** Max device push-token length (`push.register`). Expo tokens are ~40 chars. */
PUSH_TOKEN_MAX: 512,
} as const;

// =============================================================================
Expand Down Expand Up @@ -804,7 +814,9 @@ export type ClientMessage =
| PipelinePackInstallMsg
| PipelinePackRemoveMsg
| PipelinePackTrustMsg
| PipelinePackSelectMsg;
| PipelinePackSelectMsg
| PushRegisterMsg
| PushUnregisterMsg;

interface BaseClientMsg {
/** Request ID for correlating responses */
Expand Down Expand Up @@ -1085,6 +1097,27 @@ export interface SessionApproveMsg extends BaseClientMsg {
updatedInput?: Record<string, unknown>;
}

/** Device platform for a push registration. */
export type PushPlatform = "ios" | "android";

/**
* Register a device to receive push notifications for the authenticated
* user's sessions. Scoped to the caller's identity (ZeroID `sub`) and tenant
* (account/project) server-side — a client can only register its own devices.
* `token` is the opaque transport token (an Expo push token today).
*/
export interface PushRegisterMsg extends BaseClientMsg {
type: "push.register";
token: string;
platform: PushPlatform;
}

/** Remove a previously-registered device token (e.g. on sign-out). */
export interface PushUnregisterMsg extends BaseClientMsg {
type: "push.unregister";
token: string;
}

/**
* Answer a provider-initiated dialog (`session.ui_request`). Exactly one of
* the payload fields applies per method:
Expand Down
32 changes: 32 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,24 @@ const PipelineSchema = z
})
.default({ enabled: true, defaultPack: null, packs: [], registries: [] });

/**
* Push notifications (docs/push.md). When a session blocks on a tool approval,
* the daemon sends a CONTENT-BLIND wake-up (an opaque session id only, never
* tool args) to the configured transport so the session owner's registered
* devices are alerted off-LAN. `transport: "none"` (default) disables push.
*/
const PushSchema = z
.object({
/** Delivery transport. "expo" routes through Expo's push service (which
* relays to APNs/FCM); "none" disables push. A self-hosted content-blind
* relay transport swaps in behind this seam later. */
transport: z.enum(["expo", "none"]).default("none"),
/** Expo access token (Bearer) for the push API. Optional — Expo accepts
* unauthenticated sends, but a token enables receipts + higher limits. */
expoAccessToken: z.string().optional(),
})
.default({ transport: "none" });

const RootSchema = z.object({
daemonUrl: z.string().default("ws://127.0.0.1:7400"),
dbPath: z.string().default("codeoid.db"),
Expand All @@ -592,6 +610,7 @@ const RootSchema = z.object({
mcpServers: McpServersSchema,
hooks: HooksSchema,
embed: EmbedSchema,
push: PushSchema,
fork: z
.object({
/** Shell command run once in a freshly-created fork worktree to make it
Expand Down Expand Up @@ -773,6 +792,14 @@ export interface CodeoidConfig {
enabled: boolean;
entries: HookEntryConfig[];
};
/**
* Push notifications. Optional in the type so hand-built test configs stay
* minimal; loadConfig always populates it (schema default: transport "none").
*/
push?: {
transport: "expo" | "none";
expoAccessToken?: string;
};
/**
* Embed trust — origins permitted to frame the web UI and pre-authenticate
* it via the URL-hash credential handoff. The web UI's trusted-framing-origin
Expand Down Expand Up @@ -846,6 +873,10 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
// Hooks kill switch — disable every configured hook per-invocation without
// touching config.json. Entries themselves are file-config only.
{ env: "CODEOID_HOOKS_ENABLED", path: "hooks.enabled", kind: "boolean" },
// Push transport switch + Expo token — per-invocation without touching
// config.json (e.g. CODEOID_PUSH_TRANSPORT=expo).
{ env: "CODEOID_PUSH_TRANSPORT", path: "push.transport", kind: "string" },
{ env: "CODEOID_EXPO_ACCESS_TOKEN", path: "push.expoAccessToken", kind: "string" },
{ env: "CODEOID_TURN_STALL_TIMEOUT_MS", path: "session.turnStallTimeoutMs", kind: "int" },
{ env: "CODEOID_MCP_TOOL_TIMEOUT_MS", path: "session.mcpToolTimeoutMs", kind: "int" },
// Embed-SSO trusted framing origins (comma-separated). Each is an exact
Expand Down Expand Up @@ -1048,6 +1079,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
mcpServers: parsed.mcpServers,
hooks: parsed.hooks,
embed: parsed.embed,
push: parsed.push,
fork: parsed.fork,
};
}
Expand Down
65 changes: 65 additions & 0 deletions src/daemon/push/expo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Expo Push transport. Delivers via Expo's push service, which holds the
* APNs/FCM credentials and relays to the OS — so a self-hosted daemon never
* needs its own Apple/Google credentials for v1.
*
* CONTENT-BLIND: the alert copy is generic and `data` carries only the opaque
* sessionId + kind, so Expo (a third party in the delivery path) never sees a
* tool name, args, or description. The tradeoff Expo introduces — a third party
* seeing device tokens + timing — is why the self-hosted relay is the eventual
* end state; this transport keeps the payload content-blind so that migration
* changes nothing about what's exposed.
*
* Best-effort: logs and swallows failures so a push outage can never wedge the
* status-change path that fires it.
*/
import type { PushNotification, PushTarget, PushTransport } from "./types.js";

const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
/** Expo accepts at most 100 messages per request. */
const BATCH_SIZE = 100;
const TIMEOUT_MS = 10_000;

export class ExpoPushTransport implements PushTransport {
readonly name = "expo";

constructor(private readonly accessToken?: string) {}

async send(targets: PushTarget[], note: PushNotification): Promise<void> {
if (targets.length === 0) return;
const messages = targets.map((t) => ({
to: t.token,
title: "codeoid",
body: "A session needs your approval",
// Content-blind: opaque ids only. The app fetches real context over its
// authenticated socket after the user taps.
data: { sessionId: note.sessionId, kind: note.kind },
priority: "high",
// Collapse repeated pings for the same session on the device.
threadId: note.sessionId,
}));
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
await this.#post(messages.slice(i, i + BATCH_SIZE));
}
}

async #post(batch: unknown[]): Promise<void> {
try {
const res = await fetch(EXPO_PUSH_URL, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...(this.accessToken ? { authorization: `Bearer ${this.accessToken}` } : {}),
},
body: JSON.stringify(batch),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
console.error(`[codeoid/push] Expo push HTTP ${res.status} ${res.statusText}`);
}
} catch (err) {
console.error("[codeoid/push] Expo push error:", err);
}
}
}
4 changes: 4 additions & 0 deletions src/daemon/push/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { PushService, createPushTransport } from "./service.js";
export type { PushConfig, SessionOwner } from "./service.js";
export { ExpoPushTransport } from "./expo.js";
export type { PushNotification, PushTarget, PushTransport } from "./types.js";
Loading
Loading