feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
@@ -0,0 +1,280 @@
import { describe, expect, it, vi } from 'vitest'
import {
AtspiSemanticCore,
type AtspiRawNode,
type AtspiTransport
} from './atspi-semantic-core'
const transportFor = (
nodes: Record<string, AtspiRawNode>,
children: Record<string, string[]>
): AtspiTransport => ({
readNode: vi.fn(async (reference) => {
const node = nodes[reference]
if (!node) {
throw new Error('missing node')
}
return node
}),
listChildren: vi.fn(async (reference) => children[reference] ?? []),
invoke: vi.fn(async () => true),
setText: vi.fn(async () => true),
select: vi.fn(async () => true),
focus: vi.fn(async () => true)
})
const node = (
nativeReference: string,
overrides: Partial<AtspiRawNode> = {}
): AtspiRawNode => ({
nativeReference,
owner: 'private-owner',
window: 'private-window',
role: 'push button',
name: 'Action',
states: ['enabled', 'enabled'],
actions: ['Click'],
geometry: { x: -10, y: 20, width: 100, height: 30 },
...overrides
})
describe('AtspiSemanticCore', () => {
it('traverses a cyclic tree safely and returns only normalized opaque data', async () => {
const transport = transportFor(
{
'/raw/root': node('/raw/root', {
role: 'Application',
name: ' Demo\u0000 app '
}),
'/raw/child': node('/raw/child')
},
{
'/raw/root': ['/raw/child'],
'/raw/child': ['/raw/root']
}
)
let token = 0
const core = new AtspiSemanticCore(transport, {
createToken: () => `opaque-${++token}`
})
const tree = await core.snapshot('/raw/root', new AbortController().signal)
const serialized = JSON.stringify(tree)
expect(tree.truncated).toBe(true)
expect(tree.root).toMatchObject({
ref: 'opaque-1',
role: 'application',
name: 'Demo app',
states: ['enabled'],
children: [
{
ref: 'opaque-2',
role: 'push-button',
actions: ['click'],
children: []
}
]
})
expect(serialized).not.toContain('/raw/')
expect(serialized).not.toContain('private-owner')
expect(serialized).not.toContain('private-window')
})
it('bounds node count, depth, children, text, and invalid geometry', async () => {
const transport = transportFor(
{
root: node('root', {
text: 'abcdef',
geometry: { x: 0, y: 0, width: -1, height: 10 }
}),
one: node('one'),
two: node('two')
},
{ root: ['one', 'two'] }
)
let token = 0
const core = new AtspiSemanticCore(transport, {
createToken: () => `ref-${++token}`,
maximumNodes: 2,
maximumChildrenPerNode: 1,
maximumTextLength: 3
})
const tree = await core.snapshot('root', new AbortController().signal)
expect(tree.truncated).toBe(true)
expect(tree.root.text).toBe('abc')
expect(tree.root.geometry).toBeUndefined()
expect(tree.root.children).toHaveLength(1)
})
it('redacts and refuses protected and password elements', async () => {
const transport = transportFor(
{
password: node('password', {
role: 'password text',
name: 'bank password',
text: 'hunter2',
value: 123,
password: true
})
},
{}
)
const core = new AtspiSemanticCore(transport, {
createToken: () => 'protected-ref'
})
const tree = await core.snapshot(
'password',
new AbortController().signal
)
expect(tree.root).toMatchObject({
ref: 'protected-ref',
name: '受保护内容',
actions: [],
protected: true
})
expect(tree.root.text).toBeUndefined()
expect(tree.root.value).toBeUndefined()
await expect(
core.focus('protected-ref', new AbortController().signal)
).rejects.toThrow('Protected')
expect(transport.focus).not.toHaveBeenCalled()
})
it('routes semantic operations and rejects stale or unsupported references', async () => {
let now = 10
let token = 0
const transport = transportFor(
{
first: node('first', { owner: 'owner-a', window: 'window-a' }),
second: node('second', { owner: 'owner-b', window: 'window-b' })
},
{}
)
const core = new AtspiSemanticCore(transport, {
now: () => now,
referenceTtlMs: 50,
createToken: () => `ref-${++token}`
})
await core.snapshot('first', new AbortController().signal)
await expect(
core.invoke('ref-1', 'CLICK', new AbortController().signal)
).resolves.toBe(true)
await expect(
core.setText('ref-1', 'new text', new AbortController().signal)
).resolves.toBe(true)
await expect(
core.select('ref-1', new AbortController().signal)
).resolves.toBe(true)
await expect(
core.invoke('ref-1', 'delete', new AbortController().signal)
).rejects.toThrow('unavailable')
core.invalidateWindow('owner-a', 'window-a')
await expect(
core.focus('ref-1', new AbortController().signal)
).rejects.toThrow('stale')
await core.snapshot('second', new AbortController().signal)
core.invalidateOwner('owner-b')
await expect(
core.focus('ref-2', new AbortController().signal)
).rejects.toThrow('stale')
await core.snapshot('first', new AbortController().signal)
now = 60
await expect(
core.focus('ref-3', new AbortController().signal)
).rejects.toThrow('stale')
now = 10
await core.snapshot('first', new AbortController().signal)
core.invalidateRegistryOwner()
await expect(
core.focus('ref-4', new AbortController().signal)
).rejects.toThrow('stale')
})
it('invalidates every reference from snapshot N when snapshot N+1 starts', async () => {
let token = 0
const core = new AtspiSemanticCore(
transportFor(
{
first: node('first'),
second: node('second')
},
{}
),
{ createToken: () => `generation-ref-${++token}` }
)
await core.snapshot('first', new AbortController().signal)
await core.snapshot('second', new AbortController().signal)
await expect(
core.focus('generation-ref-1', new AbortController().signal)
).rejects.toThrow('stale')
await expect(
core.focus('generation-ref-2', new AbortController().signal)
).resolves.toBe(true)
})
it('keeps the opaque reference registry hard bounded across snapshots', async () => {
let token = 0
const core = new AtspiSemanticCore(
transportFor(
{
root: node('root'),
child: node('child'),
extra: node('extra')
},
{ root: ['child', 'extra'] }
),
{
maximumReferences: 2,
createToken: () => `bounded-ref-${++token}`
}
)
for (let index = 0; index < 50; index += 1) {
const tree = await core.snapshot(
'root',
new AbortController().signal
)
expect(tree.truncated).toBe(true)
expect(
(
core as unknown as {
references: Map<string, unknown>
}
).references.size
).toBeLessThanOrEqual(2)
}
})
it('does not surface raw transport errors or accept duplicate opaque tokens', async () => {
const failedTransport = transportFor({ root: node('root') }, {})
failedTransport.readNode = vi.fn(async () => {
throw new Error('private bus path /org/a11y/atspi/accessible/123')
})
const failedCore = new AtspiSemanticCore(failedTransport)
await expect(
failedCore.snapshot('root', new AbortController().signal)
).rejects.toThrow('could not be read')
await expect(
failedCore.snapshot('root', new AbortController().signal)
).rejects.not.toThrow('/org/a11y/')
const duplicateCore = new AtspiSemanticCore(
transportFor(
{ root: node('root'), child: node('child') },
{ root: ['child'] }
),
{ createToken: () => 'duplicate-token' }
)
await expect(
duplicateCore.snapshot('root', new AbortController().signal)
).rejects.toThrow('reference creation failed')
})
})
@@ -0,0 +1,442 @@
import { randomBytes } from 'node:crypto'
export type AtspiNativeReference = string
export type AtspiRect = {
x: number
y: number
width: number
height: number
}
export type AtspiRawNode = {
nativeReference: AtspiNativeReference
owner: string
window: string
role?: string
name?: string
states?: string[]
actions?: string[]
text?: string
value?: number
geometry?: AtspiRect
protected?: boolean
password?: boolean
}
export interface AtspiTransport {
readNode(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<AtspiRawNode>
listChildren(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<AtspiNativeReference[]>
invoke(
reference: AtspiNativeReference,
action: string,
signal: AbortSignal
): Promise<boolean>
setText(
reference: AtspiNativeReference,
text: string,
signal: AbortSignal
): Promise<boolean>
select(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<boolean>
focus(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<boolean>
}
export type SemanticElement = {
ref: string
role: string
name: string
states: string[]
actions: string[]
text?: string
value?: number
geometry?: AtspiRect
protected: boolean
children: SemanticElement[]
}
export type SemanticTree = {
generation: number
truncated: boolean
root: SemanticElement
}
export type AtspiSemanticCoreOptions = {
now?: () => number
createToken?: () => string
referenceTtlMs?: number
maximumNodes?: number
maximumDepth?: number
maximumChildrenPerNode?: number
maximumTextLength?: number
maximumReferences?: number
}
type StoredReference = {
nativeReference: AtspiNativeReference
owner: string
window: string
generation: number
expiresAt: number
protected: boolean
actions: Set<string>
}
const cleanText = (
value: string | undefined,
maximumLength: number,
fallback = ''
): string => {
if (!value) {
return fallback
}
const clean = [...value]
.filter((character) => {
const code = character.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
.trim()
return clean.slice(0, maximumLength) || fallback
}
const normalizeIdentifier = (
value: string | undefined,
fallback: string
): string => {
const normalized = cleanText(value, 64, fallback)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return normalized || fallback
}
const normalizeGeometry = (geometry: AtspiRect | undefined): AtspiRect | undefined => {
if (
!geometry ||
!Number.isFinite(geometry.x) ||
!Number.isFinite(geometry.y) ||
!Number.isFinite(geometry.width) ||
!Number.isFinite(geometry.height) ||
geometry.width < 0 ||
geometry.height < 0
) {
return undefined
}
return {
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height
}
}
const sensitiveRoles = new Set([
'password-text',
'password',
'secret',
'credential'
])
export class AtspiSemanticCore {
private readonly now: () => number
private readonly createToken: () => string
private readonly referenceTtlMs: number
private readonly maximumNodes: number
private readonly maximumDepth: number
private readonly maximumChildrenPerNode: number
private readonly maximumTextLength: number
private readonly maximumReferences: number
private readonly references = new Map<string, StoredReference>()
private generation = 0
constructor(
private readonly transport: AtspiTransport,
options: AtspiSemanticCoreOptions = {}
) {
this.now = options.now ?? Date.now
this.createToken =
options.createToken ?? (() => randomBytes(24).toString('base64url'))
this.referenceTtlMs = options.referenceTtlMs ?? 3_000
this.maximumNodes = options.maximumNodes ?? 500
this.maximumDepth = options.maximumDepth ?? 24
this.maximumChildrenPerNode = options.maximumChildrenPerNode ?? 100
this.maximumTextLength = options.maximumTextLength ?? 4_096
this.maximumReferences =
options.maximumReferences ?? this.maximumNodes
if (
!Number.isSafeInteger(this.maximumReferences) ||
this.maximumReferences < 1 ||
this.maximumReferences > 10_000
) {
throw new Error('Invalid semantic reference capacity')
}
}
async snapshot(
rootReference: AtspiNativeReference,
signal: AbortSignal
): Promise<SemanticTree> {
this.generation += 1
const snapshotGeneration = this.generation
this.pruneReferences()
const visited = new Set<AtspiNativeReference>()
let remaining = Math.min(
this.maximumNodes,
this.maximumReferences
)
let truncated = false
const visit = async (
reference: AtspiNativeReference,
depth: number
): Promise<SemanticElement | undefined> => {
if (
signal.aborted ||
visited.has(reference) ||
depth > this.maximumDepth ||
remaining <= 0
) {
truncated = true
return undefined
}
visited.add(reference)
remaining -= 1
let raw: AtspiRawNode
try {
raw = await this.transport.readNode(reference, signal)
} catch {
throw new Error('Accessibility element could not be read')
}
if (snapshotGeneration !== this.generation) {
throw new Error('Accessibility snapshot was superseded')
}
const role = normalizeIdentifier(raw.role, 'unknown')
const isProtected =
raw.protected === true ||
raw.password === true ||
sensitiveRoles.has(role)
const actions = [
...new Set(
(raw.actions ?? []).map((action) =>
normalizeIdentifier(action, 'action')
)
)
].slice(0, 32)
const states = [
...new Set(
(raw.states ?? []).map((state) =>
normalizeIdentifier(state, 'state')
)
)
].slice(0, 64)
const opaqueReference = this.createToken()
if (
!/^[A-Za-z0-9_-]{5,256}$/.test(opaqueReference) ||
this.references.has(opaqueReference)
) {
throw new Error('Opaque accessibility reference creation failed')
}
while (this.references.size >= this.maximumReferences) {
const oldest = this.references.keys().next().value
if (oldest === undefined) {
break
}
this.references.delete(oldest)
truncated = true
}
this.references.set(opaqueReference, {
nativeReference: raw.nativeReference,
owner: raw.owner,
window: raw.window,
generation: snapshotGeneration,
expiresAt: this.now() + this.referenceTtlMs,
protected: isProtected,
actions: new Set(actions)
})
const element: SemanticElement = {
ref: opaqueReference,
role,
name: isProtected
? '受保护内容'
: cleanText(raw.name, 256, role),
states,
actions: isProtected ? [] : actions,
protected: isProtected,
children: []
}
if (!isProtected) {
const text = cleanText(raw.text, this.maximumTextLength)
if (text) {
element.text = text
}
if (raw.value !== undefined && Number.isFinite(raw.value)) {
element.value = raw.value
}
}
const geometry = normalizeGeometry(raw.geometry)
if (geometry) {
element.geometry = geometry
}
if (depth === this.maximumDepth || remaining <= 0) {
truncated = true
return element
}
let children: AtspiNativeReference[]
try {
children = await this.transport.listChildren(reference, signal)
} catch {
throw new Error('Accessibility children could not be read')
}
if (snapshotGeneration !== this.generation) {
throw new Error('Accessibility snapshot was superseded')
}
if (children.length > this.maximumChildrenPerNode) {
truncated = true
}
for (const child of children.slice(0, this.maximumChildrenPerNode)) {
const normalized = await visit(child, depth + 1)
if (normalized) {
element.children.push(normalized)
}
}
return element
}
const root = await visit(rootReference, 0)
if (snapshotGeneration !== this.generation) {
throw new Error('Accessibility snapshot was superseded')
}
if (!root) {
throw new Error('Accessibility tree root is unavailable')
}
return {
generation: snapshotGeneration,
truncated,
root
}
}
invalidateRegistryOwner(): void {
this.generation += 1
this.references.clear()
}
invalidateOwner(owner: string): void {
for (const [reference, stored] of this.references) {
if (stored.owner === owner) {
this.references.delete(reference)
}
}
}
invalidateWindow(owner: string, window: string): void {
for (const [reference, stored] of this.references) {
if (stored.owner === owner && stored.window === window) {
this.references.delete(reference)
}
}
}
async invoke(
reference: string,
action: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
const normalizedAction = normalizeIdentifier(action, 'action')
if (!stored.actions.has(normalizedAction)) {
throw new Error('Semantic action is unavailable')
}
try {
return await this.transport.invoke(
stored.nativeReference,
normalizedAction,
signal
)
} catch {
throw new Error('Semantic action failed')
}
}
async setText(
reference: string,
text: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
if (text.length > this.maximumTextLength) {
throw new Error('Text exceeds the semantic input limit')
}
try {
return await this.transport.setText(stored.nativeReference, text, signal)
} catch {
throw new Error('Semantic text update failed')
}
}
async select(
reference: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
try {
return await this.transport.select(stored.nativeReference, signal)
} catch {
throw new Error('Semantic selection failed')
}
}
async focus(
reference: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
try {
return await this.transport.focus(stored.nativeReference, signal)
} catch {
throw new Error('Semantic focus failed')
}
}
private resolve(reference: string): StoredReference {
this.pruneReferences()
const stored = this.references.get(reference)
if (
!stored ||
stored.generation !== this.generation ||
this.now() >= stored.expiresAt
) {
this.references.delete(reference)
throw new Error('Semantic element is stale')
}
if (stored.protected) {
throw new Error('Protected semantic element cannot be controlled')
}
return stored
}
private pruneReferences(): void {
const timestamp = this.now()
for (const [reference, stored] of this.references) {
if (
stored.generation !== this.generation ||
timestamp >= stored.expiresAt
) {
this.references.delete(reference)
}
}
}
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
probeLinuxDesktopCapabilities,
type LinuxCapabilityProbes
} from './capability-probe'
const probes = (
overrides: Partial<LinuxCapabilityProbes> = {}
): LinuxCapabilityProbes => ({
sessionBus: async () => true,
accessibilityBus: async () => ({
busOwner: true,
registryOwner: true
}),
portalVersions: async () => ({ screenCast: 4, remoteDesktop: 2 }),
pipeWire: async () => true,
eis: async () => true,
x11: async () => false,
xTest: async () => false,
ydotool: async () => false,
electronCapture: async () => true,
...overrides
})
describe('probeLinuxDesktopCapabilities', () => {
it('reports concrete protocol checks without using desktop labels', async () => {
const result = await probeLinuxDesktopCapabilities(probes(), {
XDG_CURRENT_DESKTOP: 'GNOME:Treeland'
})
expect(result.overall).toBe('supported')
expect(result.sessionLabels).toEqual(['GNOME', 'Treeland'])
expect(result.checks).toContainEqual({
capability: 'portal-remote-desktop',
status: 'supported',
diagnostic: 'RemoteDesktop portal version 2 responded'
})
expect(result.checks.find((check) => check.capability === 'xtest')?.status)
.toBe('unavailable')
})
it('does not infer availability from a known desktop product name', async () => {
const unavailable = probes({
sessionBus: async () => false,
accessibilityBus: async () => ({
busOwner: false,
registryOwner: false
}),
portalVersions: async () => ({}),
pipeWire: async () => false,
eis: async () => false,
electronCapture: async () => false
})
const result = await probeLinuxDesktopCapabilities(unavailable, {
XDG_CURRENT_DESKTOP: 'KDE'
})
expect(result.overall).toBe('unavailable')
expect(result.sessionLabels).toEqual(['KDE'])
expect(result.checks.every((check) => check.status === 'unavailable')).toBe(
true
)
})
it('contains bounded diagnostics rather than thrown details or environment values', async () => {
const result = await probeLinuxDesktopCapabilities(
probes({
sessionBus: async () => {
throw new Error(
'address=unix:path=/run/user/1000/bus token=very-secret'
)
},
ydotool: async () => true,
accessibilityBus: async () => ({
busOwner: true,
registryOwner: false
})
}),
{ DBUS_SESSION_BUS_ADDRESS: 'private-address' }
)
const diagnostics = result.checks
.map((check) => check.diagnostic)
.join(' ')
expect(diagnostics).not.toContain('/run/')
expect(diagnostics).not.toContain('secret')
expect(diagnostics).not.toContain('private-address')
expect(
result.checks.find((check) => check.capability === 'accessibility')
?.status
).toBe('degraded')
expect(
result.checks.find((check) => check.capability === 'ydotool')?.status
).toBe('supported')
})
})
+237
View File
@@ -0,0 +1,237 @@
export type CapabilityStatus = 'supported' | 'degraded' | 'unavailable'
export type CapabilityCheck = {
capability:
| 'session-bus'
| 'accessibility'
| 'portal-screen-cast'
| 'portal-remote-desktop'
| 'pipewire'
| 'eis'
| 'x11'
| 'xtest'
| 'ydotool'
| 'electron-capture'
status: CapabilityStatus
diagnostic: string
}
export type LinuxDesktopCapabilities = {
overall: CapabilityStatus
checks: CapabilityCheck[]
sessionLabels: string[]
}
export interface LinuxCapabilityProbes {
sessionBus(): Promise<boolean>
accessibilityBus(): Promise<{
busOwner: boolean
registryOwner: boolean
}>
portalVersions(): Promise<{
screenCast?: number
remoteDesktop?: number
}>
pipeWire(): Promise<boolean>
eis(): Promise<boolean>
x11(): Promise<boolean>
xTest(): Promise<boolean>
ydotool(): Promise<boolean>
electronCapture(): Promise<boolean>
}
const diagnostic = (
available: boolean,
positive: string,
negative: string
): string => (available ? positive : negative)
const statusRank: Record<CapabilityStatus, number> = {
unavailable: 0,
degraded: 1,
supported: 2
}
const settle = async <T>(
probe: () => Promise<T>,
fallback: T
): Promise<T> => {
try {
return await probe()
} catch {
return fallback
}
}
const sessionLabels = (source: NodeJS.ProcessEnv): string[] => {
const text = [
source.XDG_CURRENT_DESKTOP,
source.XDG_SESSION_DESKTOP,
source.DESKTOP_SESSION
]
.filter((value): value is string => typeof value === 'string')
.join(':')
.toLowerCase()
const labels = [
['gnome', 'GNOME'],
['kde', 'KDE'],
['deepin', 'DDE'],
['dde', 'DDE'],
['treeland', 'Treeland'],
['ukui', 'UKUI']
] as const
return [
...new Set(
labels
.filter(([needle]) => text.includes(needle))
.map(([, label]) => label)
)
]
}
/**
* Probes protocols and concrete operations. Desktop names are intentionally
* excluded from capability decisions and are returned only as diagnostics.
*/
export async function probeLinuxDesktopCapabilities(
probes: LinuxCapabilityProbes,
environment: NodeJS.ProcessEnv = process.env
): Promise<LinuxDesktopCapabilities> {
const [
bus,
accessibility,
portals,
pipeWire,
eis,
x11,
xTest,
ydotool,
electronCapture
] = await Promise.all([
settle(() => probes.sessionBus(), false),
settle(() => probes.accessibilityBus(), {
busOwner: false,
registryOwner: false
}),
settle(() => probes.portalVersions(), {}),
settle(() => probes.pipeWire(), false),
settle(() => probes.eis(), false),
settle(() => probes.x11(), false),
settle(() => probes.xTest(), false),
settle(() => probes.ydotool(), false),
settle(() => probes.electronCapture(), false)
])
const screenCast = portals.screenCast ?? 0
const remoteDesktop = portals.remoteDesktop ?? 0
const checks: CapabilityCheck[] = [
{
capability: 'session-bus',
status: bus ? 'supported' : 'unavailable',
diagnostic: diagnostic(
bus,
'Session bus responded',
'Session bus did not respond'
)
},
{
capability: 'accessibility',
status:
accessibility.busOwner && accessibility.registryOwner
? 'supported'
: accessibility.busOwner
? 'degraded'
: 'unavailable',
diagnostic:
accessibility.busOwner && accessibility.registryOwner
? 'Accessibility bus and registry responded'
: accessibility.busOwner
? 'Accessibility bus responded but registry is unavailable'
: 'Accessibility bus is unavailable'
},
{
capability: 'portal-screen-cast',
status: screenCast > 0 ? 'supported' : 'unavailable',
diagnostic:
screenCast > 0
? `ScreenCast portal version ${screenCast} responded`
: 'ScreenCast portal is unavailable'
},
{
capability: 'portal-remote-desktop',
status: remoteDesktop > 0 ? 'supported' : 'unavailable',
diagnostic:
remoteDesktop > 0
? `RemoteDesktop portal version ${remoteDesktop} responded`
: 'RemoteDesktop portal is unavailable'
},
{
capability: 'pipewire',
status: pipeWire ? 'supported' : 'unavailable',
diagnostic: diagnostic(
pipeWire,
'PipeWire connection succeeded',
'PipeWire connection failed'
)
},
{
capability: 'eis',
status: eis ? 'supported' : 'unavailable',
diagnostic: diagnostic(
eis,
'EIS helper handshake succeeded',
'EIS helper handshake failed'
)
},
{
capability: 'x11',
status: x11 ? 'supported' : 'unavailable',
diagnostic: diagnostic(
x11,
'X11 connection succeeded',
'X11 connection failed'
)
},
{
capability: 'xtest',
status: x11 && xTest ? 'supported' : 'unavailable',
diagnostic:
x11 && xTest
? 'XTest extension query succeeded'
: 'XTest is unavailable on the proven display connection'
},
{
capability: 'ydotool',
status: ydotool ? 'supported' : 'unavailable',
diagnostic: ydotool
? 'Optional ydotool helper passed its direct probe'
: 'Optional ydotool helper is unavailable'
},
{
capability: 'electron-capture',
status: electronCapture ? 'supported' : 'unavailable',
diagnostic: diagnostic(
electronCapture,
'Electron one-shot capture succeeded',
'Electron one-shot capture failed'
)
}
]
const usable = checks.filter(
(check) =>
check.capability !== 'session-bus' &&
check.capability !== 'ydotool'
)
const best = usable.reduce<CapabilityStatus>(
(current, check) =>
statusRank[check.status] > statusRank[current] ? check.status : current,
'unavailable'
)
return {
overall: best,
checks,
sessionLabels: sessionLabels(environment)
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
CoordinateMapper,
type CapturedFrameLayout,
type DesktopLayout
} from './coordinate-mapper'
const layout: DesktopLayout = {
revision: 7,
capturedAt: 1_000,
displays: [
{
id: 'left',
logicalBounds: { x: -1024, y: -100, width: 1024, height: 768 },
scale: 1.25,
rotation: 0
},
{
id: 'rotated',
logicalBounds: { x: 0, y: 0, width: 800, height: 600 },
scale: 2,
rotation: 90
}
]
}
const frame = (
overrides: Partial<CapturedFrameLayout> = {}
): CapturedFrameLayout => ({
frameId: 'frame-1',
displayId: 'left',
sourceBounds: { x: -1024, y: -100, width: 1024, height: 768 },
width: 1280,
height: 960,
layoutRevision: 7,
capturedAt: 1_001,
expiresAt: 2_000,
...overrides
})
describe('CoordinateMapper', () => {
it('maps negative origins and fractional display scale', () => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
expect(mapper.mapPoint({ x: -512, y: 284 }, frame())).toEqual({
frameId: 'frame-1',
x: 640,
y: 480
})
})
it('maps rotated displays into their physical frame orientation', () => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
const rotated = frame({
frameId: 'rotated-frame',
displayId: 'rotated',
sourceBounds: { x: 0, y: 0, width: 800, height: 600 },
width: 1200,
height: 1600
})
expect(mapper.mapPoint({ x: 400, y: 300 }, rotated)).toEqual({
frameId: 'rotated-frame',
x: 600,
y: 800
})
expect(mapper.mapPoint({ x: 0, y: 0 }, rotated)).toEqual({
frameId: 'rotated-frame',
x: 1199,
y: 0
})
})
it('maps bounded rectangles without crossing frame edges', () => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
expect(
mapper.mapRect(
{ x: -1024, y: -100, width: 1024, height: 768 },
frame()
)
).toEqual({ x: 0, y: 0, width: 1280, height: 960 })
})
it.each([
frame({ layoutRevision: 6 }),
frame({ expiresAt: 1_100 }),
frame({ width: 1270 }),
frame({
sourceBounds: { x: -2000, y: -100, width: 1024, height: 768 }
})
])('rejects stale or inconsistent captured frame layout', (captured) => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
expect(() => mapper.mapPoint({ x: -512, y: 284 }, captured)).toThrow()
})
it('rejects stale and non-monotonic desktop layouts', () => {
const mapper = new CoordinateMapper(() => 7_000)
mapper.updateLayout(layout)
expect(() => mapper.mapPoint({ x: -512, y: 284 }, frame())).toThrow(
'stale'
)
expect(() => mapper.updateLayout(layout)).toThrow('revision is stale')
})
})
+212
View File
@@ -0,0 +1,212 @@
export type DesktopPoint = {
x: number
y: number
}
export type DesktopRect = DesktopPoint & {
width: number
height: number
}
export type DisplayRotation = 0 | 90 | 180 | 270
export type DisplayLayout = {
id: string
logicalBounds: DesktopRect
scale: number
rotation: DisplayRotation
}
export type DesktopLayout = {
revision: number
capturedAt: number
displays: DisplayLayout[]
}
export type CapturedFrameLayout = {
frameId: string
displayId: string
sourceBounds: DesktopRect
width: number
height: number
layoutRevision: number
capturedAt: number
expiresAt: number
}
export type FramePoint = {
frameId: string
x: number
y: number
}
const finiteRect = (rect: DesktopRect): boolean =>
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.width) &&
Number.isFinite(rect.height) &&
rect.width > 0 &&
rect.height > 0
const containsPoint = (rect: DesktopRect, point: DesktopPoint): boolean =>
point.x >= rect.x &&
point.y >= rect.y &&
point.x < rect.x + rect.width &&
point.y < rect.y + rect.height
const containsRect = (outer: DesktopRect, inner: DesktopRect): boolean =>
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y + inner.height <= outer.y + outer.height
export class CoordinateMapper {
private layout: DesktopLayout | undefined
constructor(
private readonly now: () => number = Date.now,
private readonly maximumLayoutAgeMs = 5_000
) {}
updateLayout(layout: DesktopLayout): void {
if (
!Number.isSafeInteger(layout.revision) ||
layout.revision < 1 ||
!Number.isFinite(layout.capturedAt) ||
layout.displays.length === 0 ||
layout.displays.some(
(display) =>
!display.id ||
!finiteRect(display.logicalBounds) ||
!Number.isFinite(display.scale) ||
display.scale <= 0 ||
display.scale > 8
) ||
new Set(layout.displays.map((display) => display.id)).size !==
layout.displays.length
) {
throw new Error('Invalid desktop layout')
}
if (this.layout && layout.revision <= this.layout.revision) {
throw new Error('Desktop layout revision is stale')
}
this.layout = structuredClone(layout)
}
mapPoint(
point: DesktopPoint,
frame: CapturedFrameLayout
): FramePoint {
const { display } = this.validateFrame(frame)
if (
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
!containsPoint(frame.sourceBounds, point)
) {
throw new Error('Desktop point is outside the captured frame')
}
const u = (point.x - frame.sourceBounds.x) / frame.sourceBounds.width
const v = (point.y - frame.sourceBounds.y) / frame.sourceBounds.height
const rotated = this.rotate(u, v, display.rotation)
return {
frameId: frame.frameId,
x: Math.min(frame.width - 1, Math.max(0, Math.floor(rotated.x * frame.width))),
y: Math.min(
frame.height - 1,
Math.max(0, Math.floor(rotated.y * frame.height))
)
}
}
mapRect(rect: DesktopRect, frame: CapturedFrameLayout): DesktopRect {
if (!finiteRect(rect) || !containsRect(frame.sourceBounds, rect)) {
throw new Error('Desktop rectangle is outside the captured frame')
}
const right = rect.x + rect.width
const bottom = rect.y + rect.height
const xInset = Math.min(rect.width / 2, Math.max(1e-9, rect.width * 1e-9))
const yInset = Math.min(
rect.height / 2,
Math.max(1e-9, rect.height * 1e-9)
)
const points = [
this.mapPoint({ x: rect.x, y: rect.y }, frame),
this.mapPoint({ x: right - xInset, y: rect.y }, frame),
this.mapPoint({ x: rect.x, y: bottom - yInset }, frame),
this.mapPoint(
{ x: right - xInset, y: bottom - yInset },
frame
)
]
const xs = points.map((point) => point.x)
const ys = points.map((point) => point.y)
const x = Math.min(...xs)
const y = Math.min(...ys)
return {
x,
y,
width: Math.max(...xs) - x + 1,
height: Math.max(...ys) - y + 1
}
}
private validateFrame(frame: CapturedFrameLayout): {
display: DisplayLayout
} {
const layout = this.layout
if (!layout) {
throw new Error('Desktop layout is unavailable')
}
if (
this.now() - layout.capturedAt >= this.maximumLayoutAgeMs ||
frame.layoutRevision !== layout.revision ||
this.now() >= frame.expiresAt ||
frame.capturedAt < layout.capturedAt
) {
throw new Error('Desktop layout or captured frame is stale')
}
const display = layout.displays.find(
(candidate) => candidate.id === frame.displayId
)
if (
!display ||
!frame.frameId ||
!finiteRect(frame.sourceBounds) ||
!containsRect(display.logicalBounds, frame.sourceBounds) ||
!Number.isSafeInteger(frame.width) ||
frame.width < 1 ||
!Number.isSafeInteger(frame.height) ||
frame.height < 1
) {
throw new Error('Invalid captured frame layout')
}
const rotated = display.rotation === 90 || display.rotation === 270
const expectedWidth =
(rotated ? frame.sourceBounds.height : frame.sourceBounds.width) *
display.scale
const expectedHeight =
(rotated ? frame.sourceBounds.width : frame.sourceBounds.height) *
display.scale
if (
Math.abs(frame.width - expectedWidth) > 1 ||
Math.abs(frame.height - expectedHeight) > 1
) {
throw new Error('Captured frame scale does not match the display')
}
return { display }
}
private rotate(u: number, v: number, rotation: DisplayRotation): DesktopPoint {
switch (rotation) {
case 0:
return { x: u, y: v }
case 90:
return { x: 1 - v, y: u }
case 180:
return { x: 1 - u, y: 1 - v }
case 270:
return { x: v, y: 1 - u }
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from 'vitest'
import {
LinuxDesktopInputRouter,
type InjectedInputBackend,
type SemanticInput,
type YdotoolInputBackend
} from './input-router'
const semantic = (result: boolean): SemanticInput => ({
perform: vi.fn(async () => result)
})
const backend = (): InjectedInputBackend => ({
inject: vi.fn(async () => undefined),
releasePressedInput: vi.fn(async () => undefined)
})
describe('LinuxDesktopInputRouter', () => {
it('always attempts the semantic action before native injection', async () => {
const portal = backend()
const router = new LinuxDesktopInputRouter({
semantic: semantic(true),
portal,
portalConsentActive: () => true,
provenX11: () => false
})
await expect(
router.route(
{ type: 'text', text: 'hello' },
new AbortController().signal
)
).resolves.toEqual({ route: 'semantic' })
expect(portal.inject).not.toHaveBeenCalled()
})
it('uses portal input only while user consent is active', async () => {
let consent = false
const portal = backend()
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portal,
portalConsentActive: () => consent,
provenX11: () => false
})
await expect(
router.route(
{ type: 'pointer-move', x: 1, y: 2 },
new AbortController().signal,
{ backend: 'portal' }
)
).rejects.toThrow('active user consent')
consent = true
await expect(
router.route(
{ type: 'pointer-move', x: 1, y: 2 },
new AbortController().signal
)
).resolves.toEqual({ route: 'portal' })
})
it('uses XTest only for a proven X11 connection', async () => {
let proven = false
const xTest = backend()
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portalConsentActive: () => false,
xTest,
provenX11: () => proven
})
await expect(
router.route(
{ type: 'key', key: 'Enter', pressed: true },
new AbortController().signal,
{ backend: 'xtest' }
)
).rejects.toThrow('proven X11')
proven = true
await expect(
router.route(
{ type: 'key', key: 'Enter', pressed: true },
new AbortController().signal
)
).resolves.toEqual({ route: 'xtest' })
})
it('never silently selects ydotool and requires its exact fixed executable', async () => {
const ydotool: YdotoolInputBackend = {
...backend(),
executablePath: '/usr/bin/ydotool'
}
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portalConsentActive: () => false,
provenX11: () => false,
ydotool,
ydotoolOptIn: true,
fixedYdotoolExecutable: '/usr/bin/ydotool'
})
await expect(
router.route(
{ type: 'scroll', deltaX: 0, deltaY: 1 },
new AbortController().signal
)
).rejects.toThrow('No consented')
await expect(
router.route(
{ type: 'scroll', deltaX: 0, deltaY: 1 },
new AbortController().signal,
{ backend: 'ydotool' }
)
).resolves.toEqual({ route: 'ydotool' })
expect(
() =>
new LinuxDesktopInputRouter({
semantic: semantic(false),
portalConsentActive: () => false,
provenX11: () => false,
ydotoolOptIn: true,
fixedYdotoolExecutable: 'ydotool'
})
).toThrow('absolute fixed executable')
})
it('releases pressed input best-effort when an operation is aborted', async () => {
const controller = new AbortController()
const portal = backend()
portal.inject = vi.fn(
async (_action, signal) =>
new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true
})
})
)
portal.releasePressedInput = vi.fn(async () => {
throw new Error('release failed')
})
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portal,
portalConsentActive: () => true,
provenX11: () => false
})
const routed = router.route(
{ type: 'pointer-button', button: 1, pressed: true },
controller.signal
)
await Promise.resolve()
controller.abort()
await expect(routed).rejects.toThrow()
expect(portal.releasePressedInput).toHaveBeenCalled()
})
it('releases partially pressed input after a non-abort injection failure', async () => {
const portal = backend()
portal.inject = vi.fn(async () => {
throw new Error('partial native injection')
})
portal.releasePressedInput = vi.fn(async () => {
throw new Error('best-effort release failed')
})
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portal,
portalConsentActive: () => true,
provenX11: () => false
})
await expect(
router.route(
{ type: 'key', key: 'Control_L', pressed: true },
new AbortController().signal
)
).rejects.toThrow('partial native injection')
expect(portal.releasePressedInput).toHaveBeenCalledOnce()
})
it.each([
{ type: 'pointer-move', x: Number.NaN, y: 0 } as const,
{ type: 'pointer-button', button: 0, pressed: true } as const,
{ type: 'scroll', deltaX: 0, deltaY: Number.POSITIVE_INFINITY } as const,
{ type: 'key', key: '\n', pressed: true } as const,
{ type: 'text', text: '\u0000secret' } as const
])('rejects malformed actions before any backend sees them', async (action) => {
const semanticInput = semantic(false)
const portal = backend()
const router = new LinuxDesktopInputRouter({
semantic: semanticInput,
portal,
portalConsentActive: () => true,
provenX11: () => false
})
await expect(
router.route(action, new AbortController().signal)
).rejects.toThrow('Invalid')
expect(semanticInput.perform).not.toHaveBeenCalled()
expect(portal.inject).not.toHaveBeenCalled()
})
})
+194
View File
@@ -0,0 +1,194 @@
import { isAbsolute } from 'node:path'
export type DesktopInputAction =
| { type: 'pointer-move'; x: number; y: number }
| { type: 'pointer-button'; button: number; pressed: boolean }
| { type: 'scroll'; deltaX: number; deltaY: number }
| { type: 'key'; key: string; pressed: boolean }
| { type: 'text'; text: string }
export type InputBackendName = 'portal' | 'xtest' | 'ydotool'
export interface SemanticInput {
perform(action: DesktopInputAction, signal: AbortSignal): Promise<boolean>
}
export interface InjectedInputBackend {
inject(action: DesktopInputAction, signal: AbortSignal): Promise<void>
releasePressedInput(): Promise<void>
}
export interface YdotoolInputBackend extends InjectedInputBackend {
readonly executablePath: string
}
export type InputRouterOptions = {
semantic: SemanticInput
portal?: InjectedInputBackend
portalConsentActive: () => boolean
xTest?: InjectedInputBackend
provenX11: () => boolean
ydotool?: YdotoolInputBackend
ydotoolOptIn?: boolean
fixedYdotoolExecutable?: string
}
export type RouteInputOptions = {
backend?: 'auto' | InputBackendName
}
export type RoutedInputResult = {
route: 'semantic' | InputBackendName
}
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const validateAction = (action: DesktopInputAction): void => {
switch (action.type) {
case 'pointer-move':
if (!Number.isFinite(action.x) || !Number.isFinite(action.y)) {
throw new Error('Invalid pointer coordinates')
}
return
case 'pointer-button':
if (
!Number.isSafeInteger(action.button) ||
action.button < 1 ||
action.button > 32
) {
throw new Error('Invalid pointer button')
}
return
case 'scroll':
if (
!Number.isFinite(action.deltaX) ||
!Number.isFinite(action.deltaY) ||
Math.abs(action.deltaX) > 100_000 ||
Math.abs(action.deltaY) > 100_000
) {
throw new Error('Invalid scroll delta')
}
return
case 'key':
if (
action.key.length < 1 ||
action.key.length > 64 ||
hasControlCharacters(action.key)
) {
throw new Error('Invalid key identifier')
}
return
case 'text':
if (
action.text.length > 4_096 ||
action.text.includes('\u0000')
) {
throw new Error('Invalid text input')
}
}
}
export class LinuxDesktopInputRouter {
private readonly usedBackends = new Set<InjectedInputBackend>()
constructor(private readonly options: InputRouterOptions) {
if (
options.ydotoolOptIn &&
(!options.fixedYdotoolExecutable ||
!isAbsolute(options.fixedYdotoolExecutable))
) {
throw new Error('ydotool requires an absolute fixed executable path')
}
}
async route(
action: DesktopInputAction,
signal: AbortSignal,
routeOptions: RouteInputOptions = {}
): Promise<RoutedInputResult> {
validateAction(action)
if (signal.aborted) {
throw signal.reason
}
if (await this.options.semantic.perform(action, signal)) {
return { route: 'semantic' }
}
const selected = this.selectBackend(routeOptions.backend ?? 'auto')
this.usedBackends.add(selected.backend)
let released = false
let releasePromise: Promise<void> | undefined
const releaseOnAbort = (): void => {
if (!released) {
released = true
releasePromise = this.releasePressedInput()
}
}
signal.addEventListener('abort', releaseOnAbort, { once: true })
try {
await selected.backend.inject(action, signal)
if (signal.aborted) {
releaseOnAbort()
throw signal.reason
}
return { route: selected.name }
} catch (error) {
await (releasePromise ?? this.releasePressedInput())
throw error
} finally {
signal.removeEventListener('abort', releaseOnAbort)
}
}
async releasePressedInput(): Promise<void> {
const backends = [...this.usedBackends]
this.usedBackends.clear()
await Promise.allSettled(
backends.map((backend) => backend.releasePressedInput())
)
}
private selectBackend(preference: 'auto' | InputBackendName): {
name: InputBackendName
backend: InjectedInputBackend
} {
if (preference === 'portal') {
if (!this.options.portal || !this.options.portalConsentActive()) {
throw new Error('Portal input requires active user consent')
}
return { name: 'portal', backend: this.options.portal }
}
if (preference === 'xtest') {
if (!this.options.xTest || !this.options.provenX11()) {
throw new Error('XTest requires a proven X11 connection')
}
return { name: 'xtest', backend: this.options.xTest }
}
if (preference === 'ydotool') {
const fixed = this.options.fixedYdotoolExecutable
if (
!this.options.ydotoolOptIn ||
!fixed ||
!this.options.ydotool ||
this.options.ydotool.executablePath !== fixed
) {
throw new Error(
'ydotool requires explicit opt-in and the fixed executable'
)
}
return { name: 'ydotool', backend: this.options.ydotool }
}
if (this.options.portal && this.options.portalConsentActive()) {
return { name: 'portal', backend: this.options.portal }
}
if (this.options.xTest && this.options.provenX11()) {
return { name: 'xtest', backend: this.options.xTest }
}
throw new Error('No consented or proven input backend is available')
}
}
@@ -0,0 +1,366 @@
import {
afterEach,
describe,
expect,
it,
vi,
type Mock
} from 'vitest'
import {
PortalDesktopSession,
PortalSessionError,
validatePipeWireFrameMetadata,
type ClosableResource,
type PortalResponse,
type PortalTransport
} from './portal-session'
type MockPortal = {
transport: PortalTransport
pipeWireClose: Mock<() => void>
eisClose: Mock<() => void>
}
const mockPortal = (
responseOverrides: Record<string, Partial<PortalResponse>> = {}
): MockPortal => {
const pipeWireClose = vi.fn<() => void>()
const eisClose = vi.fn<() => void>()
const resource = (close: Mock<() => void>): ClosableResource => ({
close: () => close()
})
const transport: PortalTransport = {
createSession: vi.fn(async () => ({ requestHandle: 'create' })),
selectDevices: vi.fn(async () => ({ requestHandle: 'devices' })),
selectSources: vi.fn(async () => ({ requestHandle: 'sources' })),
start: vi.fn(async () => ({ requestHandle: 'start' })),
waitForResponse: vi.fn(async (requestHandle) => ({
requestHandle,
response: 0,
results:
requestHandle === 'create'
? { session_handle: 'private-session' }
: {},
...responseOverrides[requestHandle]
})),
openPipeWireRemote: vi.fn(async () => resource(pipeWireClose)),
connectEis: vi.fn(async () => resource(eisClose)),
closeRequest: vi.fn(async () => undefined),
closeSession: vi.fn(async () => undefined)
}
return { transport, pipeWireClose, eisClose }
}
afterEach(() => {
vi.useRealTimers()
})
describe('PortalDesktopSession', () => {
it('performs the portal protocol in order and cleans every resource on stop', async () => {
const mock = mockPortal()
const session = new PortalDesktopSession(mock.transport)
await session.open(
{ devices: true, sources: true, parentWindow: 'window-token' },
new AbortController().signal
)
expect(session.state).toBe('active')
expect(session.hasActiveConsent).toBe(true)
expect(mock.transport.selectDevices).toHaveBeenCalledWith(
'private-session',
expect.any(AbortSignal)
)
expect(mock.transport.selectSources).toHaveBeenCalled()
expect(mock.transport.start).toHaveBeenCalledWith(
'private-session',
'window-token',
expect.any(AbortSignal)
)
await session.stop()
expect(session.state).toBe('stopped')
expect(session.hasActiveConsent).toBe(false)
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
expect(mock.eisClose).toHaveBeenCalledOnce()
expect(mock.transport.closeRequest).toHaveBeenCalledTimes(4)
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
})
it.each([
[1, 'cancelled'],
[2, 'denied']
] as const)('handles portal consent response %s as %s', async (code, reason) => {
const mock = mockPortal({
sources: { response: code }
})
const session = new PortalDesktopSession(mock.transport)
await expect(
session.open(
{ devices: false, sources: true },
new AbortController().signal
)
).rejects.toMatchObject({ reason })
expect(session.state).toBe('failed')
expect(mock.transport.closeRequest).toHaveBeenCalledWith('sources')
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
expect(mock.transport.openPipeWireRemote).not.toHaveBeenCalled()
})
it('rejects mismatched response handles and closes the request', async () => {
const mock = mockPortal({
create: { requestHandle: 'unrelated-response' }
})
const session = new PortalDesktopSession(mock.transport)
await expect(
session.open(
{ devices: true, sources: false },
new AbortController().signal
)
).rejects.toMatchObject({ reason: 'protocol' })
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('aborts a pending request on timeout and performs cleanup', async () => {
vi.useFakeTimers()
const mock = mockPortal()
mock.transport.waitForResponse = vi.fn(
async (_handle, signal) =>
new Promise<PortalResponse>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const session = new PortalDesktopSession(mock.transport)
const opening = session.open(
{ devices: true, sources: false, timeoutMs: 10 },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'timeout'
})
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('cleans active descriptors and revokes consent on portal owner loss', async () => {
const mock = mockPortal()
const session = new PortalDesktopSession(mock.transport)
await session.open(
{ devices: true, sources: true },
new AbortController().signal
)
await session.portalOwnerLost()
expect(session.state).toBe('failed')
expect(session.hasActiveConsent).toBe(false)
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
expect(mock.eisClose).toHaveBeenCalledOnce()
expect(mock.transport.closeSession).toHaveBeenCalledOnce()
})
it('stops an opening session without leaving requests or consent active', async () => {
const mock = mockPortal()
mock.transport.waitForResponse = vi.fn(
async (_handle, signal) =>
new Promise<PortalResponse>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const session = new PortalDesktopSession(mock.transport)
const opening = session.open(
{ devices: true, sources: false },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'aborted'
})
await Promise.resolve()
await session.stop()
await rejection
expect(session.state).toBe('stopped')
expect(session.hasActiveConsent).toBe(false)
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('prevents a late stopped open from mutating a reopened session', async () => {
const mock = mockPortal()
let resolveFirst:
| ((request: { requestHandle: string }) => void)
| undefined
const firstCreate = new Promise<{ requestHandle: string }>((resolve) => {
resolveFirst = resolve
})
let createCount = 0
mock.transport.createSession = vi.fn(async () => {
createCount += 1
if (createCount === 1) {
return firstCreate
}
return { requestHandle: 'create-second' }
})
mock.transport.waitForResponse = vi.fn(async (requestHandle) => ({
requestHandle,
response: 0,
results:
requestHandle === 'create-second'
? { session_handle: 'session-second' }
: {}
}))
const session = new PortalDesktopSession(mock.transport)
const firstOpening = session.open(
{ devices: true, sources: false },
new AbortController().signal
)
const firstRejection = expect(firstOpening).rejects.toMatchObject({
reason: 'aborted'
})
await vi.waitFor(() => {
expect(mock.transport.createSession).toHaveBeenCalledOnce()
})
await session.stop()
await firstRejection
await session.open(
{ devices: true, sources: false },
new AbortController().signal
)
expect(session.state).toBe('active')
resolveFirst?.({ requestHandle: 'late-first-create' })
await vi.waitFor(() => {
expect(mock.transport.closeRequest).toHaveBeenCalledWith(
'late-first-create'
)
})
expect(session.state).toBe('active')
expect(session.hasActiveConsent).toBe(true)
expect(mock.transport.closeSession).not.toHaveBeenCalledWith(
'session-second'
)
})
it.each(['stop', 'owner-loss'] as const)(
'bounds hung cleanup during %s',
async (lifecycle) => {
const mock = mockPortal()
const never = () => new Promise<void>(() => undefined)
mock.transport.closeRequest = vi.fn(never)
mock.transport.closeSession = vi.fn(never)
mock.transport.openPipeWireRemote = vi.fn(async () => ({
close: never
}))
const session = new PortalDesktopSession(mock.transport, {
cleanupTimeoutMs: 10
})
await session.open(
{ devices: false, sources: true },
new AbortController().signal
)
vi.useFakeTimers()
const cleanup =
lifecycle === 'stop'
? session.stop()
: session.portalOwnerLost()
await vi.advanceTimersByTimeAsync(10)
await expect(cleanup).resolves.toBeUndefined()
expect(session.state).toBe(
lifecycle === 'stop' ? 'stopped' : 'failed'
)
}
)
it('bounds hung cleanup after a failed open', async () => {
vi.useFakeTimers()
const mock = mockPortal({
sources: { response: 2 }
})
const never = () => new Promise<void>(() => undefined)
mock.transport.closeRequest = vi.fn(never)
mock.transport.closeSession = vi.fn(never)
const session = new PortalDesktopSession(mock.transport, {
cleanupTimeoutMs: 10
})
const opening = session.open(
{ devices: false, sources: true },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'denied'
})
await vi.waitFor(() => {
expect(mock.transport.closeSession).toHaveBeenCalled()
})
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(session.state).toBe('failed')
})
it('requires at least one consented portal capability', async () => {
const session = new PortalDesktopSession(mockPortal().transport)
await expect(
session.open(
{ devices: false, sources: false },
new AbortController().signal
)
).rejects.toBeInstanceOf(PortalSessionError)
await expect(
session.open(
{ devices: true, sources: false, timeoutMs: 0 },
new AbortController().signal
)
).rejects.toMatchObject({ reason: 'protocol' })
})
})
describe('validatePipeWireFrameMetadata', () => {
const valid = {
width: 1920,
height: 1080,
stride: 7680,
planes: [{ offset: 0, stride: 7680, bytes: 8_294_400 }],
byteLength: 8_294_400,
fpsNumerator: 60,
fpsDenominator: 1
}
it('accepts bounded, internally consistent frame metadata', () => {
expect(validatePipeWireFrameMetadata(valid)).toEqual(valid)
})
it.each([
{ ...valid, width: 0 },
{ ...valid, height: 20_000 },
{ ...valid, stride: 100 },
{ ...valid, fpsDenominator: 0 },
{ ...valid, fpsNumerator: 241 },
{ ...valid, byteLength: 10 },
{
...valid,
planes: [{ offset: 1, stride: 7680, bytes: 8_294_399 }]
},
{
...valid,
planes: [
{ offset: 0, stride: 7680, bytes: 100 },
{ offset: 50, stride: 960, bytes: 8_294_350 }
]
}
])('rejects unsafe or inconsistent metadata', (metadata) => {
expect(() => validatePipeWireFrameMetadata(metadata)).toThrow()
})
})
+564
View File
@@ -0,0 +1,564 @@
export type PortalSessionState =
| 'idle'
| 'creating'
| 'selecting'
| 'starting'
| 'active'
| 'stopping'
| 'stopped'
| 'failed'
export type PortalRequest = {
requestHandle: string
}
export type PortalResponse = {
requestHandle: string
response: number
results: Readonly<Record<string, unknown>>
}
export interface ClosableResource {
close(): void | Promise<void>
}
export interface PortalTransport {
createSession(signal: AbortSignal): Promise<PortalRequest>
selectDevices(
sessionHandle: string,
signal: AbortSignal
): Promise<PortalRequest>
selectSources(
sessionHandle: string,
signal: AbortSignal
): Promise<PortalRequest>
start(
sessionHandle: string,
parentWindow: string,
signal: AbortSignal
): Promise<PortalRequest>
waitForResponse(
requestHandle: string,
signal: AbortSignal
): Promise<PortalResponse>
openPipeWireRemote(
sessionHandle: string,
signal: AbortSignal
): Promise<ClosableResource>
connectEis?(
sessionHandle: string,
signal: AbortSignal
): Promise<ClosableResource>
closeRequest(requestHandle: string): Promise<void>
closeSession(sessionHandle: string): Promise<void>
}
export type PortalSessionOptions = {
devices: boolean
sources: boolean
parentWindow?: string
timeoutMs?: number
}
export type PortalDesktopSessionOptions = {
cleanupTimeoutMs?: number
}
export class PortalSessionError extends Error {
constructor(
readonly reason:
| 'cancelled'
| 'denied'
| 'protocol'
| 'timeout'
| 'aborted'
| 'owner-lost',
message: string
) {
super(message)
this.name = 'PortalSessionError'
}
}
const getSessionHandle = (response: PortalResponse): string => {
const handle = response.results.session_handle
if (typeof handle !== 'string' || handle.length === 0) {
throw new PortalSessionError(
'protocol',
'Portal did not return a session handle'
)
}
return handle
}
const throwForResponse = (response: PortalResponse): void => {
if (response.response === 0) {
return
}
if (response.response === 1) {
throw new PortalSessionError('cancelled', 'Portal consent was cancelled')
}
if (response.response === 2) {
throw new PortalSessionError('denied', 'Portal consent was denied')
}
throw new PortalSessionError('protocol', 'Portal returned an unknown response')
}
export class PortalDesktopSession {
private stateValue: PortalSessionState = 'idle'
private generation = 0
private operation: PortalOperation | undefined
private openingPromise: Promise<void> | undefined
private readonly cleanupTimeoutMs: number
constructor(
private readonly transport: PortalTransport,
options: PortalDesktopSessionOptions = {}
) {
this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? 1_000
if (
!Number.isSafeInteger(this.cleanupTimeoutMs) ||
this.cleanupTimeoutMs < 1 ||
this.cleanupTimeoutMs > 30_000
) {
throw new Error('Invalid portal cleanup timeout')
}
}
get state(): PortalSessionState {
return this.stateValue
}
get hasActiveConsent(): boolean {
return (
this.stateValue === 'active' &&
this.operation !== undefined &&
!this.operation.ownerLost
)
}
async open(
options: PortalSessionOptions,
outerSignal: AbortSignal
): Promise<void> {
if (this.stateValue !== 'idle' && this.stateValue !== 'stopped') {
throw new PortalSessionError('protocol', 'Portal session is already open')
}
if (!options.devices && !options.sources) {
throw new PortalSessionError(
'protocol',
'Portal session must request a concrete capability'
)
}
const timeoutMs = options.timeoutMs ?? 30_000
if (
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > 120_000
) {
throw new PortalSessionError('protocol', 'Invalid portal timeout')
}
this.stateValue = 'creating'
const operation: PortalOperation = {
generation: ++this.generation,
controller: new AbortController(),
requestHandles: new Set(),
resources: new Set(),
ownerLost: false,
stopRequested: false
}
this.operation = operation
const opening = this.openOperation(
operation,
options,
outerSignal,
timeoutMs
)
this.openingPromise = opening
opening
.finally(() => {
if (this.openingPromise === opening) {
this.openingPromise = undefined
}
})
.catch(() => {
// The caller receives the original open rejection.
})
return opening
}
private async openOperation(
operation: PortalOperation,
options: PortalSessionOptions,
outerSignal: AbortSignal,
timeoutMs: number
): Promise<void> {
const controller = operation.controller
const abort = (): void => controller.abort(outerSignal.reason)
outerSignal.addEventListener('abort', abort, { once: true })
if (outerSignal.aborted) {
abort()
}
const timer = setTimeout(
() => controller.abort(new PortalSessionError('timeout', 'Portal timed out')),
timeoutMs
)
try {
const created = await this.request(
operation,
() => this.transport.createSession(controller.signal),
controller.signal
)
operation.sessionHandle = getSessionHandle(created)
this.setOwnedState(operation, 'selecting')
if (options.devices) {
await this.request(
operation,
() =>
this.transport.selectDevices(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
}
if (options.sources) {
await this.request(
operation,
() =>
this.transport.selectSources(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
}
this.setOwnedState(operation, 'starting')
await this.request(
operation,
() =>
this.transport.start(
this.requireSessionHandle(operation),
options.parentWindow ?? '',
controller.signal
),
controller.signal
)
if (options.sources) {
operation.resources.add(
await this.acquireResource(
this.transport.openPipeWireRemote(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
)
}
if (options.devices && this.transport.connectEis) {
operation.resources.add(
await this.acquireResource(
this.transport.connectEis(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
)
}
if (controller.signal.aborted) {
throw controller.signal.reason
}
this.setOwnedState(operation, 'active')
} catch (error) {
await this.cleanup(operation)
this.setOwnedState(
operation,
operation.stopRequested ? 'stopped' : 'failed'
)
if (operation.ownerLost) {
throw new PortalSessionError('owner-lost', 'Portal owner disappeared')
}
if (controller.signal.aborted) {
const reason = controller.signal.reason
if (reason instanceof PortalSessionError) {
throw reason
}
throw new PortalSessionError('aborted', 'Portal session was aborted')
}
throw error
} finally {
clearTimeout(timer)
outerSignal.removeEventListener('abort', abort)
}
}
async stop(): Promise<void> {
if (this.stateValue === 'stopped' || this.stateValue === 'idle') {
this.stateValue = 'stopped'
return
}
this.stateValue = 'stopping'
const operation = this.operation
if (!operation) {
this.stateValue = 'stopped'
return
}
operation.stopRequested = true
operation.controller.abort(
new PortalSessionError('aborted', 'Portal session stopped')
)
await this.cleanup(operation)
await this.awaitOpening(operation)
if (this.operation === operation) {
this.operation = undefined
this.stateValue = 'stopped'
}
}
async portalOwnerLost(): Promise<void> {
const operation = this.operation
if (!operation) {
this.stateValue = 'failed'
return
}
operation.ownerLost = true
operation.controller.abort(
new PortalSessionError('owner-lost', 'Portal owner disappeared')
)
await this.cleanup(operation)
await this.awaitOpening(operation)
if (this.operation === operation) {
this.operation = undefined
this.stateValue = 'failed'
}
}
private async request(
operation: PortalOperation,
initiate: () => Promise<PortalRequest>,
signal: AbortSignal
): Promise<PortalResponse> {
const initiating = Promise.resolve().then(initiate)
let request: PortalRequest
try {
request = await this.abortable(initiating, signal)
} catch (error) {
void initiating
.then((lateRequest) =>
this.runCleanup([
() => this.transport.closeRequest(lateRequest.requestHandle)
])
)
.catch(() => undefined)
throw error
}
operation.requestHandles.add(request.requestHandle)
const response = await this.abortable(
this.transport.waitForResponse(request.requestHandle, signal),
signal
)
if (response.requestHandle !== request.requestHandle) {
throw new PortalSessionError(
'protocol',
'Portal response handle did not match its request'
)
}
throwForResponse(response)
return response
}
private requireSessionHandle(operation: PortalOperation): string {
if (!operation.sessionHandle) {
throw new PortalSessionError(
'protocol',
'Portal session handle is unavailable'
)
}
return operation.sessionHandle
}
private async cleanup(operation: PortalOperation): Promise<void> {
if (operation.cleanupPromise) {
return operation.cleanupPromise
}
const resources = [...operation.resources]
const requests = [...operation.requestHandles]
const sessionHandle = operation.sessionHandle
operation.resources.clear()
operation.requestHandles.clear()
operation.sessionHandle = undefined
operation.cleanupPromise = this.runCleanup([
...resources.map((resource) => () => resource.close()),
...requests.map(
(handle) => () => this.transport.closeRequest(handle)
),
...(sessionHandle
? [() => this.transport.closeSession(sessionHandle)]
: [])
])
return operation.cleanupPromise
}
private async acquireResource(
resourcePromise: Promise<ClosableResource>,
signal: AbortSignal
): Promise<ClosableResource> {
try {
return await this.abortable(resourcePromise, signal)
} catch (error) {
void resourcePromise
.then((resource) =>
this.runCleanup([() => resource.close()])
)
.catch(() => undefined)
throw error
}
}
private abortable<T>(
promise: Promise<T>,
signal: AbortSignal
): Promise<T> {
if (signal.aborted) {
promise.catch(() => undefined)
return Promise.reject(signal.reason)
}
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
promise.catch(() => undefined)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', abort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
private async runCleanup(
operations: Array<() => void | Promise<void>>
): Promise<void> {
const cleanup = Promise.allSettled(
operations.map((operation) =>
Promise.resolve().then(operation)
)
).then(() => undefined)
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.cleanupTimeoutMs)
})
await Promise.race([cleanup, timeout])
if (timer !== undefined) {
clearTimeout(timer)
}
}
private async awaitOpening(operation: PortalOperation): Promise<void> {
const opening = this.openingPromise
if (!opening || this.operation !== operation) {
return
}
await opening.catch(() => undefined)
}
private setOwnedState(
operation: PortalOperation,
state: PortalSessionState
): void {
if (
this.operation === operation &&
this.operation.generation === operation.generation
) {
this.stateValue = state
}
}
}
type PortalOperation = {
generation: number
controller: AbortController
sessionHandle?: string
requestHandles: Set<string>
resources: Set<ClosableResource>
ownerLost: boolean
stopRequested: boolean
cleanupPromise?: Promise<void>
}
export type PipeWirePlaneMetadata = {
offset: number
stride: number
bytes: number
}
export type PipeWireFrameMetadata = {
width: number
height: number
stride: number
planes: PipeWirePlaneMetadata[]
byteLength: number
fpsNumerator: number
fpsDenominator: number
}
const isPositiveInteger = (value: number): boolean =>
Number.isSafeInteger(value) && value > 0
export function validatePipeWireFrameMetadata(
metadata: PipeWireFrameMetadata
): PipeWireFrameMetadata {
if (
!isPositiveInteger(metadata.width) ||
!isPositiveInteger(metadata.height) ||
metadata.width > 16_384 ||
metadata.height > 16_384 ||
!isPositiveInteger(metadata.stride) ||
metadata.stride < metadata.width ||
metadata.stride > 1_048_576 ||
!isPositiveInteger(metadata.byteLength) ||
metadata.byteLength > 256 * 1024 * 1024 ||
!isPositiveInteger(metadata.fpsNumerator) ||
!isPositiveInteger(metadata.fpsDenominator) ||
metadata.fpsNumerator / metadata.fpsDenominator > 240 ||
metadata.planes.length < 1 ||
metadata.planes.length > 4
) {
throw new Error('Invalid PipeWire frame metadata')
}
let previousEnd = 0
for (const [index, plane] of metadata.planes.entries()) {
if (
!Number.isSafeInteger(plane.offset) ||
plane.offset < 0 ||
!isPositiveInteger(plane.stride) ||
plane.stride > 1_048_576 ||
!isPositiveInteger(plane.bytes) ||
(index === 0 && plane.offset !== 0) ||
plane.offset < previousEnd ||
plane.offset + plane.bytes > metadata.byteLength ||
(index === 0 &&
(plane.stride !== metadata.stride ||
plane.bytes < metadata.stride * metadata.height))
) {
throw new Error('Invalid PipeWire plane metadata')
}
previousEnd = plane.offset + plane.bytes
}
if (previousEnd !== metadata.byteLength) {
throw new Error('PipeWire plane metadata does not cover the frame buffer')
}
return structuredClone(metadata)
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
buildDesktopHelperEnvironment,
type SecurePathMetadata,
type SessionEnvironmentFileSystem
} from './session-environment'
const safeMetadata = (
canonicalPath: string,
kind: 'file' | 'directory'
): SecurePathMetadata => ({
canonicalPath,
uid: 1000,
mode: kind === 'file' ? 0o600 : 0o700,
isDirectory: kind === 'directory',
isFile: kind === 'file',
isSymbolicLink: false
})
const fileSystem = (
overrides: Partial<SecurePathMetadata> = {}
): SessionEnvironmentFileSystem => ({
inspect: async (path) => ({
...safeMetadata(
path,
path.endsWith('.Xauthority') ? 'file' : 'directory'
),
...overrides
})
})
describe('buildDesktopHelperEnvironment', () => {
it('constructs a minimal fresh helper environment', async () => {
const environment = await buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(),
source: {
PATH: '/usr/bin:/bin',
LANG: 'zh_CN.UTF-8',
DISPLAY: ':0.0',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
XAUTHORITY: '/home/user/.Xauthority',
NO_AT_BRIDGE: '1',
LD_PRELOAD: '/tmp/inject.so',
NODE_OPTIONS: '--require /tmp/inject.js',
GTK_MODULES: 'inject',
QT_PLUGIN_PATH: '/tmp/plugins',
HTTPS_PROXY: 'http://proxy.invalid',
API_KEY: 'secret'
}
})
expect(environment).toEqual({
PATH: '/usr/bin:/bin',
LANG: 'zh_CN.UTF-8',
DISPLAY: ':0.0',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
XAUTHORITY: '/home/user/.Xauthority',
NO_AT_BRIDGE: '1'
})
})
it.each([
['symbolic link', { isSymbolicLink: true }],
['world writable', { mode: 0o707 }],
['group writable', { mode: 0o720 }],
['wrong owner', { uid: 1001 }],
['non-canonical path', { canonicalPath: '/different' }]
])('rejects an unsafe %s runtime path', async (_name, override) => {
await expect(
buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(override),
source: { XDG_RUNTIME_DIR: '/run/user/1000' }
})
).rejects.toThrow('Unsafe directory path')
})
it.each([
{ DISPLAY: 'host:abc' },
{ WAYLAND_DISPLAY: '../wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' },
{ WAYLAND_DISPLAY: 'wayland-0' },
{ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus,bad' },
{ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus;tcp:' },
{ LANG: 'en_US.UTF-8\nINJECTED=1' },
{ PATH: '/usr/bin:relative/bin' },
{ PATH: '/usr/bin::/bin' }
])('rejects malformed desktop environment input', async (source) => {
await expect(
buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(),
source
})
).rejects.toThrow()
})
it('does not forward values that merely resemble safe opt-ins', async () => {
await expect(
buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(),
source: { NO_AT_BRIDGE: '0', LD_LIBRARY_PATH: '/tmp' }
})
).resolves.toEqual({})
})
})
@@ -0,0 +1,198 @@
import { isAbsolute, basename, posix } from 'node:path'
export type SecurePathMetadata = {
canonicalPath: string
uid: number
mode: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
}
export interface SessionEnvironmentFileSystem {
inspect(path: string): Promise<SecurePathMetadata>
}
export type SessionEnvironmentOptions = {
source?: NodeJS.ProcessEnv
uid: number
fileSystem: SessionEnvironmentFileSystem
}
const DISPLAY_PATTERN = /^(?:(?:[A-Za-z0-9._-]+)?):\d+(?:\.\d+)?$/
const WAYLAND_BASENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
const LOCALE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$/
const DBUS_VALUE_PATTERN = /^(?:[A-Za-z0-9._~:/@+-]|%[0-9A-Fa-f]{2})+$/
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const isSafePathText = (value: string): boolean =>
value.length >= 1 &&
value.length <= 8192 &&
!hasControlCharacters(value)
const assertSafePath = async (
path: string,
expectedKind: 'directory' | 'file',
options: SessionEnvironmentOptions
): Promise<string> => {
if (!isAbsolute(path) || !isSafePathText(path)) {
throw new Error(`Invalid ${expectedKind} path`)
}
const metadata = await options.fileSystem.inspect(path)
if (
metadata.isSymbolicLink ||
metadata.canonicalPath !== path ||
metadata.uid !== options.uid ||
(metadata.mode & 0o022) !== 0 ||
(expectedKind === 'directory'
? !metadata.isDirectory
: !metadata.isFile)
) {
throw new Error(`Unsafe ${expectedKind} path`)
}
return path
}
const validateBusAddress = (address: string): string => {
if (
address.length === 0 ||
address.length > 4096 ||
hasControlCharacters(address)
) {
throw new Error('Invalid session bus address')
}
const alternatives = address.split(';')
if (alternatives.some((entry) => entry.length === 0)) {
throw new Error('Invalid session bus address')
}
for (const entry of alternatives) {
const separator = entry.indexOf(':')
if (separator <= 0 || !/^[a-z][a-z0-9_-]*$/.test(entry.slice(0, separator))) {
throw new Error('Invalid session bus address')
}
const properties = entry.slice(separator + 1).split(',')
if (properties.some((property) => property.length === 0)) {
throw new Error('Invalid session bus address')
}
const seen = new Set<string>()
for (const property of properties) {
const equals = property.indexOf('=')
const key = property.slice(0, equals)
const value = property.slice(equals + 1)
if (
equals <= 0 ||
!/^[a-z][a-z0-9_-]*$/.test(key) ||
!DBUS_VALUE_PATTERN.test(value) ||
seen.has(key)
) {
throw new Error('Invalid session bus address')
}
seen.add(key)
}
}
return address
}
const copySafeValue = (
target: NodeJS.ProcessEnv,
source: NodeJS.ProcessEnv,
name: string,
pattern: RegExp
): void => {
const value = source[name]
if (value !== undefined) {
if (!pattern.test(value)) {
throw new Error(`Invalid ${name}`)
}
target[name] = value
}
}
const copySafePath = (
target: NodeJS.ProcessEnv,
source: NodeJS.ProcessEnv
): void => {
const value = source.PATH
if (value === undefined) {
return
}
const entries = value.split(':')
if (
!isSafePathText(value) ||
entries.length === 0 ||
entries.some((entry) => !posix.isAbsolute(entry))
) {
throw new Error('Invalid PATH')
}
target.PATH = value
}
/**
* Builds a new environment rather than filtering in place. Native desktop
* helpers must never inherit loader hooks, provider credentials, or proxies.
*/
export async function buildDesktopHelperEnvironment(
options: SessionEnvironmentOptions
): Promise<NodeJS.ProcessEnv> {
const source = options.source ?? process.env
const environment: NodeJS.ProcessEnv = {}
copySafePath(environment, source)
copySafeValue(environment, source, 'LANG', LOCALE_PATTERN)
copySafeValue(environment, source, 'LC_ALL', LOCALE_PATTERN)
copySafeValue(environment, source, 'LC_CTYPE', LOCALE_PATTERN)
const display = source.DISPLAY
if (display !== undefined) {
if (!DISPLAY_PATTERN.test(display)) {
throw new Error('Invalid DISPLAY')
}
environment.DISPLAY = display
}
const runtimeDirectory = source.XDG_RUNTIME_DIR
if (runtimeDirectory !== undefined) {
environment.XDG_RUNTIME_DIR = await assertSafePath(
runtimeDirectory,
'directory',
options
)
}
const waylandDisplay = source.WAYLAND_DISPLAY
if (waylandDisplay !== undefined) {
if (
basename(waylandDisplay) !== waylandDisplay ||
!WAYLAND_BASENAME_PATTERN.test(waylandDisplay) ||
runtimeDirectory === undefined
) {
throw new Error('Invalid WAYLAND_DISPLAY')
}
environment.WAYLAND_DISPLAY = waylandDisplay
}
const busAddress = source.DBUS_SESSION_BUS_ADDRESS
if (busAddress !== undefined) {
environment.DBUS_SESSION_BUS_ADDRESS = validateBusAddress(busAddress)
}
const authority = source.XAUTHORITY
if (authority !== undefined) {
environment.XAUTHORITY = await assertSafePath(
authority,
'file',
options
)
}
if (source.NO_AT_BRIDGE === '1') {
environment.NO_AT_BRIDGE = '1'
}
return environment
}