@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 +1 -0
- package/bridge/DESKTOP-IDENTITY.md +190 -0
- package/bridge/src/desktop-backend.mjs +102 -0
- package/bridge/src/desktop-contract.d.mts +46 -0
- package/bridge/src/desktop-contract.mjs +95 -0
- package/bridge/src/desktop-http.mjs +105 -0
- package/bridge/src/desktop-identity-keycloak.mjs +295 -0
- package/bridge/src/desktop-identity.mjs +162 -0
- package/bridge/src/server.mjs +107 -4
- package/desktop/README.md +25 -0
- package/desktop/src-tauri/Cargo.lock +149 -125
- package/package.json +1 -1
- package/web/dist/assets/index-BkDXuvNe.js +312 -0
- package/web/dist/index.html +1 -1
- package/web/src/App.test.tsx +45 -0
- package/web/src/App.tsx +34 -6
- package/web/src/components/Desktop.test.tsx +231 -0
- package/web/src/components/Desktop.tsx +110 -0
- package/web/src/components/Inventory.test.tsx +59 -0
- package/web/src/components/Inventory.tsx +65 -4
- package/web/src/desktop-api.test.ts +129 -0
- package/web/src/desktop-api.ts +114 -0
- package/web/src/useDesktopSession.ts +257 -0
- package/web/dist/assets/index-CL5LzhiH.js +0 -312
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
|
+
}
|