@aiwg/cockpit 2026.9.9 → 2026.9.11

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/README.md CHANGED
@@ -619,6 +619,7 @@ The detailed review script and screenshot commands live in
619
619
 
620
620
  ## See also
621
621
 
622
+ - [Desktop identity integration](bridge/DESKTOP-IDENTITY.md) — provider contract and remaining desktop work
622
623
  - `apps/cockpit/RELEASE.md` — cockpit release pattern (channels, publish leg, config-defaults gate)
623
624
  - `.aiwg/architecture/adr-cockpit-session-control-not-cli-runner.md` — the core model
624
625
  - `.aiwg/architecture/cockpit-sad.md` + `cockpit-instance-control-interface.md`
@@ -0,0 +1,190 @@
1
+ # Desktop identity integration
2
+
3
+ The Bridge provides an optional identity boundary for desktop access (#2545).
4
+ It requires a trusted provider adapter; the default Cockpit launch does not
5
+ configure one. This component alone does not enable RDP or complete #2545.
6
+
7
+ ## Provider contract
8
+
9
+ Create `createDesktopIdentity({ verify })` from `src/desktop-identity.mjs` and
10
+ pass the result as `desktopIdentity` to `createBridge`. The module documents
11
+ the complete verifier input and result schema. Every bind, status, and
12
+ authorization request requires a fresh authoritative check of the user session,
13
+ workspace membership, action, and instance policy. Token claims cached at login
14
+ do not satisfy that contract.
15
+
16
+ The adapter must authenticate login evidence, validate issuer, signature and
17
+ audience, and return a gateway-verifiable scoped delegation. Provider failures
18
+ deny access. There is no operator-token fallback. The issuer, claims mapping,
19
+ membership authority, and login flow still need production integration.
20
+
21
+ After the trusted login callback authenticates the user, call
22
+ `server.bindDesktopIdentity(req, { workspaceId, evidence })` using the original
23
+ browser request. The Bridge derives the browser binding and audience from its
24
+ HttpOnly session cookie. There is no public endpoint accepting identity claims.
25
+ Browser expiration or logout racing verification prevents binding or disclosure.
26
+
27
+ ## Keycloak provider adapter (section9 realm)
28
+
29
+ `src/desktop-identity-keycloak.mjs` implements the verifier contract against
30
+ the internal Keycloak realm (`https://auth.s9.internal/realms/section9`,
31
+ matching `itops:config/matric-user-secrets.yaml` and
32
+ `itops:configs/keycloak/realms/section9.json`). It is the production issuer
33
+ decision for #2545; the Bridge itself still receives the adapter as the
34
+ trusted `desktopIdentity` seam.
35
+
36
+ ```js
37
+ import { createDesktopIdentity } from './src/desktop-identity.mjs';
38
+ import { createKeycloakDesktopVerifier } from './src/desktop-identity-keycloak.mjs';
39
+
40
+ const keycloak = createKeycloakDesktopVerifier({
41
+ issuer: 'https://auth.s9.internal/realms/section9', // default
42
+ audience: 'cockpit-bridge', // exact `aud` on the login token
43
+ clientId: 'cockpit-bridge', // confidential client; introspection + exchange
44
+ clientSecretFile: process.env.AIWG_COCKPIT_KEYCLOAK_SECRET_FILE, // mode 0600, OpenBao handoff
45
+ delegationAudience: 'agentic-sandbox-desktop', // gateway client the delegation is minted for
46
+ });
47
+ const desktopIdentity = createDesktopIdentity({ verify: keycloak.verify });
48
+ ```
49
+
50
+ Per call the adapter: verifies the login evidence signature against the realm
51
+ JWKS (cached five minutes) and pins `iss` and the exact `aud` (bind only, the
52
+ token is retained backend-only afterwards); introspects the retained access
53
+ token with the confidential client so every bind, status and authorization is
54
+ a fresh Keycloak session check (`active`, `sid`, `aud`, `exp`); refreshes a
55
+ lapsed access token once through the refresh grant when the login supplied
56
+ one; maps claims to the binding (`workspace_id` or `tenant_id`; groups
57
+ `admins`/`operators` → all desktop actions, `viewers` → `view`, `observe`;
58
+ `desktop:<action>` realm or client roles add actions; `desktop_instances`
59
+ claim or a `resolveInstances({ subject, workspaceId, claims })` hook supplies
60
+ the instance scope); and mints the delegation by RFC 8693 token exchange for
61
+ `delegationAudience`, cached until it expires. The client secret is read from
62
+ the mode-0600 file at point of use and never logged or returned; unreadable
63
+ secrets and Keycloak errors surface as `identity_unavailable`, mismatches as
64
+ `denied`. `expiresAt` is bounded by `auth_time + sessionMaxMs` (ten hours,
65
+ the realm SSO maximum) so authorization never extends a login.
66
+
67
+ Still required outside this repository: a `cockpit-bridge` confidential
68
+ client and the gateway client in the section9 realm (itops), token-exchange
69
+ permission between them, an OpenBao path for the client secret, and the
70
+ gateway-side verification of the exchanged token (roctinam/agentic-sandbox#853).
71
+ The embedder must call `keycloak.forget(browserSessionId)` on Bridge logout and
72
+ route Keycloak back-channel logout tokens through
73
+ `keycloak.logoutSelector(token)` into `desktopIdentity.revoke(selector)`.
74
+ `test/integration/cockpit-desktop-identity-keycloak.test.js` exercises the
75
+ adapter against an in-memory Keycloak; it does not qualify the live realm.
76
+
77
+ ## Browser boundary
78
+
79
+ `GET /api/desktop-identity` requires the browser session and an exact local
80
+ origin. A same-origin GET without Origin is accepted only with
81
+ `Sec-Fetch-Site: same-origin`. Validation uses the actual listener port and
82
+ does not trust forwarded headers. Hosted reverse-proxy access needs a separate
83
+ explicit origin configuration before it can be supported.
84
+
85
+ Without a provider the endpoint returns `state: unsupported`. With a provider
86
+ it returns freshly checked, redacted identity status. Delegations are never
87
+ returned by this endpoint. Native bearer credentials alone cannot access it.
88
+
89
+ `DELETE /bootstrap/session` requires the browser cookie, exact Origin, and CSRF
90
+ token. It removes browser authority, invalidates its desktop binding, and expires
91
+ the cookie. Browser session expiration and Bridge shutdown also invalidate
92
+ bindings.
93
+
94
+ ## Transport integration still required
95
+
96
+ The desktop transport must use `withDelegation` for backend calls, register
97
+ `onInvalidate` to close active streams, and renew authorization within its
98
+ revocation budget. The identity module does not poll the provider itself.
99
+ Provider revocation events must call `revoke` with an issuer-scoped selector.
100
+ The existing terminal transport has not been converted into a desktop transport.
101
+
102
+ Gateway integration, worker attachment, the desktop panel, production identity
103
+ provider integration, and real browser/Tauri/VS Code qualification remain under
104
+ issues #2545, #2546, and #2547. Unit and local HTTP tests do not prove those workflows.
105
+
106
+ ## Desktop control API
107
+
108
+ The optional `desktopBackend` Bridge setting accepts the dedicated adapter from
109
+ `src/desktop-backend.mjs`. Configure `createDesktopBackend` with an HTTPS origin
110
+ and `tls: { ca, cert, key }` supplied through backend custody. The client
111
+ certificate authenticates the Bridge workload; each request also carries the
112
+ fresh user delegation. The gateway must verify both identities. This adapter
113
+ does not read the existing operator bearer file. TLS peer and hostname checks
114
+ cannot be disabled through its options, and redirects are never followed.
115
+
116
+ The local browser control routes use the prefix
117
+ `/api/desktops/instances/{instance}`:
118
+
119
+ | Method | Route | Required identity action |
120
+ | --- | --- | --- |
121
+ | GET | `/capability` | `view` |
122
+ | POST | `/sessions` | `create` |
123
+ | GET | `/sessions/{desktop}` | `view` |
124
+ | POST | `/sessions/{desktop}/close` | See below |
125
+
126
+ The close action is `revoke_access` or `sign_out`, from the validated body.
127
+ Create requires a 16–128
128
+ character `Idempotency-Key` containing letters, digits, `_` or `-`. Request
129
+ bodies follow the proposed `rdp-cockpit.v1` contract; unknown request fields
130
+ are rejected. Responses are whitelisted and checked against the requested
131
+ instance and authenticated workspace. Arbitrary upstream error text is not
132
+ returned to the browser.
133
+
134
+ These routes require the browser cookie, exact local origin and mutation CSRF.
135
+ Authorization is rechecked for each request. Browser logout and identity
136
+ invalidation cancel pending backend calls. Limits are 4 KiB browser bodies,
137
+ eight pending requests per browser, 64 across the Bridge, and ten seconds for
138
+ the browser control operation. The backend defaults to 32 concurrent requests,
139
+ 64 KiB responses and a five-second deadline. Backend limits are configurable
140
+ within fixed bounds. This is control-plane traffic, not display frames.
141
+
142
+ An unconfigured backend reports unsupported. A capability lookup returning
143
+ 404 or 501 from an older executor also reports unsupported; terminal routes
144
+ retain their existing behavior. Attach grants are available only through the
145
+ backend adapter, with no browser route that serializes them. The authenticated
146
+ WebSocket worker transport and desktop rendering remain unimplemented.
147
+
148
+ The local TLS tests use generated test certificates and a real HTTPS server.
149
+ They verify client authentication, server trust, hostname validation, response
150
+ limits, redirects, and the browser-to-Bridge-to-HTTPS request path. Their
151
+ identity provider and desktop responses are test fixtures; they do not qualify
152
+ production identity, the Sandbox broker, or RDP.
153
+
154
+ ## Agent login assistance
155
+
156
+ The intended workflow is an agent-driven, persistent XFCE session. An authorized
157
+ user opens that same desktop when the agent needs help signing into the user's
158
+ internal application, completes the login in the guest browser, and returns
159
+ control so the agent continues in the authenticated session.
160
+
161
+ The browser client in `../web/src/desktop-api.ts` provides typed capability,
162
+ existing-session lookup, creation and explicit close operations. It uses the
163
+ normal Cockpit cookie/CSRF bootstrap, caller-owned creation idempotency keys,
164
+ cancellation signals and the same response validator as the Bridge. Unknown
165
+ response fields and arbitrary backend diagnostics do not reach its consumers.
166
+ Looking up an existing desktop performs no creation request.
167
+
168
+ The assistance action must target the authoritative desktop ID associated with
169
+ the agent task. It must not provision a second guest session. Neither
170
+ `revoke_access` nor `sign_out` means "return control to the agent". That handoff
171
+ needs a separate acknowledged controller transition that preserves the browser
172
+ profile and login. A mission pause alone does not fence agent desktop input.
173
+
174
+ The assistance request/task binding, fenced agent-to-human transfer,
175
+ return-control operation, authenticated display stream and panel remain to be
176
+ implemented. These client calls do not yet provide a user-connectable desktop.
177
+
178
+ ## Verification
179
+
180
+ Run the focused suites from the repository root after `npm run build:cli`:
181
+
182
+ ```sh
183
+ npx vitest run --config config/vitest.config.js \
184
+ test/integration/cockpit-desktop-identity.test.js \
185
+ test/integration/cockpit-desktop-session.test.js \
186
+ test/integration/cockpit-bridge.test.js
187
+ ```
188
+
189
+ For an isolated worktree, set `AIWG_CONFIG` to a dedicated temporary directory
190
+ so installation identity checks do not use another checkout's configuration.
@@ -0,0 +1,102 @@
1
+ import https from 'node:https';
2
+ import { createSecureContext } from 'node:tls';
3
+ import { isDesktopUuid, sanitizeDesktopProblem, sanitizeDesktopResponse, validateDesktopRequest } from './desktop-contract.mjs';
4
+
5
+ const fail = (code, status = 502) => Object.assign(new Error(code), { code, status });
6
+ const operations = {
7
+ capability: { method: 'GET', path: (id) => `/api/v2/instances/${id}/desktop-capability`, response: 'Capability', status: 200 },
8
+ create: { method: 'POST', path: (id) => `/api/v2/instances/${id}/desktop-sessions`, request: 'CreateDesktop', response: 'Desktop', status: 202 },
9
+ get: { method: 'GET', path: (id) => `/api/v2/desktop-sessions/${id}`, response: 'Desktop', status: 200 },
10
+ attach: { method: 'POST', path: (id) => `/api/v2/desktop-sessions/${id}/attachments`, request: 'AttachmentRequest', response: 'BackendAttachmentGrant', status: 201 },
11
+ detach: { method: 'DELETE', path: (id) => `/api/v2/desktop-attachments/${id}`, status: 204 },
12
+ close: { method: 'POST', path: (id) => `/api/v2/desktop-sessions/${id}/close`, request: 'CloseRequest', response: 'Desktop', status: 202 },
13
+ };
14
+
15
+ /** Dedicated desktop control connection. The client certificate authenticates
16
+ * the Bridge workload; Authorization carries the freshly verified user delegation.
17
+ * The gateway must verify BOTH. No operator credential file or generic proxy is
18
+ * consulted. The attachment grant returned by `attach` is backend-only.
19
+ */
20
+ export function createDesktopBackend({ url, tls, timeoutMs = 5000, maxConcurrent = 32, maxResponseBytes = 65536 } = {}) {
21
+ let endpoint;
22
+ try { endpoint = new URL(url); } catch { throw new TypeError('Desktop HTTPS endpoint required'); }
23
+ if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/') {
24
+ throw new TypeError('Desktop endpoint must be an HTTPS origin');
25
+ }
26
+ if (!tls?.ca || !tls?.cert || !tls?.key) throw new TypeError('Desktop workload certificate, key and server CA required');
27
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 ||
28
+ !Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > 128 ||
29
+ !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 1048576) throw new TypeError('Invalid desktop transport limits');
30
+ // Whitelist TLS options: callers cannot disable peer or hostname verification.
31
+ let secureContext;
32
+ try { secureContext = createSecureContext({ ca: tls.ca, cert: tls.cert, key: tls.key, minVersion: 'TLSv1.2' }); }
33
+ catch { throw new TypeError('Invalid desktop TLS configuration'); }
34
+ const agent = new https.Agent({ keepAlive: true, maxSockets: maxConcurrent, secureContext, rejectUnauthorized: true });
35
+ const active = new Set();
36
+ let closed = false;
37
+
38
+ async function request({ operation, id, delegation, body, idempotencyKey, signal } = {}) {
39
+ const spec = Object.hasOwn(operations, operation) ? operations[operation] : undefined;
40
+ if (!spec || !isDesktopUuid(id) || typeof delegation !== 'string' || !/^[\x21-\x7e]{1,16384}$/.test(delegation)) throw fail('desktop_invalid_request', 400);
41
+ if (closed || signal?.aborted) throw fail('desktop_backend_unavailable', 503);
42
+ if (active.size >= maxConcurrent) throw fail('quota_exceeded', 429);
43
+ let payload;
44
+ if (spec.request) payload = JSON.stringify(validateDesktopRequest(spec.request, body));
45
+ else if (body !== undefined) throw fail('desktop_invalid_request', 400);
46
+ if (operation === 'create' && (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(idempotencyKey))) throw fail('desktop_invalid_request', 400);
47
+ const headers = { authorization: `Bearer ${delegation}`, accept: 'application/json, application/problem+json' };
48
+ if (payload) Object.assign(headers, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
49
+ if (operation === 'create') headers['idempotency-key'] = idempotencyKey;
50
+ const result = await new Promise((resolve, reject) => {
51
+ let settled = false;
52
+ let timer;
53
+ const abort = () => finish(fail('desktop_backend_unavailable', 503));
54
+ const finish = (error, value) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ clearTimeout(timer);
58
+ signal?.removeEventListener('abort', abort);
59
+ active.delete(req);
60
+ if (error) { req.destroy(); reject(error); }
61
+ else resolve(value);
62
+ };
63
+ const req = https.request(new URL(spec.path(id), endpoint), { method: spec.method, agent, headers }, (res) => {
64
+ const chunks = [];
65
+ let size = 0;
66
+ res.on('data', (chunk) => {
67
+ size += chunk.length;
68
+ if (size > maxResponseBytes) finish(fail('desktop_invalid_response'));
69
+ else chunks.push(chunk);
70
+ });
71
+ res.on('aborted', () => finish(fail('desktop_backend_unavailable', 503)));
72
+ res.on('error', () => finish(fail('desktop_backend_unavailable', 503)));
73
+ res.on('end', () => finish(null, { status: res.statusCode, contentType: res.headers['content-type'], body: Buffer.concat(chunks).toString('utf8') }));
74
+ });
75
+ active.add(req);
76
+ req.on('error', (err) => finish(fail(/CERT|TLS|SELF_SIGNED|ISSUER|VERIFY/.test(err.code ?? '') ? 'upstream_certificate_invalid' : 'desktop_backend_unavailable', 503)));
77
+ timer = setTimeout(() => finish(fail('desktop_backend_timeout', 504)), timeoutMs);
78
+ signal?.addEventListener('abort', abort, { once: true });
79
+ if (signal?.aborted) abort();
80
+ else req.end(payload);
81
+ });
82
+ if (operation === 'capability' && [404, 501].includes(result.status)) return Object.freeze({ state: 'unsupported', reason: 'desktop_not_supported' });
83
+ if (result.status === 204 && !spec.response && !result.body) return undefined;
84
+ if (!/^application\/(?:json|problem\+json)(?:\s*;|$)/i.test(result.contentType ?? '')) throw fail('desktop_invalid_response');
85
+ let value;
86
+ try { value = JSON.parse(result.body); } catch { throw fail('desktop_invalid_response'); }
87
+ if (result.status >= 400) {
88
+ const problem = sanitizeDesktopProblem(value);
89
+ throw fail(problem.code, problem.status);
90
+ }
91
+ if (result.status !== spec.status) throw fail('desktop_invalid_response');
92
+ const clean = sanitizeDesktopResponse(spec.response, value);
93
+ const returnedId = ['capability', 'create'].includes(operation) ? clean.instance_id : operation === 'attach' ? clean.desktop_id : clean.id;
94
+ if (returnedId?.toLowerCase() !== id.toLowerCase()) throw fail('desktop_invalid_response');
95
+ return clean;
96
+ }
97
+ return Object.freeze({ request, close() {
98
+ closed = true;
99
+ for (const req of active) req.destroy(fail('desktop_backend_unavailable', 503));
100
+ agent.destroy();
101
+ } });
102
+ }
@@ -0,0 +1,46 @@
1
+ export type DesktopMode = 'control' | 'observe';
2
+ export interface DesktopViewport { width: number; height: number; dpi: number }
3
+ export interface CreateDesktopRequest { requested_mode: DesktopMode; viewport: DesktopViewport }
4
+ export interface DesktopAttachmentRequest { mode: DesktopMode; viewport: DesktopViewport }
5
+ export interface DesktopCloseRequest { action: 'revoke_access' | 'sign_out' }
6
+ export interface DesktopPolicy {
7
+ observe: boolean;
8
+ control: boolean;
9
+ sharing: boolean;
10
+ clipboard_copy: boolean;
11
+ clipboard_paste: boolean;
12
+ file_transfer: boolean;
13
+ audio: boolean;
14
+ recording: boolean;
15
+ isolation_tier: 'cooperative' | 'separate_desktop_vm';
16
+ generation: number;
17
+ }
18
+ interface DesktopResource {
19
+ schema_version: 'rdp-cockpit.v1';
20
+ instance_id: string;
21
+ incarnation: string;
22
+ policy: DesktopPolicy;
23
+ }
24
+ export interface DesktopCapability extends DesktopResource {
25
+ supported: boolean;
26
+ readiness: 'ready' | 'not_ready' | 'unknown';
27
+ reason_codes: string[];
28
+ }
29
+ export interface DesktopSession extends DesktopResource {
30
+ id: string;
31
+ workspace_id: string;
32
+ state: 'preparing' | 'ready' | 'attached' | 'detached' | 'closing' | 'closed' | 'failed';
33
+ cleanup: 'none' | 'pending' | 'complete' | 'failed';
34
+ absolute_expires_at: string;
35
+ retained_until: string | null;
36
+ }
37
+ export function isDesktopUuid(value: unknown): value is string;
38
+ export function validateDesktopRequest(kind: 'CreateDesktop', value: unknown): CreateDesktopRequest;
39
+ export function validateDesktopRequest(kind: 'AttachmentRequest', value: unknown): DesktopAttachmentRequest;
40
+ export function validateDesktopRequest(kind: 'CloseRequest', value: unknown): DesktopCloseRequest;
41
+ export function sanitizeDesktopResponse(kind: 'Capability', value: unknown): DesktopCapability;
42
+ export function sanitizeDesktopResponse(kind: 'Desktop', value: unknown): DesktopSession;
43
+ export function sanitizeDesktopResponse(kind: 'BackendAttachmentGrant', value: unknown): {
44
+ attachment_id: string; desktop_id: string; grant: string; expires_at: string; policy_generation: number;
45
+ };
46
+ export function sanitizeDesktopProblem(value: unknown): { code: string; status: number; message: string };
@@ -0,0 +1,95 @@
1
+ // Boundary validation for the proposed rdp-cockpit.v1 contract. This module does
2
+ // not imply that the executor implements that API or that runtime UAT has passed.
3
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
4
+ const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
5
+ const fail = (request = false) => { throw Object.assign(new Error(request ? 'Invalid desktop request' : 'Invalid desktop response'), { code: request ? 'desktop_invalid_request' : 'desktop_invalid_response' }); };
6
+ const integer = (v, min, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(v) && v >= min && v <= max;
7
+ const identifier = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(v);
8
+ export const isDesktopUuid = (v) => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
9
+ const fields = (value, keys, request = false) => {
10
+ if (!record(value) || keys.some((key) => !own(value, key)) || (request && Reflect.ownKeys(value).some((key) => !keys.includes(key)))) fail(request);
11
+ };
12
+ const member = (v, values, request = false) => { if (!values.includes(v)) fail(request); return v; };
13
+ const mode = (v, request) => member(v, ['control', 'observe'], request);
14
+ function viewport(value) {
15
+ fields(value, ['width', 'height', 'dpi'], true);
16
+ if (!integer(value.width, 640, 3840) || !integer(value.height, 480, 2160) || !integer(value.dpi, 72, 240)) fail(true);
17
+ return { width: value.width, height: value.height, dpi: value.dpi };
18
+ }
19
+ export function validateDesktopRequest(kind, value) {
20
+ if (kind === 'CloseRequest') {
21
+ fields(value, ['action'], true);
22
+ return { action: member(value.action, ['revoke_access', 'sign_out'], true) };
23
+ }
24
+ if (!['CreateDesktop', 'AttachmentRequest'].includes(kind)) fail(true);
25
+ const key = kind === 'CreateDesktop' ? 'requested_mode' : 'mode';
26
+ fields(value, ['viewport', key], true);
27
+ return { [key]: mode(value[key], true), viewport: viewport(value.viewport) };
28
+ }
29
+ const booleans = ['observe', 'control', 'sharing', 'clipboard_copy', 'clipboard_paste', 'file_transfer', 'audio', 'recording'];
30
+ function policy(value) {
31
+ fields(value, [...booleans, 'isolation_tier', 'generation']);
32
+ if (booleans.some((key) => typeof value[key] !== 'boolean') || !integer(value.generation, 1)) fail();
33
+ return { ...Object.fromEntries(booleans.map((key) => [key, value[key]])), isolation_tier: member(value.isolation_tier, ['cooperative', 'separate_desktop_vm']), generation: value.generation };
34
+ }
35
+ function timestamp(value) {
36
+ if (typeof value !== 'string' || value.length > 40) fail();
37
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|([+-])(\d{2}):(\d{2}))$/);
38
+ if (!match) fail();
39
+ const [, y, m, d, h, minute, second, , oh, om] = match;
40
+ const year = Number(y), month = Number(m), day = Number(d);
41
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
42
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
43
+ if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || Number(h) > 23 || Number(minute) > 59 || Number(second) > 59 || Number(oh ?? 0) > 23 || Number(om ?? 0) > 59 || !Number.isFinite(Date.parse(value))) fail();
44
+ return value;
45
+ }
46
+ const PROBLEMS = Object.freeze({
47
+ desktop_not_supported: [501, 'Desktop access is not supported.'],
48
+ desktop_not_ready: [409, 'The desktop is not ready.'],
49
+ access_denied: [403, 'Desktop access denied.'],
50
+ control_conflict: [409, 'Another controller owns the desktop.'],
51
+ grant_expired: [410, 'Desktop admission expired. Reconnect to request new admission.'],
52
+ stale_instance: [409, 'The instance changed. Refresh before reconnecting.'],
53
+ route_unavailable: [503, 'The desktop route is unavailable.'],
54
+ credentials_unavailable: [503, 'Desktop credentials are unavailable.'],
55
+ upstream_certificate_invalid: [502, 'The desktop certificate could not be verified.'],
56
+ quota_exceeded: [429, 'The desktop capacity limit was reached.'],
57
+ desktop_session_ended: [410, 'The guest desktop session ended.'],
58
+ cleanup_pending: [409, 'Desktop cleanup is pending.'],
59
+ idempotency_conflict: [409, 'The request identifier was already used for a different request.'],
60
+ });
61
+ export function sanitizeDesktopProblem(value) {
62
+ if (!record(value) || typeof value.code !== 'string' || !own(PROBLEMS, value.code)) fail();
63
+ const [status, message] = PROBLEMS[value.code];
64
+ return { code: value.code, status, message };
65
+ }
66
+ function reasons(value) {
67
+ if (!Array.isArray(value) || value.length > 32 || value.some((code) => typeof code !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(code))) fail();
68
+ // The draft leaves reason strings open. Never reflect unknown diagnostics;
69
+ // callers get a stable unknown reason until that code is explicitly reviewed.
70
+ return [...new Set(value.map((code) => own(PROBLEMS, code) || ['unknown', 'not_ready', 'unsupported'].includes(code) ? code : 'unknown'))];
71
+ }
72
+ /** Returns whitelisted protocol fields. BackendAttachmentGrant intentionally
73
+ * retains its opaque grant for BACKEND CONSUMPTION ONLY. Never serialize that
74
+ * result to a browser, URL, log, or persistent store. */
75
+ export function sanitizeDesktopResponse(kind, value) {
76
+ if (kind === 'BackendAttachmentGrant') {
77
+ fields(value, ['attachment_id', 'desktop_id', 'grant', 'expires_at', 'policy_generation']);
78
+ if (!isDesktopUuid(value.attachment_id) || !isDesktopUuid(value.desktop_id) || !integer(value.policy_generation, 1) || typeof value.grant !== 'string' || !/^[A-Za-z0-9._~+/-]{32,4096}={0,2}$/.test(value.grant) || value.grant.length > 4096) fail();
79
+ return { attachment_id: value.attachment_id, desktop_id: value.desktop_id, grant: value.grant, expires_at: timestamp(value.expires_at), policy_generation: value.policy_generation };
80
+ }
81
+ if (!['Capability', 'Desktop'].includes(kind)) fail();
82
+ const shared = ['schema_version', 'instance_id', 'incarnation', 'policy'];
83
+ fields(value, [...shared, ...(kind === 'Capability' ? ['supported', 'readiness', 'reason_codes'] : ['id', 'workspace_id', 'state', 'cleanup', 'absolute_expires_at', 'retained_until'])]);
84
+ if (value.schema_version !== 'rdp-cockpit.v1' || !isDesktopUuid(value.instance_id) || !identifier(value.incarnation)) fail();
85
+ const common = { schema_version: value.schema_version, instance_id: value.instance_id, incarnation: value.incarnation, policy: policy(value.policy) };
86
+ if (kind === 'Capability') {
87
+ if (typeof value.supported !== 'boolean') fail();
88
+ return { ...common, supported: value.supported, readiness: member(value.readiness, ['ready', 'not_ready', 'unknown']), reason_codes: reasons(value.reason_codes) };
89
+ }
90
+ if (!isDesktopUuid(value.id) || !identifier(value.workspace_id)) fail();
91
+ return { ...common, id: value.id, workspace_id: value.workspace_id,
92
+ state: member(value.state, ['preparing', 'ready', 'attached', 'detached', 'closing', 'closed', 'failed']),
93
+ cleanup: member(value.cleanup, ['none', 'pending', 'complete', 'failed']),
94
+ absolute_expires_at: timestamp(value.absolute_expires_at), retained_until: value.retained_until === null ? null : timestamp(value.retained_until) };
95
+ }
@@ -0,0 +1,105 @@
1
+ import { isDesktopUuid, sanitizeDesktopResponse, validateDesktopRequest } from './desktop-contract.mjs';
2
+
3
+ const failure = (code, status = 400) => Object.assign(new Error(code), { code, status });
4
+ const errors = new Set(['desktop_invalid_request', 'desktop_invalid_response', 'desktop_backend_unavailable', 'desktop_backend_timeout',
5
+ 'desktop_not_supported', 'desktop_not_ready', 'access_denied', 'control_conflict', 'grant_expired', 'stale_instance',
6
+ 'route_unavailable', 'credentials_unavailable', 'upstream_certificate_invalid', 'quota_exceeded', 'desktop_session_ended', 'cleanup_pending', 'idempotency_conflict']);
7
+
8
+ async function readBody(req, signal) {
9
+ if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] ?? '')) throw failure('desktop_invalid_request');
10
+ return new Promise((resolve, reject) => {
11
+ let size = 0;
12
+ const chunks = [];
13
+ const clean = () => { req.removeListener('data', data); req.removeListener('end', end); req.removeListener('error', abort); signal.removeEventListener('abort', abort); };
14
+ const abort = () => { clean(); reject(failure('desktop_invalid_request')); };
15
+ const data = (chunk) => {
16
+ size += chunk.length;
17
+ if (size > 4096) abort();
18
+ else chunks.push(chunk);
19
+ };
20
+ const end = () => {
21
+ clean();
22
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
23
+ catch { reject(failure('desktop_invalid_request')); }
24
+ };
25
+ req.on('data', data); req.on('end', end); req.on('error', abort);
26
+ signal.addEventListener('abort', abort, { once: true });
27
+ if (signal.aborted) abort();
28
+ });
29
+ }
30
+
31
+ /** Browser control API only. Attachment grants and display upgrades have a
32
+ * separate backend-only lifecycle; this handler never exposes the attach call. */
33
+ export function createDesktopHttpHandler({ identity, backend, getAuth, validOrigin, validCsrf, json: writeJson }) {
34
+ const pending = new Map();
35
+ const unsubscribe = identity?.onInvalidate(({ browserSessionId }) => {
36
+ for (const controller of pending.get(browserSessionId) ?? []) controller.abort();
37
+ });
38
+ async function handle(req, res, url) {
39
+ if (url.pathname !== '/api/desktops' && !url.pathname.startsWith('/api/desktops/')) return false;
40
+ const json = (response, status, value) => {
41
+ if (status >= 400 && !req.complete) {
42
+ response.setHeader('connection', 'close');
43
+ response.once('finish', () => req.socket.destroySoon());
44
+ }
45
+ return writeJson(response, status, value);
46
+ };
47
+ res.setHeader('cache-control', 'no-store');
48
+ const auth = getAuth(req);
49
+ if (!auth) { json(res, 401, { error: 'unauthorized' }); return true; }
50
+ if (!validOrigin(req, { allowSameOriginFetch: true })) { json(res, 403, { error: 'forbidden_origin' }); return true; }
51
+ if (!validCsrf(req, auth)) { json(res, 403, { error: 'csrf_required' }); return true; }
52
+ if (['GET', 'HEAD'].includes(req.method) && (req.headers['transfer-encoding'] || Number(req.headers['content-length']) > 0)) {
53
+ json(res, 400, { error: 'desktop_invalid_request' }); return true;
54
+ }
55
+ const match = url.pathname.match(/^\/api\/desktops\/instances\/([^/]+)\/(capability|sessions)(?:\/([^/]+)(?:\/(close))?)?$/);
56
+ if (!match || url.search || !isDesktopUuid(match[1]) || (match[3] && !isDesktopUuid(match[3]))) { json(res, 404, { error: 'not_found' }); return true; }
57
+ const [, instanceId, resource, desktopId, suffix] = match;
58
+ const operation = resource === 'capability' && !desktopId && req.method === 'GET' ? 'capability'
59
+ : resource === 'sessions' && !desktopId && req.method === 'POST' ? 'create'
60
+ : resource === 'sessions' && desktopId && !suffix && req.method === 'GET' ? 'get'
61
+ : resource === 'sessions' && desktopId && suffix === 'close' && req.method === 'POST' ? 'close' : null;
62
+ if (!operation) { json(res, 405, { error: 'method_not_allowed' }); return true; }
63
+ if (!identity || !backend) { json(res, operation === 'capability' ? 200 : 503, { state: 'unsupported', reason: 'desktop_backend_not_configured' }); return true; }
64
+ const controller = new AbortController();
65
+ const requests = pending.get(auth.sessionId) ?? new Set();
66
+ if (requests.size >= 8 || [...pending.values()].reduce((count, items) => count + items.size, 0) >= 64) {
67
+ json(res, 429, { error: 'quota_exceeded' }); return true;
68
+ }
69
+ requests.add(controller); pending.set(auth.sessionId, requests);
70
+ const abort = () => controller.abort();
71
+ req.once('aborted', abort);
72
+ res.once('close', abort);
73
+ const timer = setTimeout(abort, 10000);
74
+ try {
75
+ const body = ['create', 'close'].includes(operation) ? await readBody(req, controller.signal) : undefined;
76
+ if (['create', 'close'].includes(operation)) validateDesktopRequest(operation === 'create' ? 'CreateDesktop' : 'CloseRequest', body);
77
+ const action = operation === 'close' ? (body?.action === 'sign_out' ? 'sign_out' : 'revoke_access') : operation === 'create' ? 'create' : 'view';
78
+ const result = await identity.withDelegation(auth.sessionId, { action, instanceId }, async ({ delegation, identity: verified }) => {
79
+ const value = await backend.request({ operation, id: desktopId ?? instanceId, body,
80
+ idempotencyKey: req.headers['idempotency-key'], delegation, signal: controller.signal });
81
+ if (controller.signal.aborted || getAuth(req)?.sessionId !== auth.sessionId) throw failure('access_denied', 403);
82
+ if (value?.state === 'unsupported' && operation === 'capability') return { state: 'unsupported', reason: 'desktop_not_supported' };
83
+ if (value?.instance_id?.toLowerCase() !== instanceId.toLowerCase() ||
84
+ (desktopId && value?.id?.toLowerCase() !== desktopId.toLowerCase()) ||
85
+ (operation !== 'capability' && value?.workspace_id !== verified.workspaceId)) throw failure('desktop_invalid_response', 502);
86
+ return sanitizeDesktopResponse(operation === 'capability' ? 'Capability' : 'Desktop', value);
87
+ });
88
+ json(res, operation === 'create' || operation === 'close' ? 202 : 200, result);
89
+ } catch (error) {
90
+ const code = error?.code === 'denied' ? 'access_denied' : error?.code === 'identity_unavailable' ? 'desktop_backend_unavailable' : errors.has(error?.code) ? error.code : 'desktop_backend_unavailable';
91
+ const status = Number.isInteger(error?.status) && error.status >= 400 && error.status < 600 ? error.status : code === 'desktop_invalid_request' ? 400 : 502;
92
+ if (!res.destroyed) json(res, status, { error: code });
93
+ } finally {
94
+ clearTimeout(timer); req.removeListener('aborted', abort); res.removeListener('close', abort);
95
+ requests.delete(controller);
96
+ if (!requests.size) pending.delete(auth.sessionId);
97
+ }
98
+ return true;
99
+ }
100
+ return { handle, close() {
101
+ unsubscribe?.();
102
+ for (const requests of pending.values()) for (const controller of requests) controller.abort();
103
+ pending.clear();
104
+ } };
105
+ }