Skip to content
Merged
290 changes: 290 additions & 0 deletions docs/features/cloudflare-tunnel-sync/plan.md

Large diffs are not rendered by default.

300 changes: 300 additions & 0 deletions docs/features/cloudflare-tunnel-sync/spec.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/main/app/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ import { createDeviceRoutes } from '../device/routes'
import { createOnboardingRoutes } from '../onboarding/routes'
import { createUpgradeRoutes } from '../upgrade/routes'
import { createSyncRoutes } from '../sync/routes'
import { SyncHostService } from '../sync/host'
import { createSyncHostRoutes } from '../sync/host/routes'
import { createPlatformRoutes } from '../platform/routes'
import { createHookRoutes } from '../hook/routes'
import { createAppSettingsRoutes } from './settingsRoutes'
Expand Down Expand Up @@ -525,6 +527,7 @@ export async function createMainProcessControl(dependencies: {
let ocrSettings: OcrSettings
let mcpService: McpService
let syncService: SyncService
let syncHostService: SyncHostService
let deeplinkService: DeeplinkService
let notificationService: NotificationService
let tabPresenter: TabPresenter
Expand Down Expand Up @@ -1318,6 +1321,13 @@ export async function createMainProcessControl(dependencies: {
providerDatabase,
publishDeepchatEvent
)
syncHostService = new SyncHostService({
listBackups: () => syncService.listBackups(),
getFolderPath: () => syncSettings.getFolderPath(),
getUserDataPath: () => app.getPath('userData'),
getAppVersion: () => app.getVersion(),
logger
})
notificationService = new NotificationService(desktopSettings, publishDeepchatEvent)
trayPresenter = new TrayPresenter(desktopSettings, windowPresenter)
dialogService = new DialogService(publishDeepchatEvent)
Expand Down Expand Up @@ -2635,6 +2645,7 @@ export async function createMainProcessControl(dependencies: {
async function destroy(): Promise<void> {
await runDestroyStep('agentCliTokenAuthority.clear', () => agentCliTokenAuthority.clear())
await runDestroyStep('cliServer.stop', () => cliServer.stop())
await runDestroyStep('syncHostService.stop', () => syncHostService.stop())
await runDestroyStep('tapeInspectorHeadWatcher.close', () => tapeInspectorHeadWatcher.close())
await runDestroyStep('typedEventHub.close', () => typedEventHub.close())
await runDestroyStep('cliMutationGuard.clear', () => cliMutationGuard.clear())
Expand Down Expand Up @@ -2933,6 +2944,7 @@ export async function createMainProcessControl(dependencies: {
})
}
})
const syncHostRoutes = createSyncHostRoutes({ host: syncHostService })
const platformRoutes = createPlatformRoutes({
proxySettings: dependencies.proxySettings,
applyProxyMode: (mode) => {
Expand Down Expand Up @@ -3086,6 +3098,7 @@ export async function createMainProcessControl(dependencies: {
upgradeRoutes,
exporterRoutes,
syncRoutes,
syncHostRoutes,
platformRoutes,
hookRoutes,
notificationRoutes,
Expand Down Expand Up @@ -3583,6 +3596,12 @@ export async function createMainProcessControl(dependencies: {
reportMainStartupComponentFailure(dependencies.startupRunId, 'cli_control', 'unknown')
logger.error('[CLI] Failed to start local control server', error)
}
try {
await syncHostService.startIfEnabled()
} catch (error) {
reportMainStartupComponentFailure(dependencies.startupRunId, 'sync_host', 'unknown')
logger.error('[SyncHost] Failed to start host mode', error)
}
if (cliServer.getStatus().running) {
try {
await cliLauncherService.ensureInstalled()
Expand Down
2 changes: 2 additions & 0 deletions src/main/logging/mainLogEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type MainLogStartupComponent =
| 'rtk_health_check'
| 'skill_sync'
| 'sqlite_mainline_normalization'
| 'sync_host'
| 'toolchain_gc'
| 'usage_stats_backfill'

Expand Down Expand Up @@ -453,6 +454,7 @@ const STARTUP_COMPONENTS = [
'rtk_health_check',
'skill_sync',
'sqlite_mainline_normalization',
'sync_host',
'toolchain_gc',
'usage_stats_backfill'
] as const satisfies readonly MainLogStartupComponent[]
Expand Down
129 changes: 129 additions & 0 deletions src/main/sync/host/devices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import {
SYNC_HOST_DEVICE_NAME_MAX_LENGTH,
SYNC_HOST_DEVICE_TOKEN_BYTES,
type SyncHostDeviceView
} from '@shared/contracts/syncHost'
import type { SyncHostDeviceRecord, SyncHostStateStore } from './state'

const LAST_SEEN_PERSIST_INTERVAL_MS = 60_000

export interface IssuedSyncHostDevice {
device: SyncHostDeviceView
token: string
}

function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}

function toView(record: SyncHostDeviceRecord): SyncHostDeviceView {
return {
deviceId: record.deviceId,
name: record.name,
createdAt: record.createdAt,
expiresAt: record.expiresAt,
lastSeenAt: record.lastSeenAt,
revoked: record.revokedAt !== null
}
}

/**
* Owns per-device bearer tokens for the sync host endpoint: issuance, authentication, revocation
* and expiry. Records live in the machine-local host state, never in the synced settings blob, and
* only token hashes are stored.
*/
export class SyncHostDeviceStore {
private readonly lastSeenPersistedAt = new Map<string, number>()

constructor(private readonly state: SyncHostStateStore) {}

list(): SyncHostDeviceView[] {
return this.state
.snapshot()
.devices.map(toView)
.sort((left, right) => right.createdAt - left.createdAt)
}

count(): number {
return this.state.snapshot().devices.length
}

async issue(input: {
name: string
expiresAt?: number | null
now?: number
}): Promise<IssuedSyncHostDevice> {
const now = input.now ?? Date.now()
const token = randomBytes(SYNC_HOST_DEVICE_TOKEN_BYTES).toString('base64url')
const record: SyncHostDeviceRecord = {
deviceId: `dev_${randomBytes(9).toString('hex')}`,
name: input.name.trim().slice(0, SYNC_HOST_DEVICE_NAME_MAX_LENGTH),
tokenHash: hashToken(token),
createdAt: now,
lastSeenAt: null,
expiresAt: input.expiresAt ?? null,
revokedAt: null
}
await this.state.update((state) => {
state.devices.push(record)
})
return { device: toView(record), token }
}

/**
* Verifies a presented bearer token. Returns the device view on success and `null` for
* unknown, malformed, revoked or expired tokens.
*/
authenticate(token: string, now: number = Date.now()): SyncHostDeviceView | null {
if (!token) return null
const presented = Buffer.from(hashToken(token), 'hex')
for (const record of this.state.snapshot().devices) {
const expected = Buffer.from(record.tokenHash, 'hex')
if (presented.length !== expected.length) continue
if (!timingSafeEqual(presented, expected)) continue
if (record.revokedAt !== null) return null
if (record.expiresAt !== null && record.expiresAt <= now) return null
this.touchLastSeen(record.deviceId, now)
return toView(record)
}
return null
}

async revoke(deviceId: string, now: number = Date.now()): Promise<boolean> {
let revoked = false
await this.state.update((state) => {
const target = state.devices.find((record) => record.deviceId === deviceId)
if (!target || target.revokedAt !== null) return
target.revokedAt = now
revoked = true
})
return revoked
}

async rename(deviceId: string, name: string): Promise<boolean> {
const next = name.trim().slice(0, SYNC_HOST_DEVICE_NAME_MAX_LENGTH)
if (!next) return false
let renamed = false
await this.state.update((state) => {
const target = state.devices.find((record) => record.deviceId === deviceId)
if (!target) return
target.name = next
renamed = true
})
return renamed
}

private touchLastSeen(deviceId: string, now: number): void {
const lastPersisted = this.lastSeenPersistedAt.get(deviceId) ?? 0
if (now - lastPersisted < LAST_SEEN_PERSIST_INTERVAL_MS) return
this.lastSeenPersistedAt.set(deviceId, now)
// Fire-and-forget: a failed last-seen write must never fail an authorized request.
void this.state
.update((state) => {
const target = state.devices.find((record) => record.deviceId === deviceId)
if (target) target.lastSeenAt = now
})
.catch(() => undefined)
}
}
Loading