@crosshands/runtime 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/broker/broker.d.ts +53 -0
- package/dist/broker/broker.d.ts.map +1 -0
- package/dist/broker/broker.js +346 -0
- package/dist/broker/broker.js.map +1 -0
- package/dist/bundle-lifecycle.d.ts +75 -0
- package/dist/bundle-lifecycle.d.ts.map +1 -0
- package/dist/bundle-lifecycle.js +247 -0
- package/dist/bundle-lifecycle.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/control-transport.d.ts +59 -0
- package/dist/ipc/control-transport.d.ts.map +1 -0
- package/dist/ipc/control-transport.js +389 -0
- package/dist/ipc/control-transport.js.map +1 -0
- package/dist/ipc/endpoint.d.ts +15 -0
- package/dist/ipc/endpoint.d.ts.map +1 -0
- package/dist/ipc/endpoint.js +16 -0
- package/dist/ipc/endpoint.js.map +1 -0
- package/dist/ipc/framing.d.ts +4 -0
- package/dist/ipc/framing.d.ts.map +1 -0
- package/dist/ipc/framing.js +32 -0
- package/dist/ipc/framing.js.map +1 -0
- package/dist/ipc/unix.d.ts +17 -0
- package/dist/ipc/unix.d.ts.map +1 -0
- package/dist/ipc/unix.js +106 -0
- package/dist/ipc/unix.js.map +1 -0
- package/dist/ipc/windows.d.ts +13 -0
- package/dist/ipc/windows.d.ts.map +1 -0
- package/dist/ipc/windows.js +10 -0
- package/dist/ipc/windows.js.map +1 -0
- package/dist/policy/policy.d.ts +23 -0
- package/dist/policy/policy.d.ts.map +1 -0
- package/dist/policy/policy.js +53 -0
- package/dist/policy/policy.js.map +1 -0
- package/dist/providers/supervisor.d.ts +17 -0
- package/dist/providers/supervisor.d.ts.map +1 -0
- package/dist/providers/supervisor.js +122 -0
- package/dist/providers/supervisor.js.map +1 -0
- package/package.json +37 -0
- package/src/broker/broker.ts +439 -0
- package/src/bundle-lifecycle.ts +385 -0
- package/src/index.ts +9 -0
- package/src/ipc/control-transport.ts +517 -0
- package/src/ipc/endpoint.ts +27 -0
- package/src/ipc/framing.ts +31 -0
- package/src/ipc/unix.ts +110 -0
- package/src/ipc/windows.ts +26 -0
- package/src/policy/policy.ts +73 -0
- package/src/providers/supervisor.ts +140 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type WindowsPipePeer = {
|
|
2
|
+
pid: number
|
|
3
|
+
sid: string
|
|
4
|
+
logonSessionId: string
|
|
5
|
+
integrityLevel: string
|
|
6
|
+
remote: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WindowsPipeSecurityBackend {
|
|
10
|
+
createCurrentLogonOnlyDacl(): unknown
|
|
11
|
+
verifyPeer(): WindowsPipePeer
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function windowsPipeSecurity(
|
|
15
|
+
backend?: WindowsPipeSecurityBackend
|
|
16
|
+
): WindowsPipeSecurityBackend {
|
|
17
|
+
if (process.platform !== 'win32') {
|
|
18
|
+
throw new Error('Windows named-pipe security is only available on Windows')
|
|
19
|
+
}
|
|
20
|
+
if (backend === undefined) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Windows named-pipe security requires a native DACL and peer-verification backend'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
return backend
|
|
26
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ComputerOperationName } from '@crosshands/contract'
|
|
2
|
+
|
|
3
|
+
export type StableAppIdentity = {
|
|
4
|
+
appId: string
|
|
5
|
+
executableId: string
|
|
6
|
+
publisher?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type AppClassification = 'allowed' | 'sensitive'
|
|
10
|
+
|
|
11
|
+
const SENSITIVE_IDENTIFIERS = [
|
|
12
|
+
'1password',
|
|
13
|
+
'bitwarden',
|
|
14
|
+
'keychainaccess',
|
|
15
|
+
'keepass',
|
|
16
|
+
'lastpass',
|
|
17
|
+
'proton.pass',
|
|
18
|
+
'secrets'
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
export function classifyApp(identity: StableAppIdentity): AppClassification {
|
|
22
|
+
const stableIdentity =
|
|
23
|
+
`${identity.appId}\n${identity.executableId}\n${identity.publisher ?? ''}`.toLowerCase()
|
|
24
|
+
return SENSITIVE_IDENTIFIERS.some((identifier) => stableIdentity.includes(identifier))
|
|
25
|
+
? 'sensitive'
|
|
26
|
+
: 'allowed'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertAppAllowed(identity: StableAppIdentity): void {
|
|
30
|
+
if (classifyApp(identity) === 'sensitive') {
|
|
31
|
+
const error = new Error('Sensitive applications are blocked by default')
|
|
32
|
+
Object.assign(error, {
|
|
33
|
+
code: 'app_blocked',
|
|
34
|
+
retry: false,
|
|
35
|
+
remediation: 'choose_non_sensitive_target'
|
|
36
|
+
})
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PROTECTED_ROLES = new Set(['password', 'securetext', 'secret', 'credential'])
|
|
42
|
+
|
|
43
|
+
export function redactProtectedContent(value: unknown): unknown {
|
|
44
|
+
if (Array.isArray(value)) return value.map(redactProtectedContent)
|
|
45
|
+
if (value === null || typeof value !== 'object') return value
|
|
46
|
+
|
|
47
|
+
const record = value as Record<string, unknown>
|
|
48
|
+
if (record.protected === true) return '[REDACTED]'
|
|
49
|
+
const protectedRole =
|
|
50
|
+
typeof record.role === 'string' && PROTECTED_ROLES.has(record.role.toLowerCase())
|
|
51
|
+
const result: Record<string, unknown> = {}
|
|
52
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
53
|
+
const sensitiveKey = /^(value|text|password|secret|token|clipboard|data)$/i.test(key)
|
|
54
|
+
result[key] = protectedRole && sensitiveKey ? '[REDACTED]' : redactProtectedContent(entry)
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type DiagnosticEntry =
|
|
60
|
+
| { event: 'operation'; operation: ComputerOperationName }
|
|
61
|
+
| { event: 'error'; message: string }
|
|
62
|
+
|
|
63
|
+
export class SecretSafeDiagnostics {
|
|
64
|
+
readonly entries: DiagnosticEntry[] = []
|
|
65
|
+
|
|
66
|
+
operation(operation: ComputerOperationName, _input: unknown): void {
|
|
67
|
+
this.entries.push({ event: 'operation', operation })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
error(message: string, _cause?: unknown): void {
|
|
71
|
+
this.entries.push({ event: 'error', message })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTRACT_VERSIONS,
|
|
3
|
+
ProviderHandshakeSchema,
|
|
4
|
+
createComputerError,
|
|
5
|
+
type ComputerProvider,
|
|
6
|
+
type ProviderHandshake,
|
|
7
|
+
type ProviderRequest,
|
|
8
|
+
type ProviderResponse
|
|
9
|
+
} from '@crosshands/contract'
|
|
10
|
+
|
|
11
|
+
export type ProviderSupervisorOptions = {
|
|
12
|
+
providerFactory: () => ComputerProvider
|
|
13
|
+
graphicalSessionId: string
|
|
14
|
+
maxInFlight?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ProviderSupervisor {
|
|
18
|
+
readonly #providerFactory: () => ComputerProvider
|
|
19
|
+
readonly #graphicalSessionId: string
|
|
20
|
+
readonly #maxInFlight: number
|
|
21
|
+
readonly #inFlight = new Set<string>()
|
|
22
|
+
#provider: ComputerProvider | undefined
|
|
23
|
+
#handshake: ProviderHandshake | undefined
|
|
24
|
+
#retirement: Promise<void> = Promise.resolve()
|
|
25
|
+
|
|
26
|
+
constructor(options: ProviderSupervisorOptions) {
|
|
27
|
+
this.#providerFactory = options.providerFactory
|
|
28
|
+
this.#graphicalSessionId = options.graphicalSessionId
|
|
29
|
+
this.#maxInFlight = options.maxInFlight ?? 16
|
|
30
|
+
if (this.#maxInFlight <= 0) throw new RangeError('maxInFlight must be positive')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get generation(): string | undefined {
|
|
34
|
+
return this.#handshake?.generation
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start(): Promise<ProviderHandshake> {
|
|
38
|
+
await this.#retirement
|
|
39
|
+
if (this.#handshake !== undefined) return this.#handshake
|
|
40
|
+
const provider = this.#providerFactory()
|
|
41
|
+
let handshake: ProviderHandshake
|
|
42
|
+
try {
|
|
43
|
+
handshake = ProviderHandshakeSchema.parse(await provider.start())
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
await provider.close().catch(() => undefined)
|
|
46
|
+
throw createComputerError('provider_unavailable', 'Provider handshake was malformed', {
|
|
47
|
+
cause: cause instanceof Error ? cause.name : 'unknown'
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
handshake.providerProtocol !== CONTRACT_VERSIONS.providerProtocol ||
|
|
52
|
+
handshake.publicContract !== CONTRACT_VERSIONS.publicContract
|
|
53
|
+
) {
|
|
54
|
+
await provider.close().catch(() => undefined)
|
|
55
|
+
throw createComputerError('version_incompatible', 'Provider protocol is incompatible', {
|
|
56
|
+
expectedProviderProtocol: CONTRACT_VERSIONS.providerProtocol,
|
|
57
|
+
receivedProviderProtocol: handshake.providerProtocol,
|
|
58
|
+
expectedPublicContract: CONTRACT_VERSIONS.publicContract,
|
|
59
|
+
receivedPublicContract: handshake.publicContract
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
if (handshake.graphicalSessionId !== this.#graphicalSessionId) {
|
|
63
|
+
await provider.close().catch(() => undefined)
|
|
64
|
+
throw createComputerError(
|
|
65
|
+
'session_unavailable',
|
|
66
|
+
'Provider is attached to a different graphical session'
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
if (handshake.generation !== provider.generation) {
|
|
70
|
+
await provider.close().catch(() => undefined)
|
|
71
|
+
throw createComputerError('provider_unavailable', 'Provider generation handshake mismatch')
|
|
72
|
+
}
|
|
73
|
+
this.#provider = provider
|
|
74
|
+
this.#handshake = handshake
|
|
75
|
+
return handshake
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async dispatch(request: ProviderRequest): Promise<ProviderResponse> {
|
|
79
|
+
const provider = this.#provider ?? (await this.start(), this.#provider)
|
|
80
|
+
if (provider === undefined)
|
|
81
|
+
throw createComputerError('provider_unavailable', 'Provider failed to start')
|
|
82
|
+
if (this.#inFlight.size >= this.#maxInFlight) {
|
|
83
|
+
throw createComputerError('provider_unavailable', 'Provider backpressure limit reached')
|
|
84
|
+
}
|
|
85
|
+
if (request.deadlineAt <= Date.now()) {
|
|
86
|
+
throw createComputerError('timeout', 'Provider request deadline elapsed before dispatch')
|
|
87
|
+
}
|
|
88
|
+
this.#inFlight.add(request.requestId)
|
|
89
|
+
let timer: NodeJS.Timeout | undefined
|
|
90
|
+
let timedOut = false
|
|
91
|
+
try {
|
|
92
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
93
|
+
timer = setTimeout(() => {
|
|
94
|
+
timedOut = true
|
|
95
|
+
this.#retirement = this.#retire(provider, request.requestId)
|
|
96
|
+
reject(createComputerError('timeout', 'Provider request deadline elapsed'))
|
|
97
|
+
}, request.deadlineAt - Date.now())
|
|
98
|
+
timer.unref()
|
|
99
|
+
})
|
|
100
|
+
return await Promise.race([provider.dispatch(request), timeout])
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
if (timedOut) await this.#retirement
|
|
103
|
+
if (typeof cause === 'object' && cause !== null && 'code' in cause) throw cause
|
|
104
|
+
throw createComputerError('provider_crashed', 'Provider crashed or disconnected', {
|
|
105
|
+
cause: cause instanceof Error ? cause.message : 'unknown'
|
|
106
|
+
})
|
|
107
|
+
} finally {
|
|
108
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
109
|
+
this.#inFlight.delete(request.requestId)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async cancel(requestId: string): Promise<void> {
|
|
114
|
+
if (!this.#inFlight.has(requestId)) return
|
|
115
|
+
await this.#provider?.cancel(requestId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async restart(): Promise<ProviderHandshake> {
|
|
119
|
+
await this.close()
|
|
120
|
+
return this.start()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async close(): Promise<void> {
|
|
124
|
+
await this.#retirement
|
|
125
|
+
const provider = this.#provider
|
|
126
|
+
this.#provider = undefined
|
|
127
|
+
this.#handshake = undefined
|
|
128
|
+
this.#inFlight.clear()
|
|
129
|
+
await provider?.close()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #retire(provider: ComputerProvider, requestId: string): Promise<void> {
|
|
133
|
+
if (this.#provider === provider) {
|
|
134
|
+
this.#provider = undefined
|
|
135
|
+
this.#handshake = undefined
|
|
136
|
+
}
|
|
137
|
+
await provider.cancel(requestId).catch(() => undefined)
|
|
138
|
+
await provider.close().catch(() => undefined)
|
|
139
|
+
}
|
|
140
|
+
}
|