@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/broker/broker.d.ts +53 -0
  3. package/dist/broker/broker.d.ts.map +1 -0
  4. package/dist/broker/broker.js +346 -0
  5. package/dist/broker/broker.js.map +1 -0
  6. package/dist/bundle-lifecycle.d.ts +75 -0
  7. package/dist/bundle-lifecycle.d.ts.map +1 -0
  8. package/dist/bundle-lifecycle.js +247 -0
  9. package/dist/bundle-lifecycle.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +10 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/ipc/control-transport.d.ts +59 -0
  15. package/dist/ipc/control-transport.d.ts.map +1 -0
  16. package/dist/ipc/control-transport.js +389 -0
  17. package/dist/ipc/control-transport.js.map +1 -0
  18. package/dist/ipc/endpoint.d.ts +15 -0
  19. package/dist/ipc/endpoint.d.ts.map +1 -0
  20. package/dist/ipc/endpoint.js +16 -0
  21. package/dist/ipc/endpoint.js.map +1 -0
  22. package/dist/ipc/framing.d.ts +4 -0
  23. package/dist/ipc/framing.d.ts.map +1 -0
  24. package/dist/ipc/framing.js +32 -0
  25. package/dist/ipc/framing.js.map +1 -0
  26. package/dist/ipc/unix.d.ts +17 -0
  27. package/dist/ipc/unix.d.ts.map +1 -0
  28. package/dist/ipc/unix.js +106 -0
  29. package/dist/ipc/unix.js.map +1 -0
  30. package/dist/ipc/windows.d.ts +13 -0
  31. package/dist/ipc/windows.d.ts.map +1 -0
  32. package/dist/ipc/windows.js +10 -0
  33. package/dist/ipc/windows.js.map +1 -0
  34. package/dist/policy/policy.d.ts +23 -0
  35. package/dist/policy/policy.d.ts.map +1 -0
  36. package/dist/policy/policy.js +53 -0
  37. package/dist/policy/policy.js.map +1 -0
  38. package/dist/providers/supervisor.d.ts +17 -0
  39. package/dist/providers/supervisor.d.ts.map +1 -0
  40. package/dist/providers/supervisor.js +122 -0
  41. package/dist/providers/supervisor.js.map +1 -0
  42. package/package.json +37 -0
  43. package/src/broker/broker.ts +439 -0
  44. package/src/bundle-lifecycle.ts +385 -0
  45. package/src/index.ts +9 -0
  46. package/src/ipc/control-transport.ts +517 -0
  47. package/src/ipc/endpoint.ts +27 -0
  48. package/src/ipc/framing.ts +31 -0
  49. package/src/ipc/unix.ts +110 -0
  50. package/src/ipc/windows.ts +26 -0
  51. package/src/policy/policy.ts +73 -0
  52. 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
+ }