Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
279918da1This file is pinned to the selected release. Download the original to open it in your design tool.
import type { Identity } from '../lib/e2ee/core.js'
import { b64e } from '../lib/e2ee/core.js'
import { projectDisplayName, type RegisteredSession } from '../lib/registry.js'
import type { TerminalBackendCoordinator } from '../lib/terminalBackendCoordinator.js'
import { encodeTerminalLocal } from '../lib/terminalBinary.js'
import { TerminalStreamManager } from '../lib/terminalStreamManager.js'
import { HarnessGrantStore, inviteSchema, type HarnessGrant } from './grants.js'
import { ownerHandshake, type ObserverCipher } from './crypto.js'
type Payload = Record<string, unknown>
interface Observer { grant: HarnessGrant; cipher: ObserverCipher }
export interface ShareOwnerDeps {
machineId: () => string
identity: Identity
grants: HarnessGrantStore
terminals: TerminalBackendCoordinator
resolveAgent: (id: string) => RegisteredSession | undefined
send: (connId: string, type: string, payload: Payload) => boolean
publish: (method: 'PUT' | 'DELETE', path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
watchViewer?: (agentId: string, send: (payload: Payload) => void) => (() => void)
now?: () => number
}
/** This is the complete observer dispatch surface. It never calls the ordinary daemon RPC switch. */
export class HarnessShareOwner {
private readonly observers = new Map<string, Observer>()
private readonly viewers = new Map<string, () => void>()
private readonly streams: TerminalStreamManager
private readonly timer: ReturnType<typeof setInterval>
private readonly now: () => number
private syncing: Promise<void> | null = null
private mutation: Promise<unknown> = Promise.resolve()
constructor(private readonly deps: ShareOwnerDeps) {
this.now = deps.now ?? (() => Date.now())
this.streams = new TerminalStreamManager({ readOnly: true, terminals: deps.terminals,
resolveAgent: deps.resolveAgent, streamingAvailable: true,
sendTarget: (id, type, payload) => this.send(id, { type, payload }),
sendBinaryTarget: (id, frame) => {
const bytes = encodeTerminalLocal(frame)
return bytes !== null && this.send(id, { type: 'observer_binary',
payload: { bytes: Buffer.from(bytes).toString('base64') } })
},
})
let ticks = 0
this.timer = setInterval(() => {
for (const [id, observer] of this.observers) {
if (!this.authorized(observer)) this.close(id, 'Sharing ended or invitation expired')
}
if (++ticks % 30 === 0) void this.sync()
}, 1000)
this.timer.unref()
}
private authorized(observer: Observer): boolean {
return !!this.deps.grants.active(observer.grant.id, observer.grant.recipientEmail, this.deps.machineId())
&& !!this.deps.resolveAgent(observer.grant.agentId)
}
private send(connId: string, frame: Payload): boolean {
const observer = this.observers.get(connId)
if (!observer || !this.authorized(observer)) return false
return this.deps.send(connId, 'observer_frame', observer.cipher.seal(frame) as unknown as Payload)
}
close(connId: string, reason = 'Observation ended', retry = false): void {
if (!this.observers.delete(connId)) return
this.viewers.get(connId)?.()
this.viewers.delete(connId)
void this.streams.closeConnection(connId)
this.deps.send(connId, 'observer_closed', { reason, retry })
}
closeAll(): void { for (const id of this.observers.keys()) this.close(id, 'Owner disconnected', true) }
async stop(): Promise<void> { clearInterval(this.timer); this.closeAll(); await this.streams.stop() }
async receive(connId: string, type: string, payload: Payload): Promise<void> {
if (!connId.startsWith('observer:')) return
if (type === 'observer_close') { this.close(connId); return }
if (type === 'observer_open') {
if (this.observers.has(connId)) { this.close(connId, 'Invalid observer handshake'); return }
const grant = this.deps.grants.active(String(payload.shareId), String(payload.email), this.deps.machineId())
if (!grant || !this.deps.resolveAgent(grant.agentId) || this.observers.size >= 100) {
this.deps.send(connId, 'observer_closed', { reason: 'Sharing ended or invitation expired' }); return
}
try {
const { cipher, welcome } = ownerHandshake(this.deps.identity, grant.machineId, grant.id, String(payload.ephemeral))
this.observers.set(connId, { grant, cipher })
if (!this.deps.send(connId, 'observer_welcome', welcome)) this.close(connId)
} catch { this.deps.send(connId, 'observer_closed', { reason: 'Invalid observer handshake' }) }
return
}
const observer = this.observers.get(connId)
if (!observer) return
if (!this.authorized(observer)) { this.close(connId, 'Sharing ended or invitation expired'); return }
const frame = type === 'observer_frame' ? observer.cipher.open(payload) : null
if (!frame || typeof frame.type !== 'string' || !frame.payload || typeof frame.payload !== 'object') {
this.close(connId, 'Invalid observer message'); return
}
const p = frame.payload as Payload
const allowed = ['terminal_capabilities', 'terminal_open', 'terminal_alive', 'terminal_ack',
'terminal_resync', 'terminal_close', 'observer_viewer']
if (!allowed.includes(frame.type) || (p.agentId !== undefined && p.agentId !== observer.grant.agentId)
|| (frame.type === 'terminal_open' && p.agentId !== observer.grant.agentId)) {
this.send(connId, { type: 'terminal_error', payload: { requestId: p.requestId, code: 'VIEW_ONLY' } })
return
}
if (frame.type === 'observer_viewer') {
if (!this.viewers.has(connId) && this.deps.watchViewer) {
this.viewers.set(connId, this.deps.watchViewer(observer.grant.agentId,
data => { this.send(connId, { type: 'observer_viewer', payload: data }) }))
}
return
}
await this.streams.handleFrame(connId, frame.type, p)
}
/** Owner controls are serialized so a delayed publish cannot undo a subsequent removal. */
manage(type: string, payload: Payload): Promise<Payload> {
const work = this.mutation.then(() => this.manageOne(type, payload))
this.mutation = work.catch(() => {})
return work
}
private async manageOne(type: string, payload: Payload): Promise<Payload> {
const agentId = typeof payload.agentId === 'string' ? payload.agentId : ''
const session = this.deps.resolveAgent(agentId)
if (!session) return { error: 'HARNESS_NOT_FOUND', detail: 'This harness is no longer available.' }
const machineId = this.deps.machineId()
if (type === 'harness_share_invite') {
const parsed = inviteSchema.safeParse(payload)
if (!parsed.success) return { error: 'INVALID_INVITATION', detail: 'Enter valid email addresses and choose an expiry.' }
for (const email of new Set(parsed.data.emails)) {
this.deps.grants.invite({ machineId, agentId, recipientEmail: email,
name: projectDisplayName(session), engine: session.engine,
ownerPublicKey: b64e(this.deps.identity.pub),
expiresAt: new Date(this.now() + parsed.data.days * 86400_000).toISOString() })
}
await this.syncing
await this.sync()
} else if (type === 'harness_share_remove') {
if (typeof payload.id !== 'string' || !this.deps.grants.revoke(payload.id, machineId, agentId)) {
return { error: 'INVITATION_NOT_FOUND', detail: 'This invitation is no longer available.' }
}
for (const [id, observer] of this.observers) if (observer.grant.id === payload.id) this.close(id, 'Access removed')
await this.syncing
await this.sync()
} else if (type !== 'harness_share_list') return { error: 'UNSUPPORTED' }
return { shares: this.deps.grants.list(machineId, agentId).map(grant => ({
id: grant.id, email: grant.recipientEmail, expiresAt: grant.expiresAt,
expired: Date.parse(grant.expiresAt) <= this.now(), pending: grant.pending, error: grant.publicationError,
watching: [...this.observers.values()].filter(o => o.grant.id === grant.id).length,
})) }
}
sync(): Promise<void> {
if (this.syncing) return this.syncing
const run = async () => {
for (const grant of this.deps.grants.all().filter(g => g.pending && g.machineId === this.deps.machineId())) {
try {
const result = await this.deps.publish(grant.revoked ? 'DELETE' : 'PUT', `/api/harness-shares/${grant.id}`,
grant.revoked ? undefined : { machineId: grant.machineId, agentId: grant.agentId,
recipientEmail: grant.recipientEmail, name: grant.name, engine: grant.engine,
ownerPublicKey: grant.ownerPublicKey, expiresAt: grant.expiresAt })
if (result.status >= 200 && result.status < 300 || grant.revoked && result.status === 404) {
// A revoke may have happened while this network request was in flight.
const current = this.deps.grants.all().find(g => g.id === grant.id)
if (current?.revoked === grant.revoked && current.expiresAt === grant.expiresAt) this.deps.grants.synced(grant.id)
} else if (!grant.revoked && [400, 403, 409, 422].includes(result.status)) {
const body = result.body as { error?: { message?: string }; message?: string }
const current = this.deps.grants.all().find(g => g.id === grant.id)
if (current && !current.revoked && current.expiresAt === grant.expiresAt) {
this.deps.grants.failed(grant.id, body.error?.message || body.message || 'This invitation could not be shared. Remove it or add the email again to retry.')
}
}
} catch { /* Persisted pending change is retried, including after daemon restart. */ }
}
}
this.syncing = run().finally(() => { this.syncing = null })
return this.syncing
}
}