diff --git a/AGENTS.md b/AGENTS.md
index 8cce9d2f..a039c058 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,6 +5,8 @@
# Checks
- After changing `root` or `functions/`, run: `npm run typecheck && npm run lint && npm run test`
+- After changing `cloud-agent/`, run: `cd cloud-agent && npm run typecheck && npm test`
+- After changing `extension/`, run: `cd extension && npm run typecheck && npm test`
# Git & Commits
- **Flow**: feature → `staging` → `main` (via PRs only).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dd0a7e27..5ab43a7e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -322,10 +322,30 @@ Fixes #456
### Running Tests
```bash
-npm test # Run all tests
+npm test # Run all tests (Expo app)
npm run test:watch # Run tests in watch mode
```
+### Cloud Agent Tests
+
+```bash
+cd cloud-agent
+npm run typecheck
+npm test
+```
+
+### Desktop Bridge Extension Tests
+
+```bash
+cd extension
+npm install # first time only
+npm run typecheck
+npm test
+npm run build # esbuild → dist/ for unpacked Chrome load
+```
+
+The root `npm run typecheck` excludes `extension/` and `cloud-agent/` — run each package's typecheck separately. Root `tsconfig.json` excludes those directories; they have their own `tsconfig.json` files.
+
### Writing Tests
- Add tests for new features in `__tests__` directory
diff --git a/README.md b/README.md
index c0ff2994..496bfe87 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
### AI & Chat
- **[AI & Chat](docs/ai-and-chat.md)** — Chat response pipeline (`generateReply`), chat memory summarization, LLM Wiki Memory (structured facts/tasks/events), wiki state machine architecture, image generation (`generateImage`), and local `cloud-agent` dev (local Postgres + Vertex via ADC; see `docker-compose.local.yml` and `./scripts/migrate-dev.sh`).
+- **[Browser Bridge](docs/browser-bridge.md)** — Clanker Desktop Bridge MV3 extension: cross-device web tasks via `browser_action`, Wake-and-Connect lifecycle, Firestore coordination, approval flow, pairing, and billing.
- **[Real-Time Voice Chat](docs/real-time-voice-chat.md)** — Talk tab live voice calls via Gemini Live (`/agent/live` WebSocket), XState session lifecycle, audio I/O, pre-flight checks, and native build requirements.
- **[Edge Agent](docs/edge-agent.md)** — On-device tool orchestration loop (BYOI proxy via `generateReply`), escalation paths, per-round billing, and security policy.
diff --git a/app/privacy.tsx b/app/privacy.tsx
index 49146ca8..84c2eec3 100644
--- a/app/privacy.tsx
+++ b/app/privacy.tsx
@@ -1,6 +1,7 @@
import { StyleSheet, ScrollView, View } from 'react-native'
import { Text, useTheme } from 'react-native-paper'
import { PRIVACY } from '~/config/privacyConfig'
+import { PolicyMarkdown } from '~/components/PolicyMarkdown'
export default function Privacy() {
const { colors } = useTheme()
@@ -25,7 +26,7 @@ export default function Privacy() {
- {PRIVACY.privacy}
+
)
diff --git a/cloud-agent/scripts/deploy.sh b/cloud-agent/scripts/deploy.sh
index ead2fc38..d74c84dd 100755
--- a/cloud-agent/scripts/deploy.sh
+++ b/cloud-agent/scripts/deploy.sh
@@ -35,8 +35,10 @@ DEPLOY_ARGS=(
--memory 512Mi
--timeout 540
--set-env-vars "GOOGLE_GENAI_USE_VERTEXAI=true,GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GOOGLE_CLOUD_LOCATION=${GEMINI_LOCATION}"
- # GEMINI_API_KEY secret was deleted (embeddings.ts migrated to Vertex AI ADC);
- # remove the dangling secret-backed env var carried forward from prior revisions.
+ # Explicitly pin required secrets so every deploy is self-contained.
+ # --update-secrets adds/overwrites only these; other secrets (Cloud SQL x4) are preserved.
+ # GEMINI_API_KEY was deleted from Secret Manager; remove its binding if still present.
+ --update-secrets "SCHEDULER_SECRET=SCHEDULER_SECRET:latest"
--remove-secrets "GEMINI_API_KEY"
)
if [[ "${ALLOW_UNAUTHENTICATED}" == "true" ]]; then
diff --git a/cloud-agent/src/handlers/authApprovalObserver.test.ts b/cloud-agent/src/handlers/authApprovalObserver.test.ts
index af8d1492..da35b1d4 100644
--- a/cloud-agent/src/handlers/authApprovalObserver.test.ts
+++ b/cloud-agent/src/handlers/authApprovalObserver.test.ts
@@ -20,6 +20,7 @@ function baseDeps(over: Record = {}) {
wakeExtension: async (...a: unknown[]) => { pushes.push({ type: 'wake', args: a }) },
sendTaskComplete: async (...a: unknown[]) => { pushes.push({ type: 'complete', args: a }) },
},
+ verifyToken: async () => ({ uid: 'uid1' }),
getExpoPushToken: async () => 'ExponentPushToken[x]',
firebaseUid: 'uid1',
sessionId: 'sid1',
@@ -53,11 +54,11 @@ test('denied writes aborted + sendTaskComplete and unsubscribes', async () => {
assert.ok(getUnsubbed())
})
-test('approved sends FCM resume wake without token verification', async () => {
+test('approved verifies token and sends FCM resume wake', async () => {
const { deps, getWatcher, pushes } = baseDeps()
startAuthApprovalObserver(deps as never)
- getWatcher()!({ status: 'approved', approvalToken: null, approvedAt: null, expiresAt: 0, actionSummary: '' })
+ getWatcher()!({ status: 'approved', approvalToken: 'valid-token', approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 10))
assert.equal(pushes.length, 1)
@@ -75,7 +76,7 @@ test('failed FCM wake aborts task instead of resolving', async () => {
})
startAuthApprovalObserver(deps as never)
- getWatcher()!({ status: 'approved', approvalToken: null, approvedAt: null, expiresAt: 0, actionSummary: '' })
+ getWatcher()!({ status: 'approved', approvalToken: 'valid-token', approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 20))
assert.ok(results.length >= 1)
@@ -85,6 +86,23 @@ test('failed FCM wake aborts task instead of resolving', async () => {
assert.match(result.error.message, /wake/i)
})
+test('invalid approval token aborts task with AUTH_DENIED', async () => {
+ const { deps, getWatcher, results, pushes } = baseDeps({
+ verifyToken: async () => { throw new Error('invalid token') },
+ })
+ startAuthApprovalObserver(deps as never)
+
+ getWatcher()!({ status: 'approved', approvalToken: 'bad-token', approvedAt: null, expiresAt: 0, actionSummary: '' })
+ await new Promise((r) => setTimeout(r, 20))
+
+ assert.equal(results.length, 1)
+ const result = (results[0] as unknown[])[3] as { status: string; error: { code: string; message: string } }
+ assert.equal(result.status, 'aborted')
+ assert.equal(result.error.code, 'AUTH_DENIED')
+ assert.match(result.error.message, /invalid/i)
+ assert.equal(pushes.length, 1)
+})
+
test('5-minute TTL aborts with AUTH_TIMEOUT and sendTaskComplete', async () => {
const { deps, results, pushes } = baseDeps({ authApprovalTtlMs: 30 })
startAuthApprovalObserver(deps as never)
diff --git a/cloud-agent/src/handlers/authApprovalObserver.ts b/cloud-agent/src/handlers/authApprovalObserver.ts
index efe01ec2..22a833bf 100644
--- a/cloud-agent/src/handlers/authApprovalObserver.ts
+++ b/cloud-agent/src/handlers/authApprovalObserver.ts
@@ -7,6 +7,7 @@ export const AUTH_APPROVAL_TTL_MS = 5 * 60 * 1000
export interface AuthApprovalObserverDeps {
fs: FirestoreSession
fcmDispatcher?: FcmDispatcher
+ verifyToken: (token: string) => Promise<{ uid: string }>
getExpoPushToken?: (uid: string) => Promise
firebaseUid: string
sessionId: string
@@ -75,6 +76,14 @@ export function startAuthApprovalObserver(deps: AuthApprovalObserverDeps): void
if (auth.status === 'approved') {
settle(async () => {
+ try {
+ const decoded = await deps.verifyToken(auth.approvalToken ?? '')
+ if (decoded.uid !== deps.firebaseUid) throw new Error('UID mismatch')
+ } catch {
+ await abortTask('AUTH_DENIED', 'Approval token invalid. The action was not completed.')
+ return
+ }
+
if (deps.fcmDispatcher && deps.deviceFcmToken) {
try {
await deps.fcmDispatcher.wakeExtension(deps.deviceFcmToken, deps.sessionId, deps.taskId, true)
diff --git a/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts b/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts
new file mode 100644
index 00000000..2d76a3dc
--- /dev/null
+++ b/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts
@@ -0,0 +1,289 @@
+// cloud-agent/src/handlers/schedulerTriggerHandler.test.ts
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import express from 'express'
+import { rateLimit } from 'express-rate-limit'
+import request from 'supertest'
+import { createSchedulerTriggerHandler, createRequireSchedulerSecret } from './schedulerTriggerHandler.js'
+import type { TaskDoc } from '../../../shared/dsl-types.js'
+
+const SECRET = 'test-scheduler-secret-abc'
+
+const schedulerBody = {
+ uid: 'u1',
+ runKey: 'job-1-exec-1',
+ action: { type: 'extract', selector: '.p', label: 'price' },
+ actionSummary: 'Extract',
+ notificationBody: 'Done',
+} as const
+
+function buildApp(overrides: {
+ getActiveDevice?: () => Promise
+ createSession?: () => Promise
+ watchTask?: (uid: string, sid: string, tid: string, cb: (t: TaskDoc) => void) => () => void
+ sendProactive?: (token: string, sid: string, tid: string, body: string) => Promise
+ getExpoPushToken?: (uid: string) => Promise
+ resolveUserId?: (uid: string) => Promise
+ spendCredit?: () => Promise
+ refundCredit?: () => Promise
+ abortPendingTaskIfOffline?: () => Promise
+ writeTaskResult?: () => Promise
+ reserveSchedulerRun?: (uid: string, runKey: string, ids: { sessionId: string; taskId: string }) => Promise<'reserved' | 'duplicate'>
+ getSchedulerRun?: (uid: string, runKey: string) => Promise<{ sessionId: string; taskId: string } | null>
+} = {}) {
+ const creditCalls = { spend: 0, refund: 0 }
+ const schedulerRuns = new Map()
+ const mockFs = {
+ getActiveDevice: overrides.getActiveDevice ?? (async () => ({ deviceId: 'd1', fcmToken: 'fcm-tok', deviceName: 'Mac' })),
+ createSession: overrides.createSession ?? (async () => {}),
+ writeTask: async () => {},
+ closeSession: async () => {},
+ abortPendingTaskIfOffline: overrides.abortPendingTaskIfOffline ?? (async () => true),
+ writeTaskResult: overrides.writeTaskResult ?? (async () => {}),
+ reserveSchedulerRun: overrides.reserveSchedulerRun ?? (async (uid: string, runKey: string, ids: { sessionId: string; taskId: string }) => {
+ const key = `${uid}:${runKey}`
+ if (schedulerRuns.has(key)) return 'duplicate' as const
+ schedulerRuns.set(key, ids)
+ return 'reserved' as const
+ }),
+ getSchedulerRun: overrides.getSchedulerRun ?? (async (uid: string, runKey: string) => {
+ return schedulerRuns.get(`${uid}:${runKey}`) ?? null
+ }),
+ watchTask: overrides.watchTask ?? ((_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => {
+ setTimeout(() => cb({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://x.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5)
+ return () => {}
+ }),
+ }
+
+ const proactiveCalls: Array<{ token: string; sid: string; tid: string; body: string }> = []
+ const mockFcm = {
+ wakeExtension: async () => {},
+ sendProactive: overrides.sendProactive ?? (async (token: string, sid: string, tid: string, body: string) => {
+ proactiveCalls.push({ token, sid, tid, body })
+ }),
+ }
+
+ const mockCredit = {
+ spendCredit: overrides.spendCredit ?? (async () => { creditCalls.spend++; return 'tx1' }),
+ refundCredit: overrides.refundCredit ?? (async () => { creditCalls.refund++ }),
+ }
+
+ const handler = createSchedulerTriggerHandler(
+ mockFs as never,
+ mockFcm as never,
+ overrides.getExpoPushToken ?? (async () => 'ExponentPushToken[sched]'),
+ mockCredit as never,
+ overrides.resolveUserId ?? (async () => 'user-db-id'),
+ { schedulerTimeoutMs: 200 },
+ )
+
+ const app = express()
+ app.use(express.json())
+ const testLimiter = rateLimit({ windowMs: 60_000, limit: 1_000 })
+ app.post(
+ '/agent/browser/scheduler-trigger',
+ testLimiter,
+ createRequireSchedulerSecret(SECRET),
+ handler,
+ )
+
+ return { app, proactiveCalls, creditCalls }
+}
+
+test('returns 401 with no Authorization header', async () => {
+ const { app } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .send(schedulerBody)
+ assert.equal(res.status, 401)
+})
+
+test('returns 401 with wrong secret', async () => {
+ const { app } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', 'Bearer wrong-secret')
+ .send(schedulerBody)
+ assert.equal(res.status, 401)
+})
+
+test('returns 422 when no active device', async () => {
+ const { app, creditCalls } = buildApp({ getActiveDevice: async () => null })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send(schedulerBody)
+ assert.equal(res.status, 422)
+ assert.match(res.body.error, /no active device/i)
+ assert.equal(creditCalls.spend, 0)
+})
+
+test('returns 422 for blocked host', async () => {
+ const { app, creditCalls } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, action: { type: 'open_tab', url: 'chrome://settings' }, actionSummary: 'Open' })
+ assert.equal(res.status, 422)
+ assert.match(res.body.error, /HOST_NOT_ALLOWED/i)
+ assert.equal(creditCalls.spend, 0)
+})
+
+test('returns 422 for actions that require approval', async () => {
+ const { app, creditCalls } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({
+ ...schedulerBody,
+ runKey: 'job-approval-1',
+ action: { type: 'click', selector: '#submit-order-btn', label: 'Submit Payment', tier: 'stateful' },
+ actionSummary: 'Submit payment of $42.99 on amazon.com',
+ })
+ assert.equal(res.status, 422)
+ assert.match(res.body.error, /REQUIRES_AUTH/i)
+ assert.equal(creditCalls.spend, 0)
+})
+
+test('returns 402 when user has insufficient credits', async () => {
+ const { app, creditCalls } = buildApp({
+ spendCredit: async () => { throw new Error('INSUFFICIENT_CREDITS') },
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, runKey: 'job-insufficient-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 402)
+ assert.match(res.body.error, /insufficient credits/i)
+ assert.equal(creditCalls.spend, 0)
+})
+
+test('returns 422 when user not found', async () => {
+ const { app, creditCalls } = buildApp({ resolveUserId: async () => null })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send(schedulerBody)
+ assert.equal(res.status, 422)
+ assert.match(res.body.error, /user not found/i)
+ assert.equal(creditCalls.spend, 0)
+})
+
+test('returns 200 and sends Expo Push on successful task', async () => {
+ const { app, proactiveCalls, creditCalls } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, runKey: 'job-insufficient-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 200)
+ assert.equal(res.body.ok, true)
+ assert.ok(res.body.sessionId)
+ assert.ok(res.body.taskId)
+ assert.equal(proactiveCalls.length, 1)
+ assert.equal(proactiveCalls[0].token, 'ExponentPushToken[sched]')
+ assert.equal(proactiveCalls[0].body, 'Price check done.')
+ assert.equal(creditCalls.spend, 1)
+ assert.equal(creditCalls.refund, 0)
+})
+
+test('returns 200 with failure body when task fails', async () => {
+ const { app, proactiveCalls, creditCalls } = buildApp({
+ watchTask: (_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => {
+ setTimeout(() => cb({ status: 'failed', result: null, error: { code: 'SELECTOR_NOT_FOUND', message: 'not found', failedAction: { type: 'extract', selector: '.p' } as never }, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5)
+ return () => {}
+ },
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, runKey: 'job-insufficient-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 200)
+ assert.equal(res.body.status, 'failed')
+ assert.equal(proactiveCalls.length, 1)
+ assert.match(proactiveCalls[0].body, /SELECTOR_NOT_FOUND|failed/i)
+ assert.equal(creditCalls.spend, 1)
+ assert.equal(creditCalls.refund, 0)
+})
+
+test('returns 504 and no Expo Push on timeout', async () => {
+ const { app, proactiveCalls, creditCalls } = buildApp({
+ watchTask: () => () => {},
+ abortPendingTaskIfOffline: async () => false,
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, runKey: 'job-insufficient-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 504)
+ assert.equal(proactiveCalls.length, 0)
+ assert.equal(creditCalls.spend, 1)
+ assert.equal(creditCalls.refund, 0)
+})
+
+test('refunds credit on timeout when extension never connected', async () => {
+ const { app, creditCalls } = buildApp({
+ watchTask: () => () => {},
+ abortPendingTaskIfOffline: async () => true,
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, runKey: 'job-insufficient-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 504)
+ assert.equal(creditCalls.spend, 1)
+ assert.equal(creditCalls.refund, 1)
+})
+
+test('returns 200 without Expo Push when no token registered', async () => {
+ const { app, proactiveCalls } = buildApp({
+ getExpoPushToken: async () => null,
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ ...schedulerBody, runKey: 'job-insufficient-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 200)
+ assert.equal(proactiveCalls.length, 0)
+})
+
+test('duplicate runKey does not spend credit or create a second task', async () => {
+ let createSessionCalls = 0
+ const { app, creditCalls } = buildApp({
+ createSession: async () => { createSessionCalls++ },
+ watchTask: (_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => {
+ setTimeout(() => cb({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://x.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5)
+ return () => {}
+ },
+ })
+
+ const body = { ...schedulerBody, runKey: 'job-dup-1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' }
+
+ const first = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send(body)
+ assert.equal(first.status, 200)
+
+ const second = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send(body)
+ assert.equal(second.status, 200)
+ assert.equal(second.body.sessionId, first.body.sessionId)
+ assert.equal(second.body.taskId, first.body.taskId)
+ assert.equal(creditCalls.spend, 1)
+ assert.equal(createSessionCalls, 1)
+})
+
+test('refunds credit when setup fails after spend', async () => {
+ const { app, creditCalls } = buildApp({
+ createSession: async () => { throw new Error('firestore write failed') },
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send(schedulerBody)
+ assert.equal(res.status, 500)
+ assert.equal(creditCalls.spend, 1)
+ assert.equal(creditCalls.refund, 1)
+})
diff --git a/cloud-agent/src/handlers/schedulerTriggerHandler.ts b/cloud-agent/src/handlers/schedulerTriggerHandler.ts
new file mode 100644
index 00000000..ade8d755
--- /dev/null
+++ b/cloud-agent/src/handlers/schedulerTriggerHandler.ts
@@ -0,0 +1,290 @@
+import { timingSafeEqual } from 'node:crypto'
+import { z } from 'zod'
+import type { NextFunction, Request, Response } from 'express'
+import type { FirestoreSession } from '../services/firestoreSession.js'
+import type { FcmDispatcher } from '../services/fcmDispatcher.js'
+import type { CreditService } from '../services/creditService.js'
+import type { TaskDoc } from '../../../shared/dsl-types.js'
+import { singleActionSchema } from '../../../shared/dsl-schema.js'
+import { intentRequiresAuth } from '../../../shared/constants.js'
+import { findBlockedNavigation } from '../../../shared/hostPolicy.js'
+import { INSTANCE_ID } from '../services/instanceId.js'
+
+export interface SchedulerTriggerOptions {
+ schedulerTimeoutMs?: number
+}
+
+const schedulerActionSchema = z.union([
+ singleActionSchema,
+ z.object({
+ type: z.literal('sequence'),
+ steps: z.array(singleActionSchema).min(1),
+ }),
+])
+
+const schedulerBodySchema = z.object({
+ uid: z.string().min(1),
+ runKey: z.string().min(1),
+ action: schedulerActionSchema,
+ actionSummary: z.string().min(1),
+ notificationBody: z.string().min(1),
+})
+
+function constantTimeEquals(provided: string, expected: string): boolean {
+ const providedBuffer = Buffer.from(provided)
+ const expectedBuffer = Buffer.from(expected)
+ if (providedBuffer.length !== expectedBuffer.length) return false
+ try {
+ return timingSafeEqual(providedBuffer, expectedBuffer)
+ } catch {
+ return false
+ }
+}
+
+export function isSchedulerAuthorized(req: Request, secret: string): boolean {
+ const authHeader = req.headers.authorization ?? ''
+ const token = authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7).trim() : ''
+ return Boolean(token && constantTimeEquals(token, secret))
+}
+
+/** Bearer SCHEDULER_SECRET check — mount after a rate limiter in production routes. */
+export function createRequireSchedulerSecret(secret: string) {
+ return (req: Request, res: Response, next: NextFunction): void => {
+ if (!isSchedulerAuthorized(req, secret)) {
+ res.status(401).json({ error: 'Unauthorized' })
+ return
+ }
+ next()
+ }
+}
+
+function scheduledActionNeedsApproval(
+ actionSummary: string,
+ action: z.infer,
+): boolean {
+ return intentRequiresAuth(actionSummary, action)
+}
+
+function waitForTerminalTask(
+ fs: FirestoreSession,
+ uid: string,
+ sessionId: string,
+ taskId: string,
+ timeoutMs: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let unsub: () => void = () => {}
+ const timeout = setTimeout(() => {
+ unsub()
+ reject(new Error('TIMEOUT'))
+ }, timeoutMs)
+
+ unsub = fs.watchTask(uid, sessionId, taskId, (task) => {
+ if (task.status === 'complete' || task.status === 'failed' || task.status === 'aborted') {
+ clearTimeout(timeout)
+ unsub()
+ resolve(task)
+ }
+ })
+ })
+}
+
+export function createSchedulerTriggerHandler(
+ fs: FirestoreSession,
+ fcm: Pick,
+ getExpoPushToken: (firebaseUid: string) => Promise,
+ creditService: Pick,
+ resolveUserId: (firebaseUid: string) => Promise,
+ opts: SchedulerTriggerOptions,
+) {
+ const timeoutMs = opts.schedulerTimeoutMs ?? 60_000
+
+ return async (req: Request, res: Response): Promise => {
+ const parsed = schedulerBodySchema.safeParse(req.body)
+ if (!parsed.success) {
+ res.status(400).json({ error: 'Invalid request body' })
+ return
+ }
+
+ const { uid, runKey, action, actionSummary, notificationBody } = parsed.data
+
+ const blocked = findBlockedNavigation(action)
+ if (blocked) {
+ res.status(422).json({ error: `HOST_NOT_ALLOWED: ${blocked.message}` })
+ return
+ }
+
+ if (scheduledActionNeedsApproval(actionSummary, action)) {
+ res.status(422).json({
+ error: 'REQUIRES_AUTH: Scheduled tasks cannot include actions that require approval',
+ })
+ return
+ }
+
+ let device: { deviceId: string; fcmToken: string; deviceName: string } | null
+ try {
+ device = await fs.getActiveDevice(uid)
+ } catch (err) {
+ console.error('[scheduler-trigger] getActiveDevice error:', err)
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+
+ if (!device) {
+ res.status(422).json({ error: 'No active device for this user' })
+ return
+ }
+
+ let userId: string
+ try {
+ const resolved = await resolveUserId(uid)
+ if (!resolved) {
+ res.status(422).json({ error: 'User not found' })
+ return
+ }
+ userId = resolved
+ } catch (err) {
+ console.error('[scheduler-trigger] resolveUserId error:', err)
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+
+ const sessionId = crypto.randomUUID()
+ const taskId = crypto.randomUUID()
+
+ let isDuplicateRun = false
+ try {
+ const reservation = await fs.reserveSchedulerRun(uid, runKey, { sessionId, taskId })
+ isDuplicateRun = reservation === 'duplicate'
+ } catch (err) {
+ console.error('[scheduler-trigger] reserveSchedulerRun error:', err)
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+
+ let activeSessionId: string = sessionId
+ let activeTaskId: string = taskId
+
+ if (isDuplicateRun) {
+ try {
+ const existing = await fs.getSchedulerRun(uid, runKey)
+ if (!existing) {
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+ activeSessionId = existing.sessionId
+ activeTaskId = existing.taskId
+ } catch (err) {
+ console.error('[scheduler-trigger] getSchedulerRun error:', err)
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+ }
+
+ let txId: string | null = null
+ if (!isDuplicateRun) {
+ try {
+ txId = await creditService.spendCredit(userId)
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : ''
+ if (msg === 'INSUFFICIENT_CREDITS') {
+ res.status(402).json({ error: 'Insufficient credits' })
+ return
+ }
+ console.error('[scheduler-trigger] spendCredit error:', err)
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+ }
+
+ const taskIntent = {
+ version: '1' as const,
+ taskId: activeTaskId,
+ sessionId: activeSessionId,
+ requiresAuth: false,
+ actionSummary,
+ action,
+ }
+
+ if (!isDuplicateRun) {
+ try {
+ await fs.createSession(uid, activeSessionId, { status: 'pending', trigger: 'scheduler', voiceInstanceId: INSTANCE_ID })
+ await fs.writeTask(uid, activeSessionId, activeTaskId, taskIntent)
+ } catch (err) {
+ console.error('[scheduler-trigger] setup error:', err)
+ if (txId) {
+ try { await creditService.refundCredit(userId, txId) } catch { /* logged */ }
+ }
+ try { await fs.closeSession(uid, activeSessionId, 'aborted') } catch { /* ignore */ }
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+
+ // FCM wake is best-effort — extension falls back to alarm-based polling.
+ if (!device.fcmToken.startsWith('polling:')) {
+ try {
+ await fcm.wakeExtension(device.fcmToken, activeSessionId, activeTaskId)
+ } catch (err) {
+ console.warn('[scheduler-trigger] FCM wake failed, extension will poll:', err instanceof Error ? err.message : err)
+ }
+ }
+ }
+
+ let task: TaskDoc | null = null
+ try {
+ task = await waitForTerminalTask(fs, uid, activeSessionId, activeTaskId, timeoutMs)
+ } catch {
+ let abortedOffline = false
+ try {
+ abortedOffline = await fs.abortPendingTaskIfOffline(uid, activeSessionId, activeTaskId, {
+ taskId: activeTaskId, status: 'failed', data: {}, activeUrl: '',
+ error: {
+ code: 'EXTENSION_OFFLINE',
+ message: 'Browser extension did not connect',
+ failedAction: action as never,
+ },
+ })
+ } catch { /* ignore */ }
+
+ if (!abortedOffline) {
+ try {
+ await fs.writeTaskResult(uid, activeSessionId, activeTaskId, {
+ taskId: activeTaskId, status: 'failed', data: {}, activeUrl: '',
+ error: {
+ code: 'EXECUTION_TIMEOUT',
+ message: 'Scheduler task timed out',
+ failedAction: action as never,
+ },
+ })
+ } catch { /* ignore */ }
+ }
+
+ if (!isDuplicateRun && abortedOffline && txId) {
+ try { await creditService.refundCredit(userId, txId) } catch { /* logged */ }
+ }
+
+ try { await fs.closeSession(uid, activeSessionId, 'aborted') } catch { /* ignore */ }
+ res.status(504).json({ error: 'Task timed out', sessionId: activeSessionId, taskId: activeTaskId })
+ return
+ }
+
+ try { await fs.closeSession(uid, activeSessionId, 'closed') } catch { /* ignore */ }
+
+ const pushBody = task.status === 'complete'
+ ? notificationBody
+ : `Browser task failed (${task.error?.code ?? 'unknown'}). Tap to check.`
+
+ if (!isDuplicateRun) {
+ try {
+ const expoPushToken = await getExpoPushToken(uid)
+ if (expoPushToken) {
+ await fcm.sendProactive(expoPushToken, activeSessionId, activeTaskId, pushBody)
+ }
+ } catch (err) {
+ console.error('[scheduler-trigger] sendProactive error:', err)
+ }
+ }
+
+ res.json({ ok: true, sessionId: activeSessionId, taskId: activeTaskId, status: task.status })
+ }
+}
diff --git a/cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts b/cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts
index 363fc7ad..aff5bdd7 100644
--- a/cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts
+++ b/cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts
@@ -168,7 +168,7 @@ test('watchAuth approved → sends FCM wake with resume', async () => {
ws.emitJson({ type: 'awaiting_auth', taskId: 't1', haltedStepIndex: 0 })
await new Promise((r) => setTimeout(r, 20))
- authWatcher!({ status: 'approved', approvalToken: null, approvedAt: null, expiresAt: 0, actionSummary: '' })
+ authWatcher!({ status: 'approved', approvalToken: 'valid-approval-token', approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 20))
assert.equal(fcmWakes.length, 1)
diff --git a/cloud-agent/src/handlers/wsBrowserAgentHandler.ts b/cloud-agent/src/handlers/wsBrowserAgentHandler.ts
index 2bbea285..d41bd16c 100644
--- a/cloud-agent/src/handlers/wsBrowserAgentHandler.ts
+++ b/cloud-agent/src/handlers/wsBrowserAgentHandler.ts
@@ -198,6 +198,7 @@ export function handleBrowserWsUpgrade(
startAuthApprovalObserver({
fs,
fcmDispatcher: fwd,
+ verifyToken,
getExpoPushToken: options.getExpoPushToken,
firebaseUid,
sessionId,
diff --git a/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts b/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts
index 9f9b8797..d01d2177 100644
--- a/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts
+++ b/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts
@@ -929,3 +929,183 @@ test('attachWebSocketRoutes: /agent/stream and /agent/live both accept connectio
httpServer.close((err) => (err ? reject(err) : resolve()))
})
})
+
+test('pushToLive falls back to Expo Push when voice WS is closed', { timeout: 5000 }, async () => {
+ const db = makeMockDb([[mockUser], [mockCharacter]])
+
+ const expoPushCalls: Array<{ token: string; sessionId: string; taskId: string; text: string }> = []
+ const mockFcmDispatcher = {
+ wakeExtension: async () => {},
+ sendApprovalCard: async () => {},
+ sendTaskComplete: async (token: string, sessionId: string, taskId: string, text: string) => {
+ expoPushCalls.push({ token, sessionId, taskId, text })
+ },
+ sendProactive: async () => {},
+ }
+
+ let watchTaskCallback: ((task: unknown) => void) | null = null
+ const mockFirestoreSession = {
+ getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'fcm-tok', deviceName: 'Mac' }),
+ createSession: async () => {},
+ writeTask: async () => {},
+ closeSession: async () => {},
+ writeTaskResult: async () => {},
+ getTask: async () => ({ status: 'pending' }),
+ getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }),
+ abortPendingTaskIfOffline: async () => false,
+ watchTask: (_uid: string, _sid: string, _tid: string, cb: (task: unknown) => void) => {
+ watchTaskCallback = cb
+ return () => {}
+ },
+ }
+
+ const mock = makeMockLiveConnect()
+ const { server, close } = createLiveTestServer({
+ db,
+ creditService: mockCreditService,
+ verifyToken: async () => ({ uid: 'fb-uid-1' }),
+ liveConnect: mock.connect,
+ getExpoPushToken: async () => 'ExponentPushToken[test]',
+ browserBridge: {
+ firebaseUid: 'fb-uid-1',
+ userId: 'user-uuid-1',
+ firestoreSession: mockFirestoreSession as never,
+ fcmDispatcher: mockFcmDispatcher as never,
+ creditService: mockCreditService,
+ instanceId: 'inst-1',
+ wakeTimeoutMs: 50,
+ textTimeoutMs: 500,
+ },
+ })
+ try {
+ const port = await listen(server)
+
+ await new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`)
+ const timeout = setTimeout(() => reject(new Error('test timeout')), 4500)
+
+ ws.on('open', () => {
+ ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID }))
+ })
+
+ ws.on('message', (raw) => {
+ const msg = JSON.parse(raw.toString()) as { type: string }
+ if (msg.type !== 'session_ready') return
+
+ // Simulate Gemini invoking browser_action
+ mock.triggerMessage({
+ toolCall: {
+ functionCalls: [{ id: 'call-1', name: 'browser_action', args: {
+ actionSummary: 'Extract price',
+ intent: { action: { type: 'extract', selector: '.price', label: 'price' } },
+ }}],
+ },
+ })
+
+ // Close the WS before the task result arrives
+ setTimeout(() => ws.close(), 20)
+ })
+
+ ws.on('close', async () => {
+ // Task result arrives after WS is closed
+ await new Promise((r) => setTimeout(r, 100))
+ watchTaskCallback?.({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://example.com' }, error: null })
+
+ await new Promise((r) => setTimeout(r, 100))
+
+ clearTimeout(timeout)
+ try {
+ assert.equal(expoPushCalls.length, 1, 'sendTaskComplete should be called once')
+ assert.equal(expoPushCalls[0].token, 'ExponentPushToken[test]')
+ assert.match(expoPushCalls[0].text, /\$340|complete/i)
+ resolve()
+ } catch (e) {
+ reject(e)
+ }
+ })
+
+ ws.on('error', reject)
+ })
+ } finally {
+ await close()
+ }
+})
+
+test('pushToLive uses DB lookup for expoPushToken when getExpoPushToken not injected', { timeout: 5000 }, async () => {
+ const expoPushRow = [{ expoPushToken: 'ExponentPushToken[db]' }]
+ const db = makeMockDb([[mockUser], [mockCharacter], expoPushRow])
+
+ const proactiveCalls: Array<{ token: string }> = []
+ const mockFcmDispatcher = {
+ wakeExtension: async () => {},
+ sendTaskComplete: async (token: string) => { proactiveCalls.push({ token }) },
+ sendProactive: async () => {},
+ }
+
+ let watchTaskCallback: ((task: unknown) => void) | null = null
+ const mockFirestoreSession = {
+ getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'tok', deviceName: 'Mac' }),
+ createSession: async () => {},
+ writeTask: async () => {},
+ closeSession: async () => {},
+ writeTaskResult: async () => {},
+ getTask: async () => ({ status: 'pending' }),
+ getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }),
+ abortPendingTaskIfOffline: async () => false,
+ watchTask: (_u: string, _s: string, _t: string, cb: (task: unknown) => void) => {
+ watchTaskCallback = cb
+ return () => {}
+ },
+ }
+
+ const mock = makeMockLiveConnect()
+ const { server, close } = createLiveTestServer({
+ db,
+ creditService: mockCreditService,
+ verifyToken: async () => ({ uid: 'fb-uid-2' }),
+ liveConnect: mock.connect,
+ browserBridge: {
+ firebaseUid: 'fb-uid-2',
+ userId: 'user-uuid-1',
+ firestoreSession: mockFirestoreSession as never,
+ fcmDispatcher: mockFcmDispatcher as never,
+ creditService: mockCreditService,
+ instanceId: 'inst-2',
+ wakeTimeoutMs: 50,
+ textTimeoutMs: 500,
+ },
+ })
+ try {
+ const port = await listen(server)
+
+ await new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`)
+ const timeout = setTimeout(() => reject(new Error('test timeout')), 4500)
+ ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token: 'v', characterId: CHAR_UUID })))
+ ws.on('message', (raw) => {
+ const msg = JSON.parse(raw.toString()) as { type: string }
+ if (msg.type !== 'session_ready') return
+ mock.triggerMessage({
+ toolCall: { functionCalls: [{ id: 'c2', name: 'browser_action', args: {
+ actionSummary: 'Extract', intent: { action: { type: 'extract', selector: '.p', label: 'p' } },
+ }}] },
+ })
+ setTimeout(() => ws.close(), 20)
+ })
+ ws.on('close', async () => {
+ await new Promise((r) => setTimeout(r, 100))
+ watchTaskCallback?.({ status: 'complete', result: { data: { p: 'x' }, activeUrl: 'https://a.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } })
+ await new Promise((r) => setTimeout(r, 100))
+ clearTimeout(timeout)
+ try {
+ assert.equal(proactiveCalls.length, 1)
+ assert.equal(proactiveCalls[0].token, 'ExponentPushToken[db]')
+ resolve()
+ } catch (e) { reject(e) }
+ })
+ ws.on('error', reject)
+ })
+ } finally {
+ await close()
+ }
+})
diff --git a/cloud-agent/src/handlers/wsLiveAgentHandler.ts b/cloud-agent/src/handlers/wsLiveAgentHandler.ts
index 6905b320..6ad85f75 100644
--- a/cloud-agent/src/handlers/wsLiveAgentHandler.ts
+++ b/cloud-agent/src/handlers/wsLiveAgentHandler.ts
@@ -16,6 +16,7 @@ import { hasGroundingData } from '../groundingMetadata.js'
import { defaultFcmDispatcher } from '../services/fcmDispatcher.js'
import { defaultFirestoreSession } from '../services/firestoreSession.js'
import { INSTANCE_ID } from '../services/instanceId.js'
+import { getExpoPushToken as dbGetExpoPushToken } from './expoPushToken.js'
export interface BillingControllerOpts {
spend: () => void
@@ -69,6 +70,8 @@ export interface WsLiveHandlerOptions {
billingIntervalMs?: number
_clearInterval?: (id: ReturnType | undefined) => void
browserBridge?: Omit
+ /** Injectable for testing; defaults to DB lookup. */
+ getExpoPushToken?: (firebaseUid: string) => Promise
}
const AUTH_TIMEOUT_MS = 5000
@@ -387,15 +390,32 @@ export async function handleLiveWsUpgrade(
activeBrowserCallId = null
}
},
- pushToLive: (taskId: string, text: string) => {
+ pushToLive: (taskId: string, bridgeSessionId: string, text: string) => {
const callId = browserCallByTaskId.get(taskId)
if (!callId) return
browserCallByTaskId.delete(taskId)
- try {
- geminiSession?.sendToolResponse({
- functionResponses: [{ id: callId, name: 'browser_action', response: { output: text } }],
- })
- } catch { /* ignore */ }
+ const sessionOpen = geminiSession !== null && ws.readyState === WebSocket.OPEN
+ if (sessionOpen) {
+ try {
+ geminiSession!.sendToolResponse({
+ functionResponses: [{ id: callId, name: 'browser_action', response: { output: text } }],
+ })
+ return
+ } catch (err) {
+ console.warn('[pushToLive] live tool response failed, falling back to Expo Push:', err)
+ }
+ }
+ // Voice session closed or live delivery failed — deliver via Expo Push fallback.
+ if (bridgeBase?.fcmDispatcher && bridgeBase.firebaseUid) {
+ const fwd = bridgeBase.fcmDispatcher
+ const fbUid = bridgeBase.firebaseUid
+ const getToken = options.getExpoPushToken ?? ((uid: string) => dbGetExpoPushToken(options.db, uid))
+ void getToken(fbUid)
+ .then(async (token) => {
+ if (token) await fwd.sendTaskComplete(token, bridgeSessionId, taskId, text)
+ })
+ .catch((err) => console.error('[pushToLive Expo fallback]', err))
+ }
},
})
: buildLiveTools(db, userId, characterId, embedText, timezone)
diff --git a/cloud-agent/src/index.test.ts b/cloud-agent/src/index.test.ts
index 9f0e10f7..87700ed5 100644
--- a/cloud-agent/src/index.test.ts
+++ b/cloud-agent/src/index.test.ts
@@ -438,3 +438,44 @@ test('POST /agent/run captures X-Timezone header and passes it to runAgentFn', a
.send({ message: 'hello', characterId: CHAR_UUID })
assert.equal(capturedTimezone, 'America/Chicago')
})
+
+test('POST /agent/browser/scheduler-trigger returns 401 with no secret', async () => {
+ const savedSecret = process.env.SCHEDULER_SECRET
+ process.env.SCHEDULER_SECRET = 'test-scheduler-secret-for-index'
+ try {
+ const db = makeMockDb()
+ const app = createApp({
+ verifyToken: async () => ({ uid: 'uid' }),
+ db,
+ runAgentFn: async () => ({ reply: 'ok', toolCalls: [] }),
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'p' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 401)
+ } finally {
+ if (savedSecret === undefined) delete process.env.SCHEDULER_SECRET
+ else process.env.SCHEDULER_SECRET = savedSecret
+ }
+})
+
+test('POST /agent/browser/scheduler-trigger returns 503 when SCHEDULER_SECRET not set', async () => {
+ const saved = process.env.SCHEDULER_SECRET
+ delete process.env.SCHEDULER_SECRET
+ try {
+ const db = makeMockDb()
+ const app = createApp({
+ verifyToken: async () => ({ uid: 'uid' }),
+ db,
+ runAgentFn: async () => ({ reply: 'ok', toolCalls: [] }),
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', 'Bearer anything')
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'p' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 503)
+ } finally {
+ if (saved === undefined) delete process.env.SCHEDULER_SECRET
+ else process.env.SCHEDULER_SECRET = saved
+ }
+})
diff --git a/cloud-agent/src/index.ts b/cloud-agent/src/index.ts
index e501963c..e2766f79 100644
--- a/cloud-agent/src/index.ts
+++ b/cloud-agent/src/index.ts
@@ -25,6 +25,7 @@ import { defaultFcmDispatcher } from './services/fcmDispatcher.js'
import { upsertDeviceRecord } from './services/deviceUpsert.js'
import { upsertExpoPushToken, getExpoPushToken } from './handlers/expoPushToken.js'
import { handleApproveAction } from './handlers/approveAction.js'
+import { createSchedulerTriggerHandler, createRequireSchedulerSecret } from './handlers/schedulerTriggerHandler.js'
import { INSTANCE_ID } from './services/instanceId.js'
import { z } from 'zod'
@@ -216,6 +217,14 @@ export function createApp(options: AppOptions) {
handler: rateLimitHandler,
})
+ const schedulerTriggerLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ limit: 10,
+ standardHeaders: 'draft-8',
+ legacyHeaders: false,
+ handler: rateLimitHandler,
+ })
+
app.post('/agent/run', agentRunLimiter, requireAuth, async (req: Request & { uid?: string }, res: Response): Promise => {
try {
const parseResult = z
@@ -386,6 +395,38 @@ export function createApp(options: AppOptions) {
}
})
+ let schedulerHandler: ReturnType | undefined
+
+ app.post('/agent/browser/scheduler-trigger', schedulerTriggerLimiter, (req: Request, res: Response, next: NextFunction): void => {
+ const secret = process.env.SCHEDULER_SECRET
+ if (!secret) {
+ res.status(503).json({ error: 'Scheduler trigger not configured' })
+ return
+ }
+ createRequireSchedulerSecret(secret)(req, res, next)
+ }, (req: Request, res: Response, next: NextFunction): void => {
+ if (!browserBridgeAvailable) {
+ res.status(503).json({ error: 'Browser bridge unavailable' })
+ return
+ }
+ next()
+ }, (req: Request, res: Response): void => {
+ if (!schedulerHandler) {
+ schedulerHandler = createSchedulerTriggerHandler(
+ defaultFirestoreSession(),
+ defaultFcmDispatcher(),
+ (firebaseUid: string) => getExpoPushToken(db, firebaseUid),
+ cs,
+ async (firebaseUid: string) => {
+ const [u] = await db.select({ id: users.id }).from(users).where(eq(users.firebaseUid, firebaseUid))
+ return u?.id ?? null
+ },
+ { schedulerTimeoutMs: 90_000 },
+ )
+ }
+ void schedulerHandler(req, res)
+ })
+
return app
}
diff --git a/cloud-agent/src/services/fcmDispatcher.test.ts b/cloud-agent/src/services/fcmDispatcher.test.ts
index d79b0d6c..e3f254fa 100644
--- a/cloud-agent/src/services/fcmDispatcher.test.ts
+++ b/cloud-agent/src/services/fcmDispatcher.test.ts
@@ -72,6 +72,34 @@ test('sendTaskComplete POSTs correct Expo Push payload', async () => {
assert.equal(data.deepLink, '/talk')
})
+test('sendProactive POSTs PROACTIVE_TASK Expo Push payload', async () => {
+ const fetched: Array<{ body: unknown }> = []
+ const fakeFetch = async (_url: string, opts: RequestInit) => {
+ fetched.push({ body: JSON.parse(opts.body as string) })
+ return { ok: true, json: async () => ({ data: [{ status: 'ok' }] }) }
+ }
+
+ const dispatcher = createFcmDispatcher(
+ { send: async () => 'msg-id' },
+ fakeFetch as unknown as typeof fetch,
+ )
+
+ await dispatcher.sendProactive('ExponentPushToken[abc]', 'sid1', 'tid1', 'Price dropped to $340.')
+
+ assert.equal(fetched.length, 1)
+ const body = fetched[0].body as Record
+ assert.equal(body.to, 'ExponentPushToken[abc]')
+ assert.equal(body.title, 'Clanker noticed something')
+ assert.equal(body.body, 'Price dropped to $340.')
+ assert.equal(body.categoryIdentifier, 'BROWSER_ACTION_APPROVAL')
+ assert.equal(body.priority, 'high')
+ const data = body.data as Record
+ assert.equal(data.type, 'PROACTIVE_TASK')
+ assert.equal(data.sessionId, 'sid1')
+ assert.equal(data.taskId, 'tid1')
+ assert.equal(data.deepLink, '/talk')
+})
+
test('expoPush rejects ticket-level errors in a 200 response', async () => {
const fakeFetch = async () => ({
ok: true,
diff --git a/cloud-agent/src/services/fcmDispatcher.ts b/cloud-agent/src/services/fcmDispatcher.ts
index 31749316..67c38115 100644
--- a/cloud-agent/src/services/fcmDispatcher.ts
+++ b/cloud-agent/src/services/fcmDispatcher.ts
@@ -64,6 +64,17 @@ export function createFcmDispatcher(messaging: MessagingLike, fetchImpl: typeof
priority: 'normal',
})
},
+
+ async sendProactive(expoPushToken: string, sessionId: string, taskId: string, body: string): Promise {
+ await expoPush({
+ to: expoPushToken,
+ title: 'Clanker noticed something',
+ body,
+ data: { type: 'PROACTIVE_TASK', sessionId, taskId, deepLink: '/talk' },
+ categoryIdentifier: 'BROWSER_ACTION_APPROVAL',
+ priority: 'high',
+ })
+ },
}
}
diff --git a/cloud-agent/src/services/firestoreSession.test.ts b/cloud-agent/src/services/firestoreSession.test.ts
index ee799900..953349cf 100644
--- a/cloud-agent/src/services/firestoreSession.test.ts
+++ b/cloud-agent/src/services/firestoreSession.test.ts
@@ -8,6 +8,15 @@ function makeFakeDb(calls?: Array<{ path: string; data: Record;
function docRef(path: string) {
return {
path,
+ async create(data: Record) {
+ if (store.has(path)) {
+ const err = new Error('Already exists') as Error & { code: number }
+ err.code = 6
+ throw err
+ }
+ calls?.push({ path, data })
+ store.set(path, data)
+ },
async set(data: Record, opts?: { merge?: boolean }) {
calls?.push({ path, data, opts })
store.set(path, opts?.merge ? { ...(store.get(path) ?? {}), ...data } : data)
@@ -71,6 +80,16 @@ function makeFakeDb(calls?: Array<{ path: string; data: Record;
const { createFirestoreSession } = await import('./firestoreSession.js')
+test('reserveSchedulerRun returns duplicate for same runKey', async () => {
+ const { db } = makeFakeDb()
+ const fs = createFirestoreSession(db as never)
+ const ids = { sessionId: 's1', taskId: 't1' }
+ assert.equal(await fs.reserveSchedulerRun('u1', 'run-a', ids), 'reserved')
+ assert.equal(await fs.reserveSchedulerRun('u1', 'run-a', { sessionId: 's2', taskId: 't2' }), 'duplicate')
+ const existing = await fs.getSchedulerRun('u1', 'run-a')
+ assert.deepEqual(existing, ids)
+})
+
test('createSession + getSession round-trip', async () => {
const { db } = makeFakeDb()
const fs = createFirestoreSession(db as never)
diff --git a/cloud-agent/src/services/firestoreSession.ts b/cloud-agent/src/services/firestoreSession.ts
index 3249b84d..3a91ab0b 100644
--- a/cloud-agent/src/services/firestoreSession.ts
+++ b/cloud-agent/src/services/firestoreSession.ts
@@ -11,6 +11,7 @@ export interface FirestoreBatch {
export interface FirestoreLike {
doc(path: string): {
set(data: Record, opts?: { merge?: boolean }): Promise
+ create?(data: Record): Promise
get(): Promise<{ exists: boolean; data(): Record | undefined }>
update(data: Record): Promise
onSnapshot?(cb: (snap: { exists: boolean; data(): Record | undefined }) => void): () => void
@@ -45,6 +46,7 @@ export function createFirestoreSession(db: FirestoreLike) {
const sessionPath = (uid: string, sid: string) => `users/${uid}/sessions/${sid}`
const taskPath = (uid: string, sid: string, tid: string) => `users/${uid}/sessions/${sid}/tasks/${tid}`
const devicesPath = (uid: string) => `users/${uid}/devices`
+ const schedulerRunPath = (uid: string, runKey: string) => `users/${uid}/schedulerRuns/${runKey}`
return {
async getActiveDevice(uid: string): Promise<{ deviceId: string; fcmToken: string; deviceName: string } | null> {
@@ -168,6 +170,46 @@ export function createFirestoreSession(db: FirestoreLike) {
}
},
+ /**
+ * Atomically reserve a scheduler run key before spending credit or creating tasks.
+ * Returns existing session/task IDs when Cloud Scheduler retries a prior execution.
+ */
+ async reserveSchedulerRun(
+ uid: string,
+ runKey: string,
+ ids: { sessionId: string; taskId: string },
+ ): Promise<'reserved' | 'duplicate'> {
+ const ref = db.doc(schedulerRunPath(uid, runKey))
+ const payload = { sessionId: ids.sessionId, taskId: ids.taskId, createdAt: now() }
+ if (ref.create) {
+ try {
+ await ref.create(payload)
+ return 'reserved'
+ } catch (err: unknown) {
+ const code = (err as { code?: number | string })?.code
+ if (code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS') {
+ return 'duplicate'
+ }
+ throw err
+ }
+ }
+ const existing = await ref.get()
+ if (existing.exists) return 'duplicate'
+ await ref.set(payload)
+ return 'reserved'
+ },
+
+ async getSchedulerRun(
+ uid: string,
+ runKey: string,
+ ): Promise<{ sessionId: string; taskId: string } | null> {
+ const doc = await db.doc(schedulerRunPath(uid, runKey)).get()
+ if (!doc.exists) return null
+ const data = doc.data() as { sessionId?: string; taskId?: string }
+ if (!data.sessionId || !data.taskId) return null
+ return { sessionId: data.sessionId, taskId: data.taskId }
+ },
+
watchAuth(uid: string, sid: string, tid: string, cb: (auth: AuthDoc) => void): () => void {
const authPath = `users/${uid}/sessions/${sid}/auth/${tid}`
const ref = db.doc(authPath)
@@ -184,7 +226,13 @@ export type FirestoreSession = ReturnType
export function defaultFirestoreSession(): FirestoreSession {
const raw = admin.firestore()
const db: FirestoreLike = {
- doc: (path) => raw.doc(path) as FirestoreLike['doc'] extends (p: string) => infer R ? R : never,
+ doc: (path) => {
+ const ref = raw.doc(path)
+ return {
+ ...(ref as FirestoreLike['doc'] extends (p: string) => infer R ? R : never),
+ create: (data: Record) => ref.create(data),
+ }
+ },
collection: (path) => raw.collection(path) as unknown as CollectionQuery,
batch: () => {
const batch = raw.batch()
diff --git a/cloud-agent/src/services/liveToolAdapter.ts b/cloud-agent/src/services/liveToolAdapter.ts
index 015b0d32..fc4095a9 100644
--- a/cloud-agent/src/services/liveToolAdapter.ts
+++ b/cloud-agent/src/services/liveToolAdapter.ts
@@ -34,7 +34,7 @@ export function buildLiveTools(
embed: EmbedFn,
timezone: string,
bridge?: Omit & {
- pushToLive?: (taskId: string, t: string) => void
+ pushToLive?: (taskId: string, sessionId: string, t: string) => void
pauseBilling?: () => void
resumeBilling?: () => void
registerLiveCall?: (taskId: string) => void
diff --git a/cloud-agent/src/tools/browserAction.test.ts b/cloud-agent/src/tools/browserAction.test.ts
index c327d8a0..8e093bcf 100644
--- a/cloud-agent/src/tools/browserAction.test.ts
+++ b/cloud-agent/src/tools/browserAction.test.ts
@@ -124,7 +124,7 @@ test('voice execution timeout resumes billing and pushes timeout message', async
firestoreSession: fs,
textTimeoutMs: 30,
resumeBilling: () => { resumed = true },
- pushToLive: (_taskId: string, text: string) => { pushed.push(text) },
+ pushToLive: (_taskId: string, _sessionId: string, text: string) => { pushed.push(text) },
})
const tool = browserActionTool(deps as never, { trigger: 'voice', preBilled: false })
await (tool as unknown as { execute: (a: unknown) => Promise }).execute({
@@ -157,7 +157,7 @@ test('voice path: awaiting_auth narrates pause, resumes billing, ends turn (no E
fcmDispatcher: { wakeExtension: async () => {} } as never,
creditService: { spendCredit: async () => 'tx1', refundCredit: async () => {} } as never,
instanceId: 'i-test',
- pushToLive: (_taskId: string, msg: string) => { pushed.push(msg) },
+ pushToLive: (_taskId: string, _sessionId: string, msg: string) => { pushed.push(msg) },
pauseBilling: () => {},
resumeBilling: () => { resumed = true },
wakeTimeoutMs: 50,
diff --git a/cloud-agent/src/tools/browserAction.ts b/cloud-agent/src/tools/browserAction.ts
index c1464724..91a673dd 100644
--- a/cloud-agent/src/tools/browserAction.ts
+++ b/cloud-agent/src/tools/browserAction.ts
@@ -18,7 +18,7 @@ export interface BrowserActionDeps {
pauseBilling?: () => void
resumeBilling?: () => void
// Voice-only: push the final result into the live Gemini session.
- pushToLive?: (taskId: string, text: string) => void
+ pushToLive?: (taskId: string, sessionId: string, text: string) => void
/** Voice-only: correlate an in-flight browser_action tool call with its taskId. */
registerLiveCall?: (taskId: string) => void
wakeTimeoutMs?: number // default 12_000
@@ -173,7 +173,7 @@ export function browserActionTool(
// Voice: resolve final result out-of-band into the live session; return interim now.
void waitForTerminalTask().then((task) => {
deps.resumeBilling?.()
- deps.pushToLive?.(taskId, formatResult(task))
+ deps.pushToLive?.(taskId, sessionId, formatResult(task))
})
return 'Sent the task to your browser. I\'ll read the result aloud when it arrives.'
},
diff --git a/docs/ai-and-chat.md b/docs/ai-and-chat.md
index 8a67d4b0..ddd94b01 100644
--- a/docs/ai-and-chat.md
+++ b/docs/ai-and-chat.md
@@ -8,6 +8,8 @@ For the on-device edge agent loop (client orchestration + secured `generateReply
For continuous voice calls on the Talk tab (Gemini Live via Cloud Agent `/agent/live`), see **[Real-Time Voice Chat](real-time-voice-chat.md)**.
+For cross-device browser tasks via the Desktop Bridge extension (`browser_action` tool), see **[Browser Bridge](browser-bridge.md)**.
+
---
## Chat Response Pipeline
diff --git a/docs/architecture-and-data.md b/docs/architecture-and-data.md
index 6d14f308..b31e46d5 100644
--- a/docs/architecture-and-data.md
+++ b/docs/architecture-and-data.md
@@ -23,6 +23,8 @@
The **Talk tab** uses `liveVoiceMachine` (via `useLiveVoiceChat`) for continuous voice calls over Cloud Agent `/agent/live`. That flow is independent of the text-chat edge agent loop in `useAIChat` / `useEdgeAgent` — it owns its own WebSocket lifecycle, audio I/O, and end-of-call SQLite persistence. See [Real-Time Voice Chat](real-time-voice-chat.md).
+**Browser bridge coordination** uses **Firestore** (not local SQLite). Cloud Agent writes session, task, device, and auth docs under `users/{uid}/` for cross-device browser tasks triggered by `browser_action`. The mobile app reads session/task docs and writes approval decisions; the Desktop Bridge extension pairs via `POST /agent/browser/register-device`. Local SQLite remains the store for chat messages, characters, and wiki — browser task state lives in Firestore until the session ends. See [Browser Bridge](browser-bridge.md).
+
**When to add a new machine:** Create for features with two or more of: multiple sequential async steps, optimistic updates with rollback, complex conditional transitions, long-running background work, explicit loading/idle/error/success states needing isolated testing. Simple one-shot operations should use TanStack Query mutations instead.
**How to add a new machine:**
diff --git a/docs/billing-and-credits.md b/docs/billing-and-credits.md
index 0e1ce1ae..6a57c2c8 100644
--- a/docs/billing-and-credits.md
+++ b/docs/billing-and-credits.md
@@ -18,6 +18,23 @@ Handled provider-side: Stripe, Apple App Store, and Google Play manage refund me
---
+## Browser Action Billing
+
+The `browser_action` tool (Desktop Bridge extension) uses **contextual billing** to avoid double-charging:
+
+| Path | Timer billing | `browser_action` flat billing |
+|------|--------------|-------------------------------|
+| Voice (`/agent/live`) | Wall-clock timer **paused** during wake + execution | `spendCredit` after paired device found |
+| Text (`POST /agent/run`) | N/A — 1 credit pre-spent per turn | Skip `spendCredit` (`preBilled: true`) |
+
+**No credit spent** when no paired device is registered or when the device is paused (`isPaused: true`).
+
+**Refunds:** Voice path refunds the `browser_action` credit on `EXTENSION_OFFLINE` (12s wake timeout, extension never connected). No refund on execution errors (`SELECTOR_NOT_FOUND`, `EXECUTION_TIMEOUT`, etc.) — the extension connected and attempted the task.
+
+See **[Browser Bridge](browser-bridge.md)** for the full billing and lifecycle context.
+
+---
+
## First Login Credits
New users receive **50 free credits** upon their first login, seeded by the Cloud SQL bootstrap flow.
diff --git a/docs/browser-bridge.md b/docs/browser-bridge.md
new file mode 100644
index 00000000..bd03c999
--- /dev/null
+++ b/docs/browser-bridge.md
@@ -0,0 +1,184 @@
+# Browser Bridge (Desktop Extension)
+
+## Overview
+
+The **Clanker Desktop Bridge** is an MV3 Chrome extension that lets your Clanker agent perform web tasks on your desktop browser when you explicitly request them — reading pages, extracting data, navigating, and (with approval) stateful actions like form fills and clicks.
+
+Cross-device flow: you speak or type on mobile (or escalate text chat to Cloud Agent); Cloud Agent wakes the extension via FCM; the extension executes a Task DSL in your authenticated browser sessions and returns results through Firestore.
+
+The edge agent and Firebase `generateReply` path **cannot** invoke `browser_action`. Only Cloud Agent (`/agent/live` voice or `/agent/run` text) has the tool, Firestore coordination, and FCM dispatch.
+
+For C4 architecture diagrams, see [Architecture Charts — C4](flowcharts/c4/system-context.md#browser-bridge-routing-desktop-bridge-extension).
+
+---
+
+## Three-Node Architecture
+
+| Node | Role | Connection |
+|------|------|------------|
+| Mobile app | Voice/text I/O, approval UI, Expo Push receiver | `/agent/live` or `/agent/run` |
+| Cloud Agent | Session router, Firestore writer, FCM dispatcher | Per-instance `sessionBridge` (same-instance shortcut only) |
+| Desktop Bridge extension | DOM executor | Idle (FCM) → active (`/agent/browser` WebSocket) |
+
+**Key invariant:** Cloud Run instances never communicate directly. All cross-instance routing flows through Firestore (`users/{uid}/sessions/{sessionId}/tasks/{taskId}`). Voice and browser WebSockets may land on different instances; the voice-side `watchTask` listener is the primary result-delivery path.
+
+---
+
+## Triggering Tasks: `browser_action`
+
+Gemini invokes the `browser_action` ADK tool (`cloud-agent/src/tools/browserAction.ts`) with an `actionSummary` and Task DSL `intent`. The tool handler:
+
+1. Resolves a paired, non-paused device (`getActiveDevice`) — **no credit spent** if none found
+2. Creates `sessionId` + `taskId`, writes session/task docs to Firestore
+3. Dispatches FCM `WAKE_AND_CONNECT` to the extension
+4. Delivers results:
+ - **Voice:** interim tool response immediately; final result pushed into Gemini Live via `pushToLive`
+ - **Text:** `await`s `watchTask` with a 30s cap (GCP load balancer ceiling)
+
+Wiring:
+
+| Entry point | Injection site |
+|-------------|----------------|
+| Voice (`/agent/live`) | `buildLiveTools` in `liveToolAdapter.ts` |
+| Text (`/agent/run`) | `buildAgent` in `agentCore.ts` |
+
+Bridge routes are registered only when Firebase Admin is initialized (`admin.apps.length > 0`).
+
+---
+
+## Wake-and-Connect Lifecycle
+
+```text
+FCM WAKE_AND_CONNECT
+ → extension service worker wakes
+ → offscreen Firebase Auth → idToken
+ → WebSocket /agent/browser auth frame { sessionId, deviceId, idToken }
+ → Cloud Agent markBrowserConnected → session_ready
+ → Task DSL dispatched to content scripts (chrome.scripting.executeScript)
+ → task_result via WS → Firestore → voice/text response
+ → session_end → extension suspends
+```
+
+**Wake timeout (12s):** If the extension does not connect, Cloud Agent writes `EXTENSION_OFFLINE` to Firestore (queries durable state, never local `sessionBridge`). Voice path refunds the `browser_action` credit.
+
+**Auth frame timeout (5s):** Browser-side handler closes the socket with code 4001 if no auth frame arrives.
+
+---
+
+## Task DSL (summary)
+
+| Tier | Actions |
+|------|---------|
+| Read + navigation | `extract`, `summarize_visible_text`, `read_dom`, `open_tab`, `focus_tab`, `scroll` |
+| Stateful (approval-gated) | `fill_field`, `click` |
+
+Destructive actions use a two-layer classifier sharing `DESTRUCTIVE_ACTION_PATTERN` in `shared/constants.ts`:
+
+1. **Cloud Coordinator** — sets `requiresAuth` on intent generation
+2. **Extension** — Layer 2 DOM classifier can escalate; never downgrade
+
+On halt: extension sends `awaiting_auth` → Cloud Agent writes auth doc → Expo Push approval card → mobile approves via `POST /agent/browser/approve-action` → FCM re-wake with resume from `haltedStepIndex`.
+
+Canonical types: `shared/dsl-types.ts` (mirrored in `extension/src/shared/dsl-types.ts`).
+
+---
+
+## Device Pairing
+
+1. User signs in via extension side panel (Firebase Auth via offscreen document)
+2. On install: `chrome.gcm.register` → store token → `POST /agent/browser/register-device`
+3. Cloud Agent upserts `users/{uid}/devices/{deviceId}` with `fcmToken`, `deviceName`, `lastSeenAt`
+4. Extension re-POSTs on `session_ready` to refresh `lastSeenAt`
+
+**Pause kill switch:** Side panel writes `isPaused: true` via register-device upsert. Paused devices are excluded from `getActiveDevice` — tool returns an error without spending credits.
+
+**Host permissions:** Extension uses `optional_host_permissions: [""]`. First task on a new host triggers a notification → side panel grant button (`chrome.permissions.request` requires user gesture).
+
+---
+
+## HTTP / WebSocket Endpoints
+
+| Route | Purpose |
+|-------|---------|
+| `POST /agent/browser/register-device` | Upsert device doc (fcmToken, deviceId, deviceName, isPaused) |
+| `POST /agent/browser/approve-action` | Mobile approval/denial for destructive actions |
+| `POST /agent/user/expo-push-token` | Register Expo push token for approval cards |
+| `POST /agent/browser/scheduler-trigger` | Cloud Scheduler proactive tasks (Phase 2+) |
+| WebSocket `/agent/browser` | Extension auth frame, task dispatch, results |
+
+---
+
+## Billing
+
+`browser_action` uses **contextual billing** to avoid double-charging:
+
+| Path | Timer billing | `browser_action` flat billing |
+|------|--------------|-------------------------------|
+| Voice (`/agent/live`) | Wall-clock timer **paused** during wake + execution | `spendCredit` after device found |
+| Text (`/agent/run`) | N/A (1 credit pre-spent per turn) | Skip `spendCredit` (`preBilled: true`) |
+
+Refunds apply on `EXTENSION_OFFLINE` (voice path only, when a credit was spent). No refund on execution errors — the extension connected and attempted the task.
+
+See [Billing & Credits](billing-and-credits.md#browser-action-billing) for details.
+
+---
+
+## Firestore Schema (summary)
+
+All docs under `users/{uid}/` for tenant isolation. Clients read; Cloud Agent Admin SDK owns writes.
+
+| Path | Purpose |
+|------|---------|
+| `sessions/{sessionId}` | Bridge session status, instance IDs, TTL |
+| `sessions/{sessionId}/tasks/{taskId}` | Task intent, status, result, `haltedStepIndex` |
+| `sessions/{sessionId}/auth/{taskId}` | Approval status, `approvalToken` (mobile writes approval only) |
+| `devices/{deviceId}` | FCM token, `isPaused`, `lastSeenAt` |
+
+Security rules: `firestore.rules` at repo root.
+
+---
+
+## Extension Development
+
+```bash
+cd extension
+npm install
+npm run build # esbuild → dist/ (env from repo-root .env)
+npm run typecheck
+npm test
+```
+
+Load `extension/dist/` as an unpacked extension in Chrome. Requires repo-root `.env` / `.env.development.local` with `EXPO_PUBLIC_FIREBASE_*` and `EXPO_PUBLIC_CLOUD_AGENT_URL`.
+
+Key directories:
+
+| Path | Role |
+|------|------|
+| `extension/src/background/service-worker.ts` | FCM receiver, WS lifecycle |
+| `extension/src/background/ws-client.ts` | WebSocket + heartbeat |
+| `extension/src/background/task-dispatcher.ts` | Task DSL routing |
+| `extension/src/content/executor.ts` | DOM action runners |
+| `extension/src/ui/side-panel/` | Sign-in, status, pause, host grant |
+
+---
+
+## Cloud Agent Development
+
+Browser bridge requires Firebase Admin (Firestore + FCM). Local Docker setup: see [AI & Chat — Local Development](ai-and-chat.md#local-development-cloud-agent).
+
+```bash
+cd cloud-agent
+npm run typecheck
+npm test
+```
+
+---
+
+## Related Documentation
+
+- **[AI & Chat](ai-and-chat.md)** — Cloud Agent text paths, local dev
+- **[Real-Time Voice Chat](real-time-voice-chat.md)** — `/agent/live` sessions, `browser_action` during calls
+- **[Edge Agent](edge-agent.md)** — On-device text loop (no `browser_action`)
+- **[Billing & Credits](billing-and-credits.md)** — Credit ledger, contextual `browser_action` billing
+- **[Architecture & Data](architecture-and-data.md)** — Firestore bridge vs local SQLite
+- **[Architecture Charts — C4](flowcharts/c4/containers.md)** — Container diagram with browser bridge routing
diff --git a/docs/cws-submission-artifacts.md b/docs/cws-submission-artifacts.md
new file mode 100644
index 00000000..9e5ce8ac
--- /dev/null
+++ b/docs/cws-submission-artifacts.md
@@ -0,0 +1,130 @@
+# CWS Submission Artifacts — Clanker Desktop Bridge
+
+Copy-paste content for the Chrome Web Store developer dashboard.
+Generated from spec `docs/superpowers/specs/2026-06-30-cws-extension-readiness-design.md`.
+
+---
+
+## Store Listing
+
+**Extension name:** `Clanker Desktop Bridge`
+
+**Short description (132-char max):**
+```text
+Remote browser bridge for Clanker AI. Lets your AI assistant perform web tasks you explicitly request on this browser.
+```
+
+**Detailed description:**
+```text
+Clanker Desktop Bridge connects your Clanker AI assistant (iOS and Android) to your desktop browser so it can help you with web tasks you explicitly request — reading articles, extracting data, opening tabs, and (with your approval) filling fields or clicking buttons.
+
+HOW IT WORKS
+When you ask Clanker to do something on your browser, the app sends a silent notification to this extension. The extension wakes, authenticates, and performs only the task you requested. It then goes idle. It does not run in the background between tasks.
+
+WHAT IT ACCESSES
+The extension only reads or interacts with web pages during an active task triggered by you. It never passively monitors your browsing, collects URLs, or tracks history.
+
+PERMISSIONS
+All host permissions are optional and granted by you per-site. You will see a prompt the first time Clanker needs access to a new site. You can pause or revoke access at any time from the side panel.
+
+PRIVACY
+No browsing data is collected or sold. Task results are sent only to your own Clanker account. See clanker-ai.com/privacy for full details.
+```
+
+---
+
+## Permission Justifications
+
+Paste each block into its matching field on the CWS dashboard.
+
+**`scripting`**
+```text
+Injects the task executor into the active tab during a user-triggered task. Scripts are injected programmatically per-task only — never declared in content_scripts, never running between tasks.
+```
+
+**`tabs`**
+```text
+The Clanker extension acts as a remote bridge for the user's mobile AI assistant. Because tasks are triggered remotely via background Google Cloud Messaging (FCM) pushes rather than direct clicks on the extension icon, we cannot rely on the activeTab permission (which requires a manual user gesture). We require the tabs permission to use chrome.tabs.query() to locate the user's active tab for script injection during these background wakes, as well as chrome.tabs.create() and chrome.tabs.update() to allow the AI to open and focus new web pages as explicitly commanded by the user.
+```
+
+**`storage`**
+```text
+Stores device ID, GCM registration token, pause state, action log (last 50 entries), and pending host permission state in chrome.storage.local. No browsing history or page content is stored.
+```
+
+**`sidePanel`**
+```text
+Provides the primary user interface: sign-in, device registration status, action log, pause toggle, and the host permission grant button. chrome.permissions.request() requires a user gesture — the side panel Grant Access button provides it.
+```
+
+**`notifications`**
+```text
+Shows a single notification when the extension lacks permission to access a host that a user-requested task targets. The notification prompts the user to open the side panel and tap Grant Access. No other notification types are used.
+```
+
+**`gcm`**
+```text
+Registers with Firebase Cloud Messaging via chrome.gcm.register() and listens for incoming silent pushes via chrome.gcm.onMessage. FCM is the sole mechanism that wakes the extension when the user's Clanker assistant has a task ready. Without this permission the extension cannot receive tasks from the mobile app.
+```
+
+**`offscreen`**
+```text
+Hosts the Firebase Auth Web SDK (firebase/auth/web-extension) in an offscreen document. MV3 service workers cannot access DOM storage APIs required for auth token persistence. The offscreen document is created only during an active bridge session and closed immediately after SESSION_END.
+```
+
+**`optional_host_permissions: [""]`**
+
+> Paste this into the **"Single purpose description"** field. Also ensure it appears in the detailed store description above.
+
+```text
+Host access is entirely optional and user-granted per-site at runtime. The extension cannot declare a fixed list of hosts at install time because the user's AI assistant may target any site the user requests. Chrome prompts the user the first time a new host is needed. The user explicitly taps a "Grant Access" button in the side panel (a required user gesture — chrome.permissions.request() cannot be called from a service worker). Users can revoke permissions at any time via chrome://extensions. No host permission is ever requested proactively or silently.
+```
+
+---
+
+## Privacy Policy
+
+The browser extension data usage section is already in `src/config/privacyConfig.ts` (added in commit `21d4eab7`, v1.5). Verify it is live at `clanker-ai.com/privacy` before CWS submission.
+
+The CWS reviewer looks for a **dedicated section with this exact heading**: `## Clanker Browser Extension Data Usage`. The section must include the Limited Use Disclosure paragraph verbatim.
+
+Required section (verify against live site):
+```text
+## Clanker Browser Extension Data Usage
+
+The Clanker Chrome Extension acts as a secure bridge between your desktop browser
+and the Clanker AI ecosystem. To comply with the Chrome Web Store User Data Policy,
+we explicitly state the following:
+
+**Single Purpose:** The sole purpose of the extension is to allow the Clanker AI
+to read, summarize, and interact with the web pages you explicitly command it to.
+
+**Data Collection:** The extension only extracts text, URLs, and DOM structure from
+your active tab when a specific task is triggered (either via scheduled automation
+or remote command). We do not passively track your browsing history or monitor
+background tabs.
+
+**Data Transmission:** Extracted page data is transmitted securely to our cloud
+infrastructure strictly to process your AI prompt.
+
+**Limited Use Disclosure:** The extension's use and transfer to any other app of
+information received from Google APIs will adhere to the Chrome Web Store User Data
+Policy, including the Limited Use requirements.
+
+**No Data Sale:** We do not sell your browser data to third parties. Your data is
+not used for advertising, creditworthiness, or lending purposes.
+```
+
+---
+
+## Pre-Submission Preflight Checklist
+
+- [ ] `dist/manifest.json` has `icons.128`
+- [ ] `dist/manifest.json` has `action.default_icon.128`
+- [ ] `dist/manifest.json` has `content_security_policy.extension_pages`
+- [ ] `dist/manifest.json` has `key` field (from 1Password `.pem`)
+- [ ] `grep -RIn "innerHTML[[:space:]]*=" extension/dist/ui/side-panel/panel.js` returns no output
+- [ ] `auth-bridge.ts` justification string names `firebase/auth/web-extension` and explains service worker constraint
+- [ ] Privacy policy live at `clanker-ai.com/privacy` with `## Clanker Browser Extension Data Usage` heading
+- [ ] All permission justifications filled in CWS dashboard
+- [ ] Store listing short description ≤ 132 chars
diff --git a/docs/edge-agent.md b/docs/edge-agent.md
index 0a51e511..5245f3cb 100644
--- a/docs/edge-agent.md
+++ b/docs/edge-agent.md
@@ -8,6 +8,8 @@ This matches the app-wide AI access policy in [AI & Chat](ai-and-chat.md): produ
**Talk tab live voice** is a separate path: continuous Gemini Live over Cloud Agent `/agent/live`, not the edge agent loop. See **[Real-Time Voice Chat](real-time-voice-chat.md)**.
+**Desktop browser tasks** are a separate path: Cloud Agent `browser_action` tool wakes the MV3 Desktop Bridge extension. The edge agent does not register or execute `browser_action` — it requires Cloud Agent, Firestore coordination, and a paired extension. See **[Browser Bridge](browser-bridge.md)**.
+
---
## Architecture
@@ -81,6 +83,8 @@ After the edge loop, `useAIChat` routes in priority order:
`escalate_to_cloud_agent` is only advertised in tool schemas when `isCloudSynced` is true (`character.save_to_cloud`). Local-only characters cannot escalate via that tool.
+Cloud Agent escalation (and live voice) can invoke `browser_action` for desktop browser tasks. The edge agent loop never sees this tool — it is injected only in Cloud Agent ADK tool sets (`liveToolAdapter.ts`, `agentCore.ts`).
+
### Escalation phantom tools
`set_reminder` is cloud-only; the edge executor returns a sentinel that triggers escalation when cloud sync is enabled.
@@ -152,5 +156,6 @@ For local cloud-agent iteration, set `EXPO_PUBLIC_CLOUD_AGENT_URL` to your Docke
## Related Documentation
- [AI & Chat](ai-and-chat.md) — `generateReply` contract, wiki memory, cloud-agent local dev
+- [Browser Bridge](browser-bridge.md) — Desktop extension, `browser_action`, Wake-and-Connect (cloud-only)
- [Real-Time Voice Chat](real-time-voice-chat.md) — Talk tab Gemini Live sessions (`/agent/live`), separate from edge agent
- [Billing & Credits](billing-and-credits.md) — credit ledger and subscription tiers
diff --git a/docs/flowcharts/README.md b/docs/flowcharts/README.md
index c610a8d8..4cde473f 100644
--- a/docs/flowcharts/README.md
+++ b/docs/flowcharts/README.md
@@ -11,7 +11,7 @@ High-level diagrams maintained by hand. Update when system boundaries or integra
| `c4/system-context.md` | Level 1: Clanker and its external dependencies |
| `c4/containers.md` | Level 2: Internal containers (app, functions, databases) |
-For text chat architecture (edge agent, BYOI proxy, escalation paths), see [Edge Agent](../edge-agent.md). C4 diagrams also cover **live voice routing** on the Talk tab (`/agent/live`).
+For text chat architecture (edge agent, BYOI proxy, escalation paths), see [Edge Agent](../edge-agent.md). C4 diagrams also cover **live voice routing** on the Talk tab (`/agent/live`) and **browser bridge routing** (Desktop Bridge extension, `browser_action`, Firestore coordination).
## Dependency Overview (auto-generated)
diff --git a/docs/flowcharts/c4/containers.md b/docs/flowcharts/c4/containers.md
index b92f8e6f..e1af8041 100644
--- a/docs/flowcharts/c4/containers.md
+++ b/docs/flowcharts/c4/containers.md
@@ -9,7 +9,7 @@ C4Container
Person(user, "User", "Mobile voice/text user; desktop Chrome user with paired extension")
System_Boundary(clanker_b, "Clanker") {
- Container(app, "Clanker App", "Expo React Native (shared mobile/web)", "UI plus edge agent orchestration (useEdgeAgent) for text chat; Talk tab live voice via XState + WebSocket /agent/live. Expo Push receiver and approval UI (Phase 2+). 90%+ shared code across mobile and web.")
+ Container(app, "Clanker App", "Expo React Native (shared mobile/web)", "UI plus edge agent orchestration (useEdgeAgent) for text chat; Talk tab live voice via XState + WebSocket /agent/live. Expo Push receiver and browser-action approval UI. 90%+ shared code across mobile and web.")
Container(extension, "Desktop Bridge Extension", "MV3 Chrome extension", "Idle until FCM wake. Service worker opens /agent/browser WebSocket, dispatches Task DSL to content scripts. Firebase Auth via offscreen document; device pairing via register-device.")
Container(sqlite, "Local SQLite", "expo-sqlite", "Offline-first store: messages, characters, wiki/memory (expo-llm-wiki), and tasks. Messages never leave device.")
}
@@ -26,14 +26,14 @@ C4Container
}
System_Ext(gemini, "Vertex AI (Gemini)", "LLM completions (model selected server-side)")
- System_Ext(expo_push, "Expo Push", "Mobile push — approval cards and async task completion (Phase 2+)")
+ System_Ext(expo_push, "Expo Push", "Mobile push — approval cards (Phase 1); async task completion when voice session closed (Phase 2+)")
System_Ext(revenuecat, "RevenueCat", "Mobile IAP (native SDK)")
System_Ext(stripe, "Stripe", "Web subscription payments")
Rel(user, app, "Uses", "HTTPS / native")
Rel(user, extension, "Pairs device, grants host permissions", "Chrome side panel")
Rel(app, auth, "Sign-in and token refresh")
- Rel(app, firestore, "Read sessions/tasks; write auth approvals (Phase 2+)", "Firebase client SDK")
+ Rel(app, firestore, "Read sessions/tasks; write auth approvals", "Firebase client SDK")
Rel(app, functions, "generateReply (edge agent + fallback), bootstrap, wiki, media, character sync")
Rel(app, cloudagent, "Escalated text chat and live voice", "WebSocket /agent/stream or /agent/live (HTTP /agent/run text fallback) + Bearer token")
Rel(app, sqlite, "All local reads and writes")
@@ -50,7 +50,7 @@ C4Container
Rel(cloudagent, cloudsql, "Character data, tasks, wiki events, credits (Drizzle ORM)")
Rel(cloudagent, gemini, "LLM calls via Google ADK (text, voice, browser_action)")
Rel(cloudagent, extension, "FCM WAKE_AND_CONNECT silent push", "Firebase Admin messaging()")
- Rel(cloudagent, expo_push, "Approval cards, async task complete (Phase 2+)", "REST")
+ Rel(cloudagent, expo_push, "Approval cards; async task complete when voice closed (Phase 2+)", "REST")
```
## Text chat routing (summary)
@@ -87,8 +87,8 @@ Three-node async loop. Voice WS and browser WS may land on different Cloud Run i
4. **Result** — Extension returns `task_result` via WS; Cloud Agent writes to Firestore; voice-side `watchTask` listener delivers to Gemini Live (or text path `await`s with 30s cap).
5. **Teardown** — `session_end` frame; extension closes WS and offscreen auth doc; service worker suspends.
-**Phase 2+ approval path:** Extension halts on destructive action → auth doc in Firestore → Expo Push approval card → mobile approves via HTTP `/agent/browser/approve-action` → observer re-wakes extension with `resume: true`.
+**Approval path (Phase 1):** Extension halts on destructive action → auth doc in Firestore → Expo Push approval card → mobile approves via HTTP `/agent/browser/approve-action` → observer re-wakes extension with `resume: true`.
> **Note:** `sessionBridge.voiceWs` / `browserWs` are same-instance shortcuts only. Primary result delivery is always the Firestore `watchTask` listener on the voice-side instance.
-See [Edge Agent](../../edge-agent.md), [AI & Chat](../../ai-and-chat.md), and the [MV3 Browser Extension Bridge design spec](../../superpowers/specs/2026-06-29-mv3-browser-extension-bridge-design.md).
+See [Browser Bridge](../../browser-bridge.md), [Edge Agent](../../edge-agent.md), [AI & Chat](../../ai-and-chat.md), and the [MV3 Browser Extension Bridge design spec](../../superpowers/specs/2026-06-29-mv3-browser-extension-bridge-design.md).
diff --git a/docs/flowcharts/c4/system-context.md b/docs/flowcharts/c4/system-context.md
index 2df82fa0..2de16954 100644
--- a/docs/flowcharts/c4/system-context.md
+++ b/docs/flowcharts/c4/system-context.md
@@ -12,7 +12,7 @@ C4Context
System_Ext(firebase, "Firebase", "Auth, Firestore session/task coordination bus, Cloud Functions (generateReply BYOI proxy, bootstrap, wiki LLM/sync, media callables, payment webhooks), FCM sender for extension wake")
System_Ext(cloudagent, "Cloud Agent", "Stateless ADK agent on Cloud Run. Text: WebSocket /agent/stream with HTTP /agent/run fallback. Voice: WebSocket /agent/live (Gemini Live API). Browser bridge: /agent/browser WebSocket (auth frame with Firebase ID token), browser_action tool, Firestore session/task coordination via watchTask, FCM wake dispatch")
- System_Ext(expo_push, "Expo Push", "Mobile push notifications — approval cards and async task completion (Phase 2+)")
+ System_Ext(expo_push, "Expo Push", "Mobile push — approval cards (Phase 1); async task completion when voice session closed (Phase 2+)")
System_Ext(google, "Google Sign-In", "OAuth identity provider (via Firebase Auth)")
System_Ext(gemini, "Vertex AI (Gemini)", "LLM completions. Called only by Cloud Functions and Cloud Agent — never by the client")
System_Ext(stripe, "Stripe", "Web subscription payments and checkout")
@@ -29,7 +29,7 @@ C4Context
Rel(stripe, firebase, "Purchase webhooks")
Rel(revenuecat, firebase, "Purchase webhooks")
Rel(cloudagent, firebase, "Firestore session/task writes (Admin SDK), FCM silent push to extension, Firebase ID token verification")
- Rel(cloudagent, expo_push, "Approval cards and async task notifications (Phase 2+)", "REST exp.host/--/api/v2/push/send")
+ Rel(cloudagent, expo_push, "Approval cards; async task notifications when voice closed (Phase 2+)", "REST exp.host/--/api/v2/push/send")
Rel(cloudagent, gemini, "LLM calls via Google ADK (text, voice, browser_action tool)")
```
@@ -56,8 +56,8 @@ Cross-device browser tasks use a three-node async loop. Cloud Run instances neve
1. **Trigger** — Gemini invokes `browser_action` on `/agent/live` (voice) or `/agent/run` (text). Cloud Agent creates session + task docs in Firestore, resolves paired device, and dispatches FCM `WAKE_AND_CONNECT`.
2. **Wake-and-Connect** — MV3 service worker wakes on FCM, mints Firebase ID token via offscreen auth bridge, opens WebSocket `/agent/browser`, and executes the Task DSL in the user's browser.
3. **Result delivery** — Extension returns results via WS → Cloud Agent writes to Firestore → voice-side `watchTask` listener pushes into Gemini Live (or text path awaits synchronously). If the voice session is closed, Expo Push delivers async completion (Phase 2+).
-4. **Approval (Phase 2+)** — Destructive actions halt execution; mobile user taps Approve/Deny on an Expo Push card; server writes approval decision to Firestore; Cloud Agent observer resumes the extension via FCM.
+4. **Approval (Phase 1)** — Destructive actions halt execution; mobile user taps Approve/Deny on an Expo Push card; server writes approval decision to Firestore; Cloud Agent observer resumes the extension via FCM.
> **Note:** Cloud Agent `/agent/live` and `/agent/browser` handlers are deployed on Cloud Run. Firestore replaces in-memory cross-instance routing for browser task coordination.
-See [Edge Agent](../../edge-agent.md), [AI & Chat](../../ai-and-chat.md), and the [MV3 Browser Extension Bridge design spec](../../superpowers/specs/2026-06-29-mv3-browser-extension-bridge-design.md).
+See [Browser Bridge](../../browser-bridge.md), [Edge Agent](../../edge-agent.md), [AI & Chat](../../ai-and-chat.md), and the [MV3 Browser Extension Bridge design spec](../../superpowers/specs/2026-06-29-mv3-browser-extension-bridge-design.md).
diff --git a/docs/real-time-voice-chat.md b/docs/real-time-voice-chat.md
index e1f52e2d..6e00ea89 100644
--- a/docs/real-time-voice-chat.md
+++ b/docs/real-time-voice-chat.md
@@ -71,6 +71,19 @@ Transcript tokens with the same role are concatenated into one message. `usage_s
---
+## Browser Actions During Live Calls
+
+Gemini Live can invoke the `browser_action` tool during an active call. When triggered:
+
+1. Cloud Agent creates Firestore session/task docs and wakes the Desktop Bridge extension via FCM
+2. The live billing timer is **paused** while waiting for extension wake and execution (resumed when the task completes, times out, or the extension is offline)
+3. Voice path returns an interim tool response immediately; the final result is pushed into the Gemini Live session via `pushToLive` when the Firestore task reaches a terminal status
+4. If the voice session closes before the result arrives, Cloud Agent can deliver async completion via Expo Push (Phase 2+)
+
+Text chat reaches the same tool via Cloud Agent `POST /agent/run` (synchronous `watchTask` with 30s cap). See **[Browser Bridge](browser-bridge.md)** for the full Wake-and-Connect lifecycle, Task DSL, pairing, and approval flow.
+
+---
+
## Audio
**Native audio (Talk tab):** Uses `@speechmatics/expo-two-way-audio` (MIT, stock, Speechmatics-maintained) as a unified duplex module for microphone capture and PCM playback. Hardware AEC is enabled on both iOS (`VoiceProcessingIO`) and Android (linked `AudioRecord`/`AudioTrack` session). Downlink audio is resampled from 24 kHz to 16 kHz in `src/utils/audioResample.ts` before enqueue. Wire protocol (24 kHz base64 downlink, 16 kHz uplink) is unchanged.
@@ -98,6 +111,7 @@ Set `EXPO_PUBLIC_CLOUD_AGENT_URL` for runtime WebSocket URL derivation (see `liv
## Related Docs
+- **[Browser Bridge](browser-bridge.md)** — Desktop extension tasks during live calls (`browser_action`, billing pause)
- **[AI & Chat](ai-and-chat.md)** — Text chat pipeline, wiki memory, Cloud Agent text paths
- **[Edge Agent](edge-agent.md)** — On-device text tool loop (separate from voice)
- **[Billing & Credits](billing-and-credits.md)** — Credit ledger and subscriptions
diff --git a/docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md b/docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md
new file mode 100644
index 00000000..fe94e2e3
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md
@@ -0,0 +1,1014 @@
+# Phase 3: Proactive Browser Bridge — Cloud Scheduler Triggers & Expo Push Async Completion
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Cloud Scheduler–triggered proactive browser tasks that fire without a user session, and Expo Push fallback delivery when the voice session closes before a browser task result arrives.
+
+**Architecture:** A new `POST /agent/browser/scheduler-trigger` endpoint accepts Bearer `SCHEDULER_SECRET` auth from Cloud Scheduler, creates a bridge session, wakes the extension via FCM, synchronously waits up to 60 s for the result, then sends an Expo Push notification via `sendProactive`. Separately, `pushToLive` in `wsLiveAgentHandler` gains a fallback path: when the Gemini session has already closed, the result is delivered via `sendTaskComplete` Expo Push instead of being silently dropped.
+
+**Tech Stack:** Node.js 22, TypeScript, Express, Firebase Admin SDK, Expo Push REST API, `node:test` + `node:assert/strict`, `supertest`
+
+---
+
+## File Map
+
+| Action | File | Responsibility |
+|--------|------|----------------|
+| Modify | `cloud-agent/src/services/fcmDispatcher.ts` | Add `sendProactive` method |
+| Modify | `cloud-agent/src/services/fcmDispatcher.test.ts` | Test `sendProactive` payload |
+| Modify | `cloud-agent/src/tools/browserAction.ts` | Pass `sessionId` to `pushToLive` (signature change) |
+| Modify | `cloud-agent/src/tools/browserAction.test.ts` | Update `pushToLive` mock signature |
+| Modify | `cloud-agent/src/handlers/wsLiveAgentHandler.ts` | Add `getExpoPushToken` option; Expo Push fallback in `pushToLive` |
+| Modify | `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` | Test Expo Push fallback when Gemini session closed |
+| Create | `cloud-agent/src/handlers/schedulerTriggerHandler.ts` | HTTP handler for Cloud Scheduler trigger endpoint |
+| Create | `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts` | Unit + HTTP tests for scheduler handler |
+| Modify | `cloud-agent/src/index.ts` | Register `POST /agent/browser/scheduler-trigger` |
+| Modify | `cloud-agent/src/index.test.ts` | Test route 401 on bad secret, 503 when `SCHEDULER_SECRET` unset |
+
+---
+
+## Task 1: Add `sendProactive` to `fcmDispatcher.ts`
+
+**Files:**
+- Modify: `cloud-agent/src/services/fcmDispatcher.test.ts`
+- Modify: `cloud-agent/src/services/fcmDispatcher.ts`
+
+- [ ] **Step 1.1: Write failing test**
+
+Append to `cloud-agent/src/services/fcmDispatcher.test.ts`:
+
+```typescript
+test('sendProactive POSTs PROACTIVE_TASK Expo Push payload', async () => {
+ const fetched: Array<{ body: unknown }> = []
+ const fakeFetch = async (_url: string, opts: RequestInit) => {
+ fetched.push({ body: JSON.parse(opts.body as string) })
+ return { ok: true, json: async () => ({ data: [{ status: 'ok' }] }) }
+ }
+
+ const dispatcher = createFcmDispatcher(
+ { send: async () => 'msg-id' },
+ fakeFetch as unknown as typeof fetch,
+ )
+
+ await dispatcher.sendProactive('ExponentPushToken[abc]', 'sid1', 'tid1', 'Price dropped to $340.')
+
+ assert.equal(fetched.length, 1)
+ const body = fetched[0].body as Record
+ assert.equal(body.to, 'ExponentPushToken[abc]')
+ assert.equal(body.title, 'Clanker noticed something')
+ assert.equal(body.body, 'Price dropped to $340.')
+ assert.equal(body.categoryIdentifier, 'BROWSER_ACTION_APPROVAL')
+ assert.equal(body.priority, 'high')
+ const data = body.data as Record
+ assert.equal(data.type, 'PROACTIVE_TASK')
+ assert.equal(data.sessionId, 'sid1')
+ assert.equal(data.taskId, 'tid1')
+ assert.equal(data.deepLink, '/talk')
+})
+```
+
+- [ ] **Step 1.2: Run test to confirm it fails**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'sendProactive|FAIL|not a function|TypeError'
+```
+
+Expected: `TypeError: dispatcher.sendProactive is not a function` or similar.
+
+- [ ] **Step 1.3: Implement `sendProactive` in fcmDispatcher**
+
+In `cloud-agent/src/services/fcmDispatcher.ts`, add `sendProactive` inside the returned object (after `sendTaskComplete`):
+
+```typescript
+async sendProactive(expoPushToken: string, sessionId: string, taskId: string, body: string): Promise {
+ await expoPush({
+ to: expoPushToken,
+ title: 'Clanker noticed something',
+ body,
+ data: { type: 'PROACTIVE_TASK', sessionId, taskId, deepLink: '/talk' },
+ categoryIdentifier: 'BROWSER_ACTION_APPROVAL',
+ priority: 'high',
+ })
+},
+```
+
+- [ ] **Step 1.4: Run tests to confirm they pass**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'sendProactive|pass|fail' | head -10
+```
+
+Expected: `sendProactive POSTs PROACTIVE_TASK Expo Push payload` → pass.
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add cloud-agent/src/services/fcmDispatcher.ts cloud-agent/src/services/fcmDispatcher.test.ts
+git commit -m "feat(bridge): add sendProactive to fcmDispatcher for phase 3 scheduler notifications"
+```
+
+---
+
+## Task 2: Extend `pushToLive` Signature
+
+The voice-side `pushToLive` callback currently takes `(taskId, text)`. To support the Expo Push fallback in Task 3, it needs `sessionId` too so `sendTaskComplete` can correlate the push payload.
+
+**Files:**
+- Modify: `cloud-agent/src/tools/browserAction.ts` (call site only)
+- Modify: `cloud-agent/src/tools/browserAction.test.ts` (mock signature only)
+
+- [ ] **Step 2.1: Update `BrowserActionDeps.pushToLive` signature in `browserAction.ts`**
+
+In `cloud-agent/src/tools/browserAction.ts`, change line:
+
+```typescript
+ // Voice-only: push the final result into the live Gemini session.
+ pushToLive?: (taskId: string, text: string) => void
+```
+
+to:
+
+```typescript
+ // Voice-only: push the final result into the live Gemini session.
+ pushToLive?: (taskId: string, sessionId: string, text: string) => void
+```
+
+- [ ] **Step 2.2: Update the `pushToLive` call in the voice path**
+
+In the same file, find:
+
+```typescript
+ void waitForTerminalTask().then((task) => {
+ deps.resumeBilling?.()
+ deps.pushToLive?.(taskId, formatResult(task))
+ })
+```
+
+Change to:
+
+```typescript
+ void waitForTerminalTask().then((task) => {
+ deps.resumeBilling?.()
+ deps.pushToLive?.(taskId, sessionId, formatResult(task))
+ })
+```
+
+- [ ] **Step 2.3: Update `pushToLive` mock in `browserAction.test.ts`**
+
+Search `browserAction.test.ts` for any test that passes a `pushToLive` mock. Update each mock from `(taskId, text)` to `(taskId, sessionId, text)`. Example:
+
+```typescript
+// Before
+pushToLive: (taskId: string, text: string) => { pushResults.push({ taskId, text }) },
+
+// After
+pushToLive: (taskId: string, _sessionId: string, text: string) => { pushResults.push({ taskId, text }) },
+```
+
+If no existing tests use `pushToLive`, no change needed here — the interface change is sufficient.
+
+- [ ] **Step 2.4: Run tests to confirm no regressions**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'FAIL|pass|fail|Error' | tail -20
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 2.5: Commit**
+
+```bash
+git add cloud-agent/src/tools/browserAction.ts cloud-agent/src/tools/browserAction.test.ts
+git commit -m "feat(bridge): add sessionId param to pushToLive for Expo Push fallback path"
+```
+
+---
+
+## Task 3: Expo Push Fallback in `wsLiveAgentHandler`
+
+When the Gemini/voice session closes before a browser task result arrives, the `pushToLive` callback should deliver the result via `sendTaskComplete` Expo Push instead of silently dropping it.
+
+**Files:**
+- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.ts`
+- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts`
+
+- [ ] **Step 3.1: Write failing test**
+
+Append to `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts`. Find the end of existing tests and add:
+
+```typescript
+test('pushToLive falls back to Expo Push when voice WS is closed', { timeout: 5000 }, async () => {
+ const db = makeMockDb([[mockUser], [mockCharacter]])
+
+ const expoPushCalls: Array<{ token: string; sessionId: string; taskId: string; text: string }> = []
+ const mockFcmDispatcher = {
+ wakeExtension: async () => {},
+ sendApprovalCard: async () => {},
+ sendTaskComplete: async (token: string, sessionId: string, taskId: string, text: string) => {
+ expoPushCalls.push({ token, sessionId, taskId, text })
+ },
+ sendProactive: async () => {},
+ }
+
+ let watchTaskCallback: ((task: unknown) => void) | null = null
+ const mockFirestoreSession = {
+ getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'fcm-tok', deviceName: 'Mac' }),
+ createSession: async () => {},
+ writeTask: async () => {},
+ closeSession: async () => {},
+ writeTaskResult: async () => {},
+ getTask: async () => ({ status: 'pending' }),
+ getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }),
+ abortPendingTaskIfOffline: async () => false,
+ watchTask: (_uid: string, _sid: string, _tid: string, cb: (task: unknown) => void) => {
+ watchTaskCallback = cb
+ return () => {}
+ },
+ }
+
+ const mock = makeMockLiveConnect()
+ const { server, close } = createLiveTestServer({
+ db,
+ creditService: mockCreditService,
+ verifyToken: async () => ({ uid: 'fb-uid-1' }),
+ liveConnect: mock.connect,
+ getExpoPushToken: async () => 'ExponentPushToken[test]',
+ browserBridge: {
+ firebaseUid: 'fb-uid-1',
+ userId: 'user-uuid-1',
+ firestoreSession: mockFirestoreSession as never,
+ fcmDispatcher: mockFcmDispatcher as never,
+ creditService: mockCreditService,
+ instanceId: 'inst-1',
+ wakeTimeoutMs: 50,
+ textTimeoutMs: 500,
+ },
+ })
+ const port = await listen(server)
+
+ await new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`)
+ const timeout = setTimeout(() => reject(new Error('test timeout')), 4500)
+
+ ws.on('open', () => {
+ ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID }))
+ })
+
+ ws.on('message', (raw) => {
+ const msg = JSON.parse(raw.toString()) as { type: string }
+ if (msg.type !== 'session_ready') return
+
+ // Simulate Gemini invoking browser_action
+ mock.triggerMessage({
+ toolCall: {
+ functionCalls: [{ id: 'call-1', name: 'browser_action', args: {
+ actionSummary: 'Extract price',
+ intent: { action: { type: 'extract', selector: '.price', label: 'price' } },
+ }}],
+ },
+ })
+
+ // Close the WS before the task result arrives
+ setTimeout(() => ws.close(), 20)
+ })
+
+ ws.on('close', async () => {
+ // Task result arrives after WS is closed
+ await new Promise((r) => setTimeout(r, 100))
+ watchTaskCallback?.({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://example.com' }, error: null })
+
+ await new Promise((r) => setTimeout(r, 100))
+
+ clearTimeout(timeout)
+ try {
+ assert.equal(expoPushCalls.length, 1, 'sendTaskComplete should be called once')
+ assert.equal(expoPushCalls[0].token, 'ExponentPushToken[test]')
+ assert.match(expoPushCalls[0].text, /\$340|complete/i)
+ resolve()
+ } catch (e) {
+ reject(e)
+ }
+ })
+
+ ws.on('error', reject)
+ })
+
+ await close()
+})
+```
+
+- [ ] **Step 3.2: Run to confirm test fails**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'pushToLive falls back|FAIL|AssertionError' | head -5
+```
+
+Expected: `AssertionError [ERR_ASSERTION]: 0 == 1` — `sendTaskComplete` not called yet.
+
+- [ ] **Step 3.3: Add `getExpoPushToken` to `WsLiveHandlerOptions`**
+
+In `cloud-agent/src/handlers/wsLiveAgentHandler.ts`, add to the `WsLiveHandlerOptions` interface (after `browserBridge`):
+
+```typescript
+ /** Injectable for testing; defaults to DB lookup. */
+ getExpoPushToken?: (firebaseUid: string) => Promise
+```
+
+- [ ] **Step 3.4: Import `getExpoPushToken` in the handler**
+
+At the top of `cloud-agent/src/handlers/wsLiveAgentHandler.ts`, add:
+
+```typescript
+import { getExpoPushToken as dbGetExpoPushToken } from './expoPushToken.js'
+```
+
+- [ ] **Step 3.5: Add Expo Push fallback in `pushToLive`**
+
+In `wsLiveAgentHandler.ts`, find the `pushToLive` callback definition (inside `handleAuthMessage`):
+
+```typescript
+ pushToLive: (taskId: string, text: string) => {
+ const callId = browserCallByTaskId.get(taskId)
+ if (!callId) return
+ browserCallByTaskId.delete(taskId)
+ try {
+ geminiSession?.sendToolResponse({
+ functionResponses: [{ id: callId, name: 'browser_action', response: { output: text } }],
+ })
+ } catch { /* ignore */ }
+ },
+```
+
+Replace with:
+
+```typescript
+ pushToLive: (taskId: string, bridgeSessionId: string, text: string) => {
+ const callId = browserCallByTaskId.get(taskId)
+ if (!callId) return
+ browserCallByTaskId.delete(taskId)
+ const sessionOpen = geminiSession !== null && ws.readyState === WebSocket.OPEN
+ if (sessionOpen) {
+ try {
+ geminiSession!.sendToolResponse({
+ functionResponses: [{ id: callId, name: 'browser_action', response: { output: text } }],
+ })
+ } catch { /* ignore */ }
+ return
+ }
+ // Voice session already closed — deliver result via Expo Push fallback.
+ if (bridgeBase?.fcmDispatcher && bridgeBase.firebaseUid) {
+ const fwd = bridgeBase.fcmDispatcher
+ const fbUid = bridgeBase.firebaseUid
+ const getToken = options.getExpoPushToken ?? ((uid: string) => dbGetExpoPushToken(options.db, uid))
+ void getToken(fbUid).then((token) => {
+ if (token) return fwd.sendTaskComplete(token, bridgeSessionId, taskId, text)
+ }).catch((err) => console.error('[pushToLive Expo fallback]', err))
+ }
+ },
+```
+
+- [ ] **Step 3.6: Run tests to confirm passing**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'pushToLive falls back|pass|FAIL' | head -10
+```
+
+Expected: `pushToLive falls back to Expo Push when voice WS is closed` → pass, no FAIL lines.
+
+- [ ] **Step 3.7: Commit**
+
+```bash
+git add cloud-agent/src/handlers/wsLiveAgentHandler.ts cloud-agent/src/handlers/wsLiveAgentHandler.test.ts
+git commit -m "feat(bridge): send Expo Push when voice session closes before browser task result"
+```
+
+---
+
+## Task 4: Create `schedulerTriggerHandler.ts`
+
+**Files:**
+- Create: `cloud-agent/src/handlers/schedulerTriggerHandler.ts`
+- Create: `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts`
+
+- [ ] **Step 4.1: Write failing test**
+
+Create `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts`:
+
+```typescript
+// cloud-agent/src/handlers/schedulerTriggerHandler.test.ts
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import express from 'express'
+import request from 'supertest'
+import { createSchedulerTriggerHandler } from './schedulerTriggerHandler.js'
+import type { TaskDoc } from '../../../shared/dsl-types.js'
+
+const SECRET = 'test-scheduler-secret-abc'
+
+function buildApp(overrides: {
+ getActiveDevice?: () => Promise
+ watchTask?: (uid: string, sid: string, tid: string, cb: (t: TaskDoc) => void) => () => void
+ sendProactive?: (token: string, sid: string, tid: string, body: string) => Promise
+ getExpoPushToken?: (uid: string) => Promise
+} = {}) {
+ const mockFs = {
+ getActiveDevice: overrides.getActiveDevice ?? (async () => ({ deviceId: 'd1', fcmToken: 'fcm-tok', deviceName: 'Mac' })),
+ createSession: async () => {},
+ writeTask: async () => {},
+ closeSession: async () => {},
+ abortPendingTaskIfOffline: async () => false,
+ watchTask: overrides.watchTask ?? ((_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => {
+ // Resolve immediately with complete
+ setTimeout(() => cb({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://x.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5)
+ return () => {}
+ }),
+ }
+
+ const proactiveCalls: Array<{ token: string; sid: string; tid: string; body: string }> = []
+ const mockFcm = {
+ wakeExtension: async () => {},
+ sendProactive: overrides.sendProactive ?? (async (token: string, sid: string, tid: string, body: string) => {
+ proactiveCalls.push({ token, sid, tid, body })
+ }),
+ }
+
+ const handler = createSchedulerTriggerHandler(
+ mockFs as never,
+ mockFcm as never,
+ overrides.getExpoPushToken ?? (async () => 'ExponentPushToken[sched]'),
+ mockCredit as never,
+ overrides.resolveUserId ?? (async () => 'user-db-id'),
+ { secret: SECRET, schedulerTimeoutMs: 200 },
+ )
+
+ const app = express()
+ app.use(express.json())
+ app.post('/agent/browser/scheduler-trigger', handler)
+
+ return { app, proactiveCalls }
+}
+
+test('returns 401 with no Authorization header', async () => {
+ const { app } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'price' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 401)
+})
+
+test('returns 401 with wrong secret', async () => {
+ const { app } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', 'Bearer wrong-secret')
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'price' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 401)
+})
+
+test('returns 422 when no active device', async () => {
+ const { app } = buildApp({ getActiveDevice: async () => null })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'price' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 422)
+ assert.match(res.body.error, /no active device/i)
+})
+
+test('returns 422 for blocked host', async () => {
+ const { app } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ uid: 'u1', action: { type: 'open_tab', url: 'chrome://settings' }, actionSummary: 'Open', notificationBody: 'Done' })
+ assert.equal(res.status, 422)
+ assert.match(res.body.error, /HOST_NOT_ALLOWED/i)
+})
+
+test('returns 200 and sends Expo Push on successful task', async () => {
+ const { app, proactiveCalls } = buildApp()
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 200)
+ assert.equal(res.body.ok, true)
+ assert.ok(res.body.sessionId)
+ assert.ok(res.body.taskId)
+ assert.equal(proactiveCalls.length, 1)
+ assert.equal(proactiveCalls[0].token, 'ExponentPushToken[sched]')
+ assert.equal(proactiveCalls[0].body, 'Price check done.')
+})
+
+test('returns 200 with failure body when task fails', async () => {
+ const { app, proactiveCalls } = buildApp({
+ watchTask: (_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => {
+ setTimeout(() => cb({ status: 'failed', result: null, error: { code: 'SELECTOR_NOT_FOUND', message: 'not found', failedAction: { type: 'extract', selector: '.p' } as never }, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5)
+ return () => {}
+ },
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 200)
+ assert.equal(res.body.status, 'failed')
+ assert.equal(proactiveCalls.length, 1)
+ assert.match(proactiveCalls[0].body, /SELECTOR_NOT_FOUND|failed/i)
+})
+
+test('returns 504 and no Expo Push on timeout', async () => {
+ const { app, proactiveCalls } = buildApp({
+ watchTask: () => () => {}, // never resolves
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 504)
+ assert.equal(proactiveCalls.length, 0)
+})
+
+test('returns 200 without Expo Push when no token registered', async () => {
+ const { app, proactiveCalls } = buildApp({
+ getExpoPushToken: async () => null,
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', `Bearer ${SECRET}`)
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' })
+ assert.equal(res.status, 200)
+ assert.equal(proactiveCalls.length, 0)
+})
+```
+
+- [ ] **Step 4.2: Run to confirm all tests fail (module not found)**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'schedulerTrigger|Cannot find|FAIL' | head -5
+```
+
+Expected: `Cannot find module './schedulerTriggerHandler.js'` or compilation error.
+
+- [ ] **Step 4.3: Implement `schedulerTriggerHandler.ts`**
+
+Create `cloud-agent/src/handlers/schedulerTriggerHandler.ts`:
+
+```typescript
+import { z } from 'zod'
+import type { Request, Response } from 'express'
+import type { FirestoreSession } from '../services/firestoreSession.js'
+import type { FcmDispatcher } from '../services/fcmDispatcher.js'
+import type { TaskDoc, SingleAction, SequenceAction } from '../../../shared/dsl-types.js'
+import { intentRequiresAuth } from '../../../shared/constants.js'
+import { findBlockedNavigation } from '../../../shared/hostPolicy.js'
+import { INSTANCE_ID } from '../services/instanceId.js'
+
+export interface SchedulerTriggerOptions {
+ /** SCHEDULER_SECRET env var value — requests must Bearer-match this */
+ secret: string
+ schedulerTimeoutMs?: number
+}
+
+const schedulerBodySchema = z.object({
+ uid: z.string().min(1),
+ action: z.record(z.string(), z.unknown()),
+ actionSummary: z.string().min(1),
+ notificationBody: z.string().min(1),
+})
+
+function watchTaskPromise(
+ fs: FirestoreSession,
+ uid: string,
+ sessionId: string,
+ taskId: string,
+): Promise {
+ return new Promise((resolve) => {
+ const unsub = fs.watchTask(uid, sessionId, taskId, (task) => {
+ if (task.status === 'complete' || task.status === 'failed' || task.status === 'aborted') {
+ unsub()
+ resolve(task)
+ }
+ })
+ })
+}
+
+export function createSchedulerTriggerHandler(
+ fs: FirestoreSession,
+ fcm: Pick,
+ getExpoPushToken: (firebaseUid: string) => Promise,
+ creditService: Pick,
+ resolveUserId: (firebaseUid: string) => Promise,
+ opts: SchedulerTriggerOptions,
+) {
+ const timeoutMs = opts.schedulerTimeoutMs ?? 60_000
+
+ return async (req: Request, res: Response): Promise => {
+ const authHeader = req.headers.authorization ?? ''
+ const token = authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7).trim() : ''
+ if (!token || token !== opts.secret) {
+ res.status(401).json({ error: 'Unauthorized' })
+ return
+ }
+
+ const parsed = schedulerBodySchema.safeParse(req.body)
+ if (!parsed.success) {
+ res.status(400).json({ error: 'Invalid request body' })
+ return
+ }
+
+ const { uid, action, actionSummary, notificationBody } = parsed.data
+ const typedAction = action as SingleAction | SequenceAction
+
+ const blocked = findBlockedNavigation(typedAction)
+ if (blocked) {
+ res.status(422).json({ error: `HOST_NOT_ALLOWED: ${blocked.message}` })
+ return
+ }
+
+ let device: { deviceId: string; fcmToken: string; deviceName: string } | null
+ try {
+ device = await fs.getActiveDevice(uid)
+ } catch (err) {
+ console.error('[scheduler-trigger] getActiveDevice error:', err)
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+
+ if (!device) {
+ res.status(422).json({ error: 'No active device for this user' })
+ return
+ }
+
+ const sessionId = crypto.randomUUID()
+ const taskId = crypto.randomUUID()
+ const requiresAuth = intentRequiresAuth(actionSummary, typedAction)
+ const taskIntent = { version: '1' as const, taskId, sessionId, requiresAuth, actionSummary, action: typedAction }
+
+ try {
+ await fs.createSession(uid, sessionId, { status: 'pending', trigger: 'scheduler', voiceInstanceId: INSTANCE_ID })
+ await fs.writeTask(uid, sessionId, taskId, taskIntent)
+ await fcm.wakeExtension(device.fcmToken, sessionId, taskId)
+ } catch (err) {
+ console.error('[scheduler-trigger] setup error:', err)
+ try { await fs.closeSession(uid, sessionId, 'aborted') } catch { /* ignore */ }
+ res.status(500).json({ error: 'Internal server error' })
+ return
+ }
+
+ let task: TaskDoc | null = null
+ try {
+ task = await Promise.race([
+ watchTaskPromise(fs, uid, sessionId, taskId),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), timeoutMs)),
+ ])
+ } catch {
+ // Timeout — task did not complete
+ try { await fs.abortPendingTaskIfOffline(uid, sessionId, taskId, {
+ taskId, status: 'failed', data: {}, activeUrl: '',
+ error: { code: 'EXECUTION_TIMEOUT', message: 'Scheduler task timed out', failedAction: typedAction as never },
+ }) } catch { /* ignore */ }
+ try { await fs.closeSession(uid, sessionId, 'aborted') } catch { /* ignore */ }
+ res.status(504).json({ error: 'Task timed out', sessionId, taskId })
+ return
+ }
+
+ try { await fs.closeSession(uid, sessionId, 'closed') } catch { /* ignore */ }
+
+ const pushBody = task.status === 'complete'
+ ? notificationBody
+ : `Browser task failed (${task.error?.code ?? 'unknown'}). Tap to check.`
+
+ try {
+ const expoPushToken = await getExpoPushToken(uid)
+ if (expoPushToken) {
+ await fcm.sendProactive(expoPushToken, sessionId, taskId, pushBody)
+ }
+ } catch (err) {
+ console.error('[scheduler-trigger] sendProactive error:', err)
+ }
+
+ res.json({ ok: true, sessionId, taskId, status: task.status })
+ }
+}
+```
+
+- [ ] **Step 4.4: Run tests to confirm all pass**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'scheduler|FAIL' | head -20
+```
+
+Expected: all 7 scheduler tests pass, no FAIL lines.
+
+- [ ] **Step 4.5: Commit**
+
+```bash
+git add cloud-agent/src/handlers/schedulerTriggerHandler.ts cloud-agent/src/handlers/schedulerTriggerHandler.test.ts
+git commit -m "feat(bridge): add schedulerTriggerHandler for Cloud Scheduler proactive browser tasks"
+```
+
+---
+
+## Task 5: Wire `POST /agent/browser/scheduler-trigger` into `index.ts`
+
+**Files:**
+- Modify: `cloud-agent/src/index.ts`
+- Modify: `cloud-agent/src/index.test.ts`
+
+- [ ] **Step 5.1: Write failing test**
+
+In `cloud-agent/src/index.test.ts`, locate the existing test helper section and add a test after existing route tests:
+
+```typescript
+test('POST /agent/browser/scheduler-trigger returns 401 with no secret', async () => {
+ const db = makeMockDb()
+ const app = createApp({
+ verifyToken: async () => ({ uid: 'uid' }),
+ db,
+ runAgentFn: async () => ({ reply: 'ok', toolCalls: [] }),
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'p' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 401)
+})
+
+test('POST /agent/browser/scheduler-trigger returns 503 when SCHEDULER_SECRET not set', async () => {
+ const saved = process.env.SCHEDULER_SECRET
+ delete process.env.SCHEDULER_SECRET
+ const db = makeMockDb()
+ const app = createApp({
+ verifyToken: async () => ({ uid: 'uid' }),
+ db,
+ runAgentFn: async () => ({ reply: 'ok', toolCalls: [] }),
+ })
+ const res = await request(app)
+ .post('/agent/browser/scheduler-trigger')
+ .set('Authorization', 'Bearer anything')
+ .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'p' }, actionSummary: 'Extract', notificationBody: 'Done' })
+ assert.equal(res.status, 503)
+ process.env.SCHEDULER_SECRET = saved
+})
+```
+
+- [ ] **Step 5.2: Run to confirm tests fail**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'scheduler-trigger.*401|scheduler-trigger.*503|FAIL' | head -5
+```
+
+Expected: tests fail because route doesn't exist yet (`404`).
+
+- [ ] **Step 5.3: Add imports to `index.ts`**
+
+In `cloud-agent/src/index.ts`, add after existing imports:
+
+```typescript
+import { createSchedulerTriggerHandler } from './handlers/schedulerTriggerHandler.js'
+import { getExpoPushToken } from './handlers/expoPushToken.js'
+```
+
+- [ ] **Step 5.4: Add route to `createApp` in `index.ts`**
+
+In `createApp`, after the `POST /agent/browser/approve-action` route block (before `return app`), add:
+
+```typescript
+ app.post('/agent/browser/scheduler-trigger', async (req: Request, res: Response): Promise => {
+ const secret = process.env.SCHEDULER_SECRET
+ if (!secret) {
+ res.status(503).json({ error: 'Scheduler trigger not configured' })
+ return
+ }
+ if (!browserBridgeAvailable) {
+ res.status(503).json({ error: 'Browser bridge unavailable' })
+ return
+ }
+ const handler = createSchedulerTriggerHandler(
+ defaultFirestoreSession(),
+ defaultFcmDispatcher(),
+ (firebaseUid: string) => getExpoPushToken(db, firebaseUid),
+ cs,
+ async (firebaseUid: string) => {
+ const [u] = await db.select({ id: users.id }).from(users).where(eq(users.firebaseUid, firebaseUid))
+ return u?.id ?? null
+ },
+ { secret },
+ )
+ return handler(req, res)
+ })
+```
+
+- [ ] **Step 5.5: Run tests to confirm passing**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'scheduler-trigger|FAIL' | head -10
+```
+
+Expected: both new scheduler-trigger index tests pass.
+
+- [ ] **Step 5.6: Commit**
+
+```bash
+git add cloud-agent/src/index.ts cloud-agent/src/index.test.ts
+git commit -m "feat(bridge): wire POST /agent/browser/scheduler-trigger route"
+```
+
+---
+
+## Task 6: Update `wsLiveAgentHandler.ts` `getExpoPushToken` in production wiring
+
+The production wiring in `index.ts` needs to pass `getExpoPushToken` to `handleLiveWsUpgrade`. Currently it uses the default DB lookup internally, but with Task 3's injectable option, the test can override it. For production, no change is needed since the handler falls back to `dbGetExpoPushToken(options.db, fbUid)` when `options.getExpoPushToken` is not provided. Verify the fallback works.
+
+**Files:**
+- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` (one more integration test)
+
+- [ ] **Step 6.1: Write test for production path (no `getExpoPushToken` override, handler uses DB lookup)**
+
+Append to `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts`:
+
+```typescript
+test('pushToLive uses DB lookup for expoPushToken when getExpoPushToken not injected', { timeout: 5000 }, async () => {
+ // Build a mock DB that returns an expoPushToken row for expoPushToken queries.
+ // The mock DB in wsLiveAgentHandler test calls `select().from().where().limit()`
+ // The `getExpoPushToken` helper does: db.select({ expoPushToken }).from(users).where(eq(users.firebaseUid, uid)).limit(1)
+ const expoPushRow = [{ expoPushToken: 'ExponentPushToken[db]' }]
+ const db = makeMockDb([[mockUser], [mockCharacter], expoPushRow])
+
+ const proactiveCalls: Array<{ token: string }> = []
+ const mockFcmDispatcher = {
+ wakeExtension: async () => {},
+ sendTaskComplete: async (token: string) => { proactiveCalls.push({ token }) },
+ sendProactive: async () => {},
+ }
+
+ let watchTaskCallback: ((task: unknown) => void) | null = null
+ const mockFirestoreSession = {
+ getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'tok', deviceName: 'Mac' }),
+ createSession: async () => {},
+ writeTask: async () => {},
+ closeSession: async () => {},
+ writeTaskResult: async () => {},
+ getTask: async () => ({ status: 'pending' }),
+ getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }),
+ abortPendingTaskIfOffline: async () => false,
+ watchTask: (_u: string, _s: string, _t: string, cb: (task: unknown) => void) => {
+ watchTaskCallback = cb
+ return () => {}
+ },
+ }
+
+ const mock = makeMockLiveConnect()
+ // No getExpoPushToken override — handler must fall back to DB lookup
+ const { server, close } = createLiveTestServer({
+ db,
+ creditService: mockCreditService,
+ verifyToken: async () => ({ uid: 'fb-uid-2' }),
+ liveConnect: mock.connect,
+ browserBridge: {
+ firebaseUid: 'fb-uid-2',
+ userId: 'user-uuid-1',
+ firestoreSession: mockFirestoreSession as never,
+ fcmDispatcher: mockFcmDispatcher as never,
+ creditService: mockCreditService,
+ instanceId: 'inst-2',
+ wakeTimeoutMs: 50,
+ textTimeoutMs: 500,
+ },
+ })
+ const port = await listen(server)
+
+ await new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`)
+ const timeout = setTimeout(() => reject(new Error('test timeout')), 4500)
+ ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token: 'v', characterId: CHAR_UUID })))
+ ws.on('message', (raw) => {
+ const msg = JSON.parse(raw.toString()) as { type: string }
+ if (msg.type !== 'session_ready') return
+ mock.triggerMessage({
+ toolCall: { functionCalls: [{ id: 'c2', name: 'browser_action', args: {
+ actionSummary: 'Extract', intent: { action: { type: 'extract', selector: '.p', label: 'p' } },
+ }}] },
+ })
+ setTimeout(() => ws.close(), 20)
+ })
+ ws.on('close', async () => {
+ await new Promise((r) => setTimeout(r, 100))
+ watchTaskCallback?.({ status: 'complete', result: { data: { p: 'x' }, activeUrl: 'https://a.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } })
+ await new Promise((r) => setTimeout(r, 100))
+ clearTimeout(timeout)
+ try {
+ assert.equal(proactiveCalls.length, 1)
+ assert.equal(proactiveCalls[0].token, 'ExponentPushToken[db]')
+ resolve()
+ } catch (e) { reject(e) }
+ })
+ ws.on('error', reject)
+ })
+
+ await close()
+})
+```
+
+- [ ] **Step 6.2: Run to confirm passing**
+
+```bash
+cd cloud-agent && npm test 2>&1 | grep -E 'DB lookup|FAIL' | head -5
+```
+
+Expected: `pushToLive uses DB lookup...` → pass.
+
+- [ ] **Step 6.3: Run full test suite**
+
+```bash
+cd cloud-agent && npm test 2>&1 | tail -15
+```
+
+Expected: all tests pass, `0 failures`.
+
+- [ ] **Step 6.4: Commit**
+
+```bash
+git add cloud-agent/src/handlers/wsLiveAgentHandler.test.ts
+git commit -m "test(bridge): verify pushToLive Expo Push fallback uses DB lookup when not injected"
+```
+
+---
+
+## Task 7: Cloud Scheduler Setup (Infrastructure — No Code)
+
+This task configures the GCP-side infrastructure. It is manual and not automated by tests.
+
+- [ ] **Step 7.1: Set `SCHEDULER_SECRET` environment variable on Cloud Run**
+
+In GCP Console → Cloud Run → `cloud-agent` service → Edit & Deploy New Revision → Variables & Secrets:
+
+Add environment variable:
+
+```bash
+SCHEDULER_SECRET =
+```
+
+Store the secret in 1Password as `cloud-agent/SCHEDULER_SECRET`.
+
+- [ ] **Step 7.2: Create Cloud Scheduler job**
+
+In GCP Console → Cloud Scheduler → Create Job:
+
+```text
+Name: browser-bridge-price-monitor
+Region: us-central1
+Frequency: 0 * * * * (hourly, adjust per use case)
+Target: HTTP
+URL: https:///agent/browser/scheduler-trigger
+HTTP method: POST
+Body: {
+ "uid": "",
+ "runKey": "-",
+ "action": {
+ "type": "extract",
+ "selector": ".price",
+ "label": "price"
+ },
+ "actionSummary": "Extract current price from open tab",
+ "notificationBody": "Price check complete. Tap to review."
+ }
+Auth header: Add header
+ Authorization: Bearer
+ Content-Type: application/json
+```
+
+- [ ] **Step 7.3: Trigger the job manually and verify end-to-end**
+
+GCP Console → Cloud Scheduler → Force Run the job. Verify:
+1. Cloud Run logs show `[scheduler-trigger]` entries
+2. Extension wakes and executes the extract action
+3. Mobile device receives `PROACTIVE_TASK` Expo Push notification
+4. Tap notification → opens `/talk` deep link
+
+This is the Phase 3 gate: **1 working scheduled monitoring task**.
+
+---
+
+## Self-Review
+
+**Spec coverage check:**
+
+| Spec requirement | Covered by |
+|-----------------|-----------|
+| Cloud Scheduler triggers | Task 4 + Task 5 + Task 7 |
+| `sendProactive` Expo Push | Task 1 |
+| Expo Push async completion (voice session closed) | Task 3 |
+| `trigger: 'scheduler'` in SessionDoc | Already in `dsl-types.ts` — no change |
+| Proactive FCM payload shape (`PROACTIVE_TASK`, high priority, `categoryIdentifier`) | Task 1 |
+| Phase 3 gate: 1 working scheduled monitoring task | Task 7 |
+
+**`open question` from spec resolved:** Cloud Scheduler uses same `TaskIntent` DSL envelope — no new envelope needed.
+
+**Placeholder scan:** None — all steps include concrete code.
+
+**Type consistency:**
+- `sendProactive(expoPushToken, sessionId, taskId, body)` — consistent between Task 1 (definition) and Task 4 (usage)
+- `pushToLive(taskId, sessionId, text)` — consistent between Task 2 (definition change) and Task 3 (usage)
+- `createSchedulerTriggerHandler(fs, fcm, getExpoPushToken, creditService, resolveUserId, opts)` — consistent between Task 4 (definition) and Task 5 (wiring)
+- `FcmDispatcher` type is `ReturnType` — adding `sendProactive` to the factory auto-updates the type
diff --git a/docs/superpowers/plans/2026-06-30-cws-extension-readiness.md b/docs/superpowers/plans/2026-06-30-cws-extension-readiness.md
new file mode 100644
index 00000000..9beab8ee
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-30-cws-extension-readiness.md
@@ -0,0 +1,512 @@
+# CWS Extension Readiness Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Land five targeted code changes in the extension and produce a CWS submission artifacts doc so the extension passes Chrome Web Store review.
+
+**Architecture:** Three source files change (`manifest.json`, `panel.ts`, `auth-bridge.ts`); a new CWS artifacts doc captures copy-paste store listing content; the build script already copies source `manifest.json` to `dist/` so no build script changes are needed.
+
+**Tech Stack:** TypeScript, Node.js test runner (tsx/esm), esbuild, Chrome Extension MV3
+
+---
+
+## File Map
+
+| Action | Path |
+|--------|------|
+| Modify | `extension/manifest.json` |
+| Modify | `extension/src/ui/side-panel/panel.ts` |
+| Modify | `extension/src/background/auth-bridge.ts` |
+| Create | `extension/src/ui/side-panel/panel.test.ts` |
+| Create | `docs/cws-submission-artifacts.md` |
+
+---
+
+### Task 1: Retrieve extension public key from 1Password
+
+The `key` field in `manifest.json` requires the base64 public key derived from the extension's `.pem` signing file stored in 1Password. This task has no code changes — it produces the value needed for Task 2.
+
+**Files:** (none — output is a string you copy into Task 2)
+
+- [ ] **Step 1: Retrieve `.pem` from 1Password**
+
+Open 1Password and find the Clanker Desktop Bridge signing key (look for "extension key.pem" or "clanker-extension"). Download or copy the `.pem` file to a temporary location (e.g., `/tmp/key.pem`).
+
+- [ ] **Step 2: Extract the base64 public key**
+
+```bash
+openssl rsa -in /tmp/key.pem -pubout -outform DER | base64 | tr -d '\n'
+```
+
+Expected output: a long base64 string (no newlines). Copy this value — you will paste it as `` in Task 2.
+
+- [ ] **Step 3: Delete the temp pem file**
+
+```bash
+rm /tmp/key.pem
+```
+
+---
+
+### Task 2: Update `extension/manifest.json` — icons, key, CSP
+
+**Files:**
+- Modify: `extension/manifest.json`
+
+- [ ] **Step 1: Write the updated manifest**
+
+Replace the entire content of `extension/manifest.json` with:
+
+```json
+{
+ "manifest_version": 3,
+ "name": "Clanker Desktop Bridge",
+ "version": "0.1.0",
+ "description": "Lets your Clanker agent perform web tasks you request on this browser.",
+ "key": "",
+ "background": { "service_worker": "background/service-worker.js", "type": "module" },
+ "content_scripts": [],
+ "gcm_sender_id": "54051268985",
+ "permissions": ["scripting", "tabs", "storage", "sidePanel", "notifications", "gcm", "offscreen"],
+ "optional_host_permissions": [""],
+ "side_panel": { "default_path": "ui/side-panel/index.html" },
+ "action": {
+ "default_popup": "ui/popup/index.html",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ }
+ },
+ "icons": {
+ "16": "icons/icon-16.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "content_security_policy": {
+ "extension_pages": "script-src 'self'; object-src 'self';"
+ }
+}
+```
+
+Replace `` with the value obtained in Task 1 Step 2.
+
+If the `.pem` is not yet available (e.g. first-time submission), omit the `key` field for now and add it before uploading to CWS.
+
+- [ ] **Step 2: Verify no `eval`/remote CSP violations exist**
+
+```bash
+grep -rE "eval\(|new Function\(|importScripts\(" extension/src/
+```
+
+Expected: no matches.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add extension/manifest.json
+git commit -m "feat(extension): add icons, key, and CSP to manifest for CWS submission"
+```
+
+---
+
+### Task 3: Replace `innerHTML` write in `panel.ts`
+
+The CWS automated scanner flags `innerHTML` with string-concatenated content. Replace the `renderLog` function body with safe DOM insertion.
+
+**Files:**
+- Modify: `extension/src/ui/side-panel/panel.ts:87-88`
+- Create: `extension/src/ui/side-panel/panel.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `extension/src/ui/side-panel/panel.test.ts`:
+
+```typescript
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { JSDOM } from 'jsdom'
+
+function makeLog(entries: Array<{ ts: number; action: string; status: string }>): Element {
+ const dom = new JSDOM('')
+ const document = dom.window.document
+ const logEl = document.getElementById('log')!
+ logEl.textContent = ''
+ for (const entry of entries) {
+ const li = document.createElement('li')
+ li.textContent = `${new Date(entry.ts).toLocaleTimeString()} ${entry.action} ${entry.status === 'complete' ? '✓' : '✕'}`
+ logEl.appendChild(li)
+ }
+ return logEl
+}
+
+test('renderLog creates li elements, not innerHTML strings', () => {
+ const ts = new Date('2026-01-01T12:00:00Z').getTime()
+ const logEl = makeLog([
+ { ts, action: 'READ_PAGE', status: 'complete' },
+ { ts, action: 'CLICK', status: 'failed' },
+ ])
+ const items = logEl.querySelectorAll('li')
+ assert.equal(items.length, 2)
+ assert.match(items[0].textContent ?? '', /READ_PAGE/)
+ assert.match(items[0].textContent ?? '', /✓/)
+ assert.match(items[1].textContent ?? '', /CLICK/)
+ assert.match(items[1].textContent ?? '', /✕/)
+})
+
+test('renderLog clears previous entries before rendering', () => {
+ const ts = Date.now()
+ const logEl = makeLog([{ ts, action: 'FIRST', status: 'complete' }])
+ // Simulate a second render by running the same logic
+ logEl.textContent = ''
+ const li = logEl.ownerDocument.createElement('li')
+ li.textContent = 'SECOND'
+ logEl.appendChild(li)
+ assert.equal(logEl.querySelectorAll('li').length, 1)
+ assert.match(logEl.querySelector('li')?.textContent ?? '', /SECOND/)
+})
+
+test('XSS payload in action field is not executed as HTML', () => {
+ const ts = Date.now()
+ const logEl = makeLog([{ ts, action: '', status: 'complete' }])
+ const li = logEl.querySelector('li')
+ assert.ok(li?.textContent?.includes('', status: 'complete' }], dom.window.document)
+ const li = logEl.querySelector('li')
+ assert.ok(li?.textContent?.includes('