@adea-ai/spatial 0.93.0 → 0.95.0
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 +57 -0
- package/dist/sim-bridge/availability.d.ts +98 -0
- package/dist/sim-bridge/availability.js +181 -0
- package/dist/sim-bridge/cookie.d.ts +21 -0
- package/dist/sim-bridge/cookie.js +85 -0
- package/dist/sim-bridge/index.d.ts +6 -0
- package/dist/sim-bridge/index.js +6 -0
- package/dist/sim-bridge/launch.d.ts +111 -0
- package/dist/sim-bridge/launch.js +153 -0
- package/dist/sim-bridge/messages.d.ts +105 -0
- package/dist/sim-bridge/messages.js +207 -0
- package/dist/sim-bridge/origin.d.ts +21 -0
- package/dist/sim-bridge/origin.js +51 -0
- package/dist/sim-bridge/record.d.ts +16 -0
- package/dist/sim-bridge/record.js +56 -0
- package/dist/sim-bridge/service.d.ts +85 -0
- package/dist/sim-bridge/service.js +166 -0
- package/package.json +6 -1
- package/src/sim-bridge/availability.ts +234 -0
- package/src/sim-bridge/cookie.ts +82 -0
- package/src/sim-bridge/index.ts +6 -0
- package/src/sim-bridge/launch.ts +227 -0
- package/src/sim-bridge/messages.ts +282 -0
- package/src/sim-bridge/origin.ts +61 -0
- package/src/sim-bridge/record.ts +60 -0
- package/src/sim-bridge/service.ts +279 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type LaunchCodeBinding, type LaunchCodeRedemption, type SimLaunchGrant, type SimPlatform, type SimSessionExchangeResult, type SimSurface } from './launch';
|
|
2
|
+
/**
|
|
3
|
+
* Backend logic for the hosted Agent Sim launch, written against injectable ports.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here picks an entitlement source, a sim origin or a storage engine: the caller supplies
|
|
6
|
+
* each as a port or a config value, and an absent value fails closed as `not_configured`. The
|
|
7
|
+
* module does no I/O, owns no endpoint and is not imported by the app. A real deployment adds the
|
|
8
|
+
* HTTP routes, the durable stores and the entitlement lookup around it.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points mirror the two steps of the launch contract in `launch.ts`:
|
|
11
|
+
*
|
|
12
|
+
* - `createSimLaunchIssuer` (Adea backend): checks the signed-in account's entitlement and issues a
|
|
13
|
+
* single-use code bound to the host's `bridgeId`, the sim origin and the surface.
|
|
14
|
+
* - `createSimSessionExchange` (sim origin): redeems that code once and mints a sim-only session
|
|
15
|
+
* whose value travels only in the `Set-Cookie` header, never in a body.
|
|
16
|
+
*/
|
|
17
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
18
|
+
export type SimEntitlementQuery = Readonly<{
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
surface: SimSurface;
|
|
21
|
+
/**
|
|
22
|
+
* The platform the client says it is. It is a hint for the entitlement source and never proof
|
|
23
|
+
* of anything: localhost, an embedded secret or a desktop shell's claim cannot entitle a launch.
|
|
24
|
+
*/
|
|
25
|
+
platform: SimPlatform;
|
|
26
|
+
}>;
|
|
27
|
+
export type SimEntitlementVerdict = 'entitled' | 'not_entitled' | 'unauthenticated' | 'unavailable';
|
|
28
|
+
/**
|
|
29
|
+
* Decides whether the account behind the current request may launch. The port is bound to that
|
|
30
|
+
* request's authenticated session by whoever builds it, so no account identity passes through the
|
|
31
|
+
* launch request body.
|
|
32
|
+
*/
|
|
33
|
+
export type SimEntitlementChecker = (query: SimEntitlementQuery) => MaybePromise<SimEntitlementVerdict>;
|
|
34
|
+
/** Durable single-use code storage. `createLaunchCodeLedger()` is the in-memory reference. */
|
|
35
|
+
export type SimLaunchCodeStore = Readonly<{
|
|
36
|
+
issue(code: string, binding: LaunchCodeBinding, expiresAt: number): MaybePromise<void>;
|
|
37
|
+
redeem(code: string, presented: Omit<LaunchCodeBinding, 'surface'>, now: number): MaybePromise<LaunchCodeRedemption>;
|
|
38
|
+
}>;
|
|
39
|
+
export type SimSessionRecord = Readonly<{
|
|
40
|
+
surface: SimSurface;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
}>;
|
|
43
|
+
/** Where the sim origin keeps the sessions it mints; looked up later by the asset gate. */
|
|
44
|
+
export type SimSessionStore = Readonly<{
|
|
45
|
+
save(value: string, record: SimSessionRecord): MaybePromise<void>;
|
|
46
|
+
}>;
|
|
47
|
+
type Clock = () => number;
|
|
48
|
+
type RandomBytes = (length: number) => Uint8Array;
|
|
49
|
+
export type SimLaunchIssuerConfig = Readonly<{
|
|
50
|
+
/** The sim origin to embed. Absent or invalid means "not configured", never a default. */
|
|
51
|
+
frameOrigin: string | null | undefined;
|
|
52
|
+
allowLoopbackHttp?: boolean;
|
|
53
|
+
entitlement: SimEntitlementChecker;
|
|
54
|
+
store: SimLaunchCodeStore;
|
|
55
|
+
/** Code lifetime in milliseconds, clamped to the contract maximum. */
|
|
56
|
+
codeTtlMs?: number;
|
|
57
|
+
now?: Clock;
|
|
58
|
+
randomBytes?: RandomBytes;
|
|
59
|
+
}>;
|
|
60
|
+
export type SimLaunchIssueResult = SimLaunchGrant | Readonly<{
|
|
61
|
+
status: 'invalid_request';
|
|
62
|
+
}>;
|
|
63
|
+
/** The backend half of the launch: a request body in, a grant (or a refusal) out. */
|
|
64
|
+
export declare function createSimLaunchIssuer(config: SimLaunchIssuerConfig): (body: unknown) => Promise<SimLaunchIssueResult>;
|
|
65
|
+
export type SimSessionExchangeConfig = Readonly<{
|
|
66
|
+
/** This sim origin, as the issuer bound it into the code. Absent or invalid is "not configured". */
|
|
67
|
+
origin: string | null | undefined;
|
|
68
|
+
allowLoopbackHttp?: boolean;
|
|
69
|
+
store: SimLaunchCodeStore;
|
|
70
|
+
sessions: SimSessionStore;
|
|
71
|
+
/** Session lifetime in seconds, clamped to the contract maximum. */
|
|
72
|
+
sessionSeconds?: number;
|
|
73
|
+
now?: Clock;
|
|
74
|
+
randomBytes?: RandomBytes;
|
|
75
|
+
}>;
|
|
76
|
+
/** What the sim origin answers: a JSON body, plus the cookie header only for a new session. */
|
|
77
|
+
export type SimSessionExchangeResponse = Readonly<{
|
|
78
|
+
body: SimSessionExchangeResult | Readonly<{
|
|
79
|
+
status: 'invalid_request';
|
|
80
|
+
}>;
|
|
81
|
+
setCookie?: string;
|
|
82
|
+
}>;
|
|
83
|
+
/** The sim origin's half of the launch: redeems a code once and sets the sim-only cookie. */
|
|
84
|
+
export declare function createSimSessionExchange(config: SimSessionExchangeConfig): (body: unknown) => Promise<SimSessionExchangeResponse>;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createOriginAllowlist } from './origin';
|
|
2
|
+
import { MAX_SIM_LAUNCH_TTL_MS, parseSimLaunchRequest, parseSimSessionExchangeRequest, } from './launch';
|
|
3
|
+
import { MAX_SIM_SESSION_SECONDS, buildSimSessionSetCookie } from './cookie';
|
|
4
|
+
const systemClock = () => Date.now();
|
|
5
|
+
const systemRandom = (length) => crypto.getRandomValues(new Uint8Array(length));
|
|
6
|
+
const DEFAULT_LAUNCH_TTL_MS = 60_000;
|
|
7
|
+
const DEFAULT_SESSION_SECONDS = MAX_SIM_SESSION_SECONDS;
|
|
8
|
+
const CODE_BYTES = 32;
|
|
9
|
+
const SESSION_BYTES = 32;
|
|
10
|
+
/** The backend half of the launch: a request body in, a grant (or a refusal) out. */
|
|
11
|
+
export function createSimLaunchIssuer(config) {
|
|
12
|
+
const frameOrigin = configuredOrigin(config.frameOrigin, config.allowLoopbackHttp === true);
|
|
13
|
+
const now = config.now ?? systemClock;
|
|
14
|
+
const random = config.randomBytes ?? systemRandom;
|
|
15
|
+
const ttl = clampedLifetime(config.codeTtlMs, DEFAULT_LAUNCH_TTL_MS, MAX_SIM_LAUNCH_TTL_MS);
|
|
16
|
+
return async (body) => {
|
|
17
|
+
const request = parseSimLaunchRequest(body);
|
|
18
|
+
if (!request)
|
|
19
|
+
return { status: 'invalid_request' };
|
|
20
|
+
// A lifetime that is not a finite number is a misconfiguration, treated like a missing origin.
|
|
21
|
+
if (!frameOrigin || ttl === null)
|
|
22
|
+
return { status: 'unavailable', reason: 'not_configured' };
|
|
23
|
+
let verdict;
|
|
24
|
+
try {
|
|
25
|
+
verdict = await config.entitlement({
|
|
26
|
+
workspaceId: request.workspaceId,
|
|
27
|
+
surface: request.surface,
|
|
28
|
+
platform: request.platform,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
33
|
+
}
|
|
34
|
+
if (verdict === 'unauthenticated')
|
|
35
|
+
return { status: 'denied', reason: 'unauthenticated' };
|
|
36
|
+
if (verdict === 'not_entitled')
|
|
37
|
+
return { status: 'denied', reason: 'not_entitled' };
|
|
38
|
+
// Anything but an explicit "entitled" fails closed, including a verdict this code does not know.
|
|
39
|
+
if (verdict !== 'entitled')
|
|
40
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
41
|
+
// Everything that can fail on bad injected input is computed before the store is touched, so a
|
|
42
|
+
// bad clock or random source leaves no stored code behind.
|
|
43
|
+
let code;
|
|
44
|
+
let expiresAt;
|
|
45
|
+
let expiresAtIso;
|
|
46
|
+
try {
|
|
47
|
+
code = base64url(random(CODE_BYTES));
|
|
48
|
+
expiresAt = readClock(now) + ttl;
|
|
49
|
+
expiresAtIso = isoInstant(expiresAt);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
53
|
+
}
|
|
54
|
+
if (!expiresAtIso)
|
|
55
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
56
|
+
try {
|
|
57
|
+
await config.store.issue(code, { bridgeId: request.bridgeId, origin: frameOrigin, surface: request.surface }, expiresAt);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: 'granted',
|
|
64
|
+
code,
|
|
65
|
+
expiresAt: expiresAtIso,
|
|
66
|
+
surface: request.surface,
|
|
67
|
+
frameOrigin,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The sim origin's half of the launch: redeems a code once and sets the sim-only cookie. */
|
|
72
|
+
export function createSimSessionExchange(config) {
|
|
73
|
+
const origin = configuredOrigin(config.origin, config.allowLoopbackHttp === true);
|
|
74
|
+
const now = config.now ?? systemClock;
|
|
75
|
+
const random = config.randomBytes ?? systemRandom;
|
|
76
|
+
const seconds = clampedLifetime(config.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SIM_SESSION_SECONDS);
|
|
77
|
+
return async (body) => {
|
|
78
|
+
const request = parseSimSessionExchangeRequest(body);
|
|
79
|
+
if (!request)
|
|
80
|
+
return { body: { status: 'invalid_request' } };
|
|
81
|
+
if (!origin || seconds === null)
|
|
82
|
+
return { body: { status: 'unavailable' } };
|
|
83
|
+
// The session is fully prepared before the code is redeemed. Redeeming spends the code, so a
|
|
84
|
+
// bad clock or random source must fail here, where nothing has been consumed or stored.
|
|
85
|
+
let at;
|
|
86
|
+
let value;
|
|
87
|
+
let expiresAt;
|
|
88
|
+
let expiresAtIso;
|
|
89
|
+
let setCookie;
|
|
90
|
+
try {
|
|
91
|
+
at = readClock(now);
|
|
92
|
+
value = base64url(random(SESSION_BYTES));
|
|
93
|
+
expiresAt = at + seconds * 1000;
|
|
94
|
+
expiresAtIso = isoInstant(expiresAt);
|
|
95
|
+
setCookie = buildSimSessionSetCookie(value, seconds);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { body: { status: 'unavailable' } };
|
|
99
|
+
}
|
|
100
|
+
if (!expiresAtIso)
|
|
101
|
+
return { body: { status: 'unavailable' } };
|
|
102
|
+
let redemption;
|
|
103
|
+
try {
|
|
104
|
+
redemption = await config.store.redeem(request.code, { bridgeId: request.bridgeId, origin }, at);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { body: { status: 'unavailable' } };
|
|
108
|
+
}
|
|
109
|
+
if (!redemption.ok) {
|
|
110
|
+
// An unknown code carries no more information than an expired one; the reply never says
|
|
111
|
+
// whether a code ever existed.
|
|
112
|
+
const reason = redemption.reason === 'unknown' ? 'code_expired' : redemption.reason;
|
|
113
|
+
return { body: { status: 'rejected', reason } };
|
|
114
|
+
}
|
|
115
|
+
// The code is spent from here on: if saving fails the same code is not retryable, and the client
|
|
116
|
+
// starts a new launch.
|
|
117
|
+
try {
|
|
118
|
+
await config.sessions.save(value, { surface: redemption.surface, expiresAt });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { body: { status: 'unavailable' } };
|
|
122
|
+
}
|
|
123
|
+
return { body: { status: 'session', expiresAt: expiresAtIso }, setCookie };
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A configured lifetime in the unit the caller passes (milliseconds or seconds): the default when
|
|
128
|
+
* absent, clamped to [1, max] when a finite number, and null for anything else (NaN, Infinity, a
|
|
129
|
+
* non-number), which callers treat as a misconfiguration rather than guessing a value.
|
|
130
|
+
*/
|
|
131
|
+
function clampedLifetime(value, fallback, max) {
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
return fallback;
|
|
134
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
135
|
+
return null;
|
|
136
|
+
return Math.min(Math.max(1, Math.floor(value)), max);
|
|
137
|
+
}
|
|
138
|
+
/** One clock reading, which must be a finite number of milliseconds; anything else throws. */
|
|
139
|
+
function readClock(now) {
|
|
140
|
+
const reading = now();
|
|
141
|
+
if (typeof reading !== 'number' || !Number.isFinite(reading)) {
|
|
142
|
+
throw new RangeError('clock returned a non-finite reading');
|
|
143
|
+
}
|
|
144
|
+
return reading;
|
|
145
|
+
}
|
|
146
|
+
/** The ISO string for an instant, or null when it is not a valid Date (NaN, beyond the Date range). */
|
|
147
|
+
function isoInstant(ms) {
|
|
148
|
+
const date = new Date(ms);
|
|
149
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
150
|
+
}
|
|
151
|
+
function configuredOrigin(origin, allowLoopbackHttp) {
|
|
152
|
+
if (origin == null)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
return createOriginAllowlist([origin], { allowLoopbackHttp }).origins[0] ?? null;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function base64url(bytes) {
|
|
162
|
+
let binary = '';
|
|
163
|
+
for (const byte of bytes)
|
|
164
|
+
binary += String.fromCharCode(byte);
|
|
165
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adea-ai/spatial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.0",
|
|
4
4
|
"description": "Scene and asset manifest contracts plus the shell-to-engine spatial protocol for Adea workspaces: scene mount manifests, spawn URL helpers, and scene telemetry schemas. Pure types — no rendering, no binaries.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assets",
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
33
|
"development": "./src/index.ts",
|
|
34
34
|
"import": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./sim-bridge": {
|
|
37
|
+
"types": "./dist/sim-bridge/index.d.ts",
|
|
38
|
+
"development": "./src/sim-bridge/index.ts",
|
|
39
|
+
"import": "./dist/sim-bridge/index.js"
|
|
35
40
|
}
|
|
36
41
|
},
|
|
37
42
|
"scripts": {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { SimFrameMessage } from './messages'
|
|
2
|
+
import type { SimLaunchGrant } from './launch'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the host knows about a hosted Agent Sim launch, as a pure state machine. The host shows only
|
|
6
|
+
* what this state says: nothing is "running" until the frame reports it, and every failure names a
|
|
7
|
+
* cause. Chat and Dev never read this state, so a failed or offline sim cannot affect them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const SIM_UNAVAILABLE_REASONS = [
|
|
11
|
+
'unauthenticated',
|
|
12
|
+
'not_entitled',
|
|
13
|
+
'not_configured',
|
|
14
|
+
'service_unavailable',
|
|
15
|
+
'origin_refused',
|
|
16
|
+
'bridge_incompatible',
|
|
17
|
+
'launch_rejected',
|
|
18
|
+
'engine_failed',
|
|
19
|
+
'frame_timeout',
|
|
20
|
+
] as const
|
|
21
|
+
export type SimUnavailableReason = (typeof SIM_UNAVAILABLE_REASONS)[number]
|
|
22
|
+
|
|
23
|
+
type GrantedLaunch = Extract<SimLaunchGrant, { status: 'granted' }>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every state carries the `attempt` it belongs to. `start` begins attempt N+1, and every event
|
|
27
|
+
* that answers a specific attempt (`grant`, `frame`, `timeout`) names the attempt it was issued
|
|
28
|
+
* for. An event for any other attempt is dropped, so a late grant, frame message or timer from an
|
|
29
|
+
* abandoned attempt can never launch, fail or close a newer one. Callers also mint a fresh
|
|
30
|
+
* `bridgeId` per attempt, which stops a stale frame at the message layer as well.
|
|
31
|
+
*/
|
|
32
|
+
export type SimLaunchState =
|
|
33
|
+
| Readonly<{ phase: 'idle'; attempt: number }>
|
|
34
|
+
| Readonly<{ phase: 'requesting'; attempt: number }>
|
|
35
|
+
| Readonly<{ phase: 'awaiting_frame'; attempt: number; grant: GrantedLaunch }>
|
|
36
|
+
| Readonly<{ phase: 'launching'; attempt: number }>
|
|
37
|
+
| Readonly<{ phase: 'running'; attempt: number }>
|
|
38
|
+
| Readonly<{ phase: 'unavailable'; attempt: number; reason: SimUnavailableReason }>
|
|
39
|
+
| Readonly<{ phase: 'offline'; attempt: number }>
|
|
40
|
+
|
|
41
|
+
export type SimLaunchEvent =
|
|
42
|
+
/**
|
|
43
|
+
* Begins a new attempt. `online: false` means the host already knows it is offline: the attempt
|
|
44
|
+
* is recorded as `offline` and no grant is requested, so a surface that mounts offline shows the
|
|
45
|
+
* offline state (with its retry) instead of staying idle, and the backend is never asked.
|
|
46
|
+
*/
|
|
47
|
+
| Readonly<{ type: 'start'; online?: boolean }>
|
|
48
|
+
/** The backend's parsed reply for `attempt`; `null` is a reply that failed validation. */
|
|
49
|
+
| Readonly<{ type: 'grant'; attempt: number; grant: SimLaunchGrant | null }>
|
|
50
|
+
| Readonly<{ type: 'frame'; attempt: number; message: SimFrameMessage; now: number }>
|
|
51
|
+
| Readonly<{ type: 'timeout'; attempt: number }>
|
|
52
|
+
| Readonly<{ type: 'offline' }>
|
|
53
|
+
| Readonly<{ type: 'dispose' }>
|
|
54
|
+
|
|
55
|
+
export type SimLaunchEffect =
|
|
56
|
+
| Readonly<{ type: 'request_grant'; attempt: number }>
|
|
57
|
+
| Readonly<{ type: 'post_launch'; attempt: number; grant: GrantedLaunch }>
|
|
58
|
+
| Readonly<{ type: 'dispose_frame'; attempt: number }>
|
|
59
|
+
|
|
60
|
+
export type SimLaunchStep = Readonly<{ state: SimLaunchState; effect?: SimLaunchEffect }>
|
|
61
|
+
|
|
62
|
+
const FRAME_ERROR_REASONS = {
|
|
63
|
+
launch_rejected: 'launch_rejected',
|
|
64
|
+
asset_unavailable: 'service_unavailable',
|
|
65
|
+
engine_failed: 'engine_failed',
|
|
66
|
+
bridge_incompatible: 'bridge_incompatible',
|
|
67
|
+
} as const
|
|
68
|
+
|
|
69
|
+
export const SIM_IDLE: SimLaunchState = { phase: 'idle', attempt: 0 }
|
|
70
|
+
|
|
71
|
+
function stay(state: SimLaunchState): SimLaunchStep {
|
|
72
|
+
return { state }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unavailable(attempt: number, reason: SimUnavailableReason): SimLaunchStep {
|
|
76
|
+
return { state: { phase: 'unavailable', attempt, reason } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reduceFrame(state: SimLaunchState, message: SimFrameMessage, now: number): SimLaunchStep {
|
|
80
|
+
const { attempt } = state
|
|
81
|
+
switch (message.type) {
|
|
82
|
+
case 'frame.ready': {
|
|
83
|
+
// Only the first ready of an awaited frame launches; a replay changes nothing.
|
|
84
|
+
if (state.phase !== 'awaiting_frame') return stay(state)
|
|
85
|
+
if (Date.parse(state.grant.expiresAt) <= now) return unavailable(attempt, 'frame_timeout')
|
|
86
|
+
return {
|
|
87
|
+
state: { phase: 'launching', attempt },
|
|
88
|
+
effect: { type: 'post_launch', attempt, grant: state.grant },
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
case 'frame.state': {
|
|
92
|
+
if (state.phase !== 'launching' && state.phase !== 'running') return stay(state)
|
|
93
|
+
const { state: reported, reason } = message.payload
|
|
94
|
+
if (reported === 'running') return stay({ phase: 'running', attempt })
|
|
95
|
+
if (reported === 'offline') return stay({ phase: 'offline', attempt })
|
|
96
|
+
if (reported === 'unavailable') return unavailable(attempt, reason ?? 'service_unavailable')
|
|
97
|
+
return stay(state)
|
|
98
|
+
}
|
|
99
|
+
case 'frame.error':
|
|
100
|
+
// A frame that fails before it ever reports ready (an engine that cannot start, a bridge
|
|
101
|
+
// version the host does not speak) is a failure of this attempt, not noise.
|
|
102
|
+
if (
|
|
103
|
+
state.phase !== 'awaiting_frame' &&
|
|
104
|
+
state.phase !== 'launching' &&
|
|
105
|
+
state.phase !== 'running'
|
|
106
|
+
)
|
|
107
|
+
return stay(state)
|
|
108
|
+
return unavailable(attempt, FRAME_ERROR_REASONS[message.payload.code])
|
|
109
|
+
case 'frame.close':
|
|
110
|
+
return state.phase === 'idle' ? stay(state) : stay({ phase: 'idle', attempt })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function reduceSimLaunch(state: SimLaunchState, event: SimLaunchEvent): SimLaunchStep {
|
|
115
|
+
const { attempt } = state
|
|
116
|
+
switch (event.type) {
|
|
117
|
+
case 'start': {
|
|
118
|
+
if (state.phase !== 'idle' && state.phase !== 'unavailable' && state.phase !== 'offline')
|
|
119
|
+
return stay(state)
|
|
120
|
+
const next = attempt + 1
|
|
121
|
+
if (event.online === false) return stay({ phase: 'offline', attempt: next })
|
|
122
|
+
return {
|
|
123
|
+
state: { phase: 'requesting', attempt: next },
|
|
124
|
+
effect: { type: 'request_grant', attempt: next },
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
case 'grant': {
|
|
128
|
+
if (event.attempt !== attempt || state.phase !== 'requesting') return stay(state)
|
|
129
|
+
const { grant } = event
|
|
130
|
+
if (!grant) return unavailable(attempt, 'service_unavailable')
|
|
131
|
+
if (grant.status === 'granted') return stay({ phase: 'awaiting_frame', attempt, grant })
|
|
132
|
+
return unavailable(attempt, grant.reason)
|
|
133
|
+
}
|
|
134
|
+
case 'frame':
|
|
135
|
+
return event.attempt === attempt ? reduceFrame(state, event.message, event.now) : stay(state)
|
|
136
|
+
case 'timeout':
|
|
137
|
+
return event.attempt === attempt &&
|
|
138
|
+
(state.phase === 'awaiting_frame' || state.phase === 'launching')
|
|
139
|
+
? unavailable(attempt, 'frame_timeout')
|
|
140
|
+
: stay(state)
|
|
141
|
+
case 'offline':
|
|
142
|
+
return state.phase === 'idle' ? stay(state) : stay({ phase: 'offline', attempt })
|
|
143
|
+
case 'dispose':
|
|
144
|
+
return state.phase === 'idle'
|
|
145
|
+
? stay(state)
|
|
146
|
+
: { state: { phase: 'idle', attempt }, effect: { type: 'dispose_frame', attempt } }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type SimAvailabilityNotice = Readonly<{
|
|
151
|
+
tone: 'neutral' | 'error'
|
|
152
|
+
title: string
|
|
153
|
+
detail: string
|
|
154
|
+
/** Whether trying again can help without the user changing anything else. */
|
|
155
|
+
retryable: boolean
|
|
156
|
+
}>
|
|
157
|
+
|
|
158
|
+
const REASON_COPY: Record<SimUnavailableReason, Omit<SimAvailabilityNotice, 'tone'>> = {
|
|
159
|
+
unauthenticated: {
|
|
160
|
+
title: 'Sign in to open Agent Sim',
|
|
161
|
+
detail: 'Agent Sim needs your Adea account.',
|
|
162
|
+
retryable: false,
|
|
163
|
+
},
|
|
164
|
+
not_entitled: {
|
|
165
|
+
title: 'Agent Sim is not included in this account',
|
|
166
|
+
detail: 'Your account does not include Agent Sim.',
|
|
167
|
+
retryable: false,
|
|
168
|
+
},
|
|
169
|
+
not_configured: {
|
|
170
|
+
title: 'Agent Sim is not set up here',
|
|
171
|
+
detail: 'This deployment has no Agent Sim service configured.',
|
|
172
|
+
retryable: false,
|
|
173
|
+
},
|
|
174
|
+
service_unavailable: {
|
|
175
|
+
title: 'Agent Sim is unavailable right now',
|
|
176
|
+
detail: 'The Agent Sim service did not respond.',
|
|
177
|
+
retryable: true,
|
|
178
|
+
},
|
|
179
|
+
origin_refused: {
|
|
180
|
+
title: 'Agent Sim was blocked',
|
|
181
|
+
detail: 'The Agent Sim frame did not come from an allowed address.',
|
|
182
|
+
retryable: false,
|
|
183
|
+
},
|
|
184
|
+
bridge_incompatible: {
|
|
185
|
+
title: 'Agent Sim needs an update',
|
|
186
|
+
detail: 'This version of Adea and Agent Sim cannot talk to each other.',
|
|
187
|
+
retryable: false,
|
|
188
|
+
},
|
|
189
|
+
launch_rejected: {
|
|
190
|
+
title: 'Agent Sim could not start',
|
|
191
|
+
detail: 'The launch was refused or had already expired.',
|
|
192
|
+
retryable: true,
|
|
193
|
+
},
|
|
194
|
+
engine_failed: {
|
|
195
|
+
title: 'Agent Sim stopped working',
|
|
196
|
+
detail: 'The simulation hit an error.',
|
|
197
|
+
retryable: true,
|
|
198
|
+
},
|
|
199
|
+
frame_timeout: {
|
|
200
|
+
title: 'Agent Sim took too long to start',
|
|
201
|
+
detail: 'It did not finish loading in time.',
|
|
202
|
+
retryable: true,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const INDEPENDENCE = ' Chat and Dev are not affected.'
|
|
207
|
+
|
|
208
|
+
/** Truthful copy for a state, or null when the surface has nothing to say (idle, loading, running). */
|
|
209
|
+
export function describeSimAvailability(state: SimLaunchState): SimAvailabilityNotice | null {
|
|
210
|
+
switch (state.phase) {
|
|
211
|
+
case 'offline':
|
|
212
|
+
return {
|
|
213
|
+
tone: 'error',
|
|
214
|
+
title: 'Agent Sim is offline',
|
|
215
|
+
detail: `You are offline, so Agent Sim cannot load.${INDEPENDENCE}`,
|
|
216
|
+
retryable: true,
|
|
217
|
+
}
|
|
218
|
+
case 'unavailable': {
|
|
219
|
+
const copy = REASON_COPY[state.reason]
|
|
220
|
+
return { tone: 'error', ...copy, detail: `${copy.detail}${INDEPENDENCE}` }
|
|
221
|
+
}
|
|
222
|
+
case 'requesting':
|
|
223
|
+
case 'awaiting_frame':
|
|
224
|
+
case 'launching':
|
|
225
|
+
return {
|
|
226
|
+
tone: 'neutral',
|
|
227
|
+
title: 'Opening Agent Sim',
|
|
228
|
+
detail: 'Checking your account and loading the simulation.',
|
|
229
|
+
retryable: false,
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sim-only session cookie set by the Agent Sim origin after a successful code exchange.
|
|
3
|
+
*
|
|
4
|
+
* It is `__Host-` prefixed (Secure, Path=/, no Domain), HttpOnly so no script on either origin can
|
|
5
|
+
* read it, and `SameSite=None; Partitioned` because the sim is embedded as a third-party frame: the
|
|
6
|
+
* browser keys it to the embedding Adea site and never sends it in any other context. It
|
|
7
|
+
* authorizes the sim origin's assets only; it is not an Adea account session and never appears in a
|
|
8
|
+
* response body or a bridge message.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SIM_SESSION_COOKIE_NAME = '__Host-adea-sim'
|
|
12
|
+
export const MAX_SIM_SESSION_SECONDS = 3600
|
|
13
|
+
|
|
14
|
+
const SESSION_VALUE = /^[A-Za-z0-9_-]{32,256}$/
|
|
15
|
+
|
|
16
|
+
export function buildSimSessionSetCookie(value: string, maxAgeSeconds: number): string {
|
|
17
|
+
if (!SESSION_VALUE.test(value)) throw new Error('Invalid Agent Sim session value')
|
|
18
|
+
return setCookie(value, boundedMaxAge(maxAgeSeconds))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Expires the session on the client. */
|
|
22
|
+
export function buildSimSessionClearCookie(): string {
|
|
23
|
+
return setCookie('', 0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function boundedMaxAge(seconds: number): number {
|
|
27
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_SIM_SESSION_SECONDS)
|
|
28
|
+
throw new Error('Agent Sim session lifetime out of range')
|
|
29
|
+
return seconds
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setCookie(value: string, maxAge: number): string {
|
|
33
|
+
return [
|
|
34
|
+
`${SIM_SESSION_COOKIE_NAME}=${value}`,
|
|
35
|
+
`Max-Age=${maxAge}`,
|
|
36
|
+
'Path=/',
|
|
37
|
+
'Secure',
|
|
38
|
+
'HttpOnly',
|
|
39
|
+
'SameSite=None',
|
|
40
|
+
'Partitioned',
|
|
41
|
+
].join('; ')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every way a `Set-Cookie` header departs from the contract; empty means it conforms. */
|
|
45
|
+
export function simSessionCookieViolations(header: string): readonly string[] {
|
|
46
|
+
const [pair = '', ...attributes] = header.split(';').map((part) => part.trim())
|
|
47
|
+
const violations: string[] = []
|
|
48
|
+
if (!pair.startsWith(`${SIM_SESSION_COOKIE_NAME}=`)) violations.push('name')
|
|
49
|
+
const names = new Map<string, string>()
|
|
50
|
+
for (const attribute of attributes) {
|
|
51
|
+
const index = attribute.indexOf('=')
|
|
52
|
+
const key = (index === -1 ? attribute : attribute.slice(0, index)).toLowerCase()
|
|
53
|
+
if (names.has(key)) violations.push(`duplicate:${key}`)
|
|
54
|
+
names.set(key, index === -1 ? '' : attribute.slice(index + 1))
|
|
55
|
+
}
|
|
56
|
+
if (!names.has('secure')) violations.push('secure')
|
|
57
|
+
if (!names.has('httponly')) violations.push('httponly')
|
|
58
|
+
if (!names.has('partitioned')) violations.push('partitioned')
|
|
59
|
+
if (names.get('path') !== '/') violations.push('path')
|
|
60
|
+
if (names.has('domain')) violations.push('domain')
|
|
61
|
+
if (names.get('samesite')?.toLowerCase() !== 'none') violations.push('samesite')
|
|
62
|
+
const maxAge = names.get('max-age')
|
|
63
|
+
if (maxAge === undefined || !/^\d+$/.test(maxAge) || Number(maxAge) > MAX_SIM_SESSION_SECONDS)
|
|
64
|
+
violations.push('max-age')
|
|
65
|
+
if (names.has('expires')) violations.push('expires')
|
|
66
|
+
return violations
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The session value from a request's `Cookie` header, or null unless exactly one sim cookie of
|
|
71
|
+
* the right shape is present (a duplicate is refused rather than guessed between).
|
|
72
|
+
*/
|
|
73
|
+
export function readSimSessionCookie(cookieHeader: string | null | undefined): string | null {
|
|
74
|
+
if (!cookieHeader) return null
|
|
75
|
+
const values = cookieHeader
|
|
76
|
+
.split(';')
|
|
77
|
+
.map((part) => part.trim())
|
|
78
|
+
.filter((part) => part.startsWith(`${SIM_SESSION_COOKIE_NAME}=`))
|
|
79
|
+
.map((part) => part.slice(SIM_SESSION_COOKIE_NAME.length + 1))
|
|
80
|
+
const [only] = values
|
|
81
|
+
return values.length === 1 && only !== undefined && SESSION_VALUE.test(only) ? only : null
|
|
82
|
+
}
|