@adea-ai/spatial 0.94.0 → 0.96.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/index.d.ts +3 -1
- package/dist/index.js +7 -2
- package/dist/sim-bridge/availability.d.ts +98 -0
- package/dist/sim-bridge/availability.js +181 -0
- package/dist/sim-bridge/character.d.ts +17 -0
- package/dist/sim-bridge/character.js +49 -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 +8 -0
- package/dist/sim-bridge/index.js +8 -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 +133 -0
- package/dist/sim-bridge/messages.js +244 -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/save-client.d.ts +25 -0
- package/dist/sim-bridge/save-client.js +55 -0
- package/dist/sim-bridge/service.d.ts +85 -0
- package/dist/sim-bridge/service.js +166 -0
- package/package.json +7 -2
- package/src/index.ts +7 -2
- package/src/sim-bridge/availability.ts +238 -0
- package/src/sim-bridge/character.ts +53 -0
- package/src/sim-bridge/cookie.ts +82 -0
- package/src/sim-bridge/index.ts +8 -0
- package/src/sim-bridge/launch.ts +227 -0
- package/src/sim-bridge/messages.ts +338 -0
- package/src/sim-bridge/origin.ts +61 -0
- package/src/sim-bridge/record.ts +60 -0
- package/src/sim-bridge/save-client.ts +87 -0
- package/src/sim-bridge/service.ts +279 -0
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ package.
|
|
|
17
17
|
- `scene-spawn.ts`: cross-app spawn encode/parse and portal URL helpers.
|
|
18
18
|
- `telemetry.ts`: `ScenePerformanceReport` schema, validation, envelope, and
|
|
19
19
|
navigation instrumentation.
|
|
20
|
+
- `sim-bridge/` (subpath `@adea-ai/spatial/sim-bridge`): the contract for embedding
|
|
21
|
+
a separately hosted Agent Sim page. See below.
|
|
20
22
|
- `data/`: tracked scene manifests (`home/`, `work/`), mirrored from the
|
|
21
23
|
engine's `scenes/sim/assets/`; staged into the web public dir by
|
|
22
24
|
`scripts/sync-assets.mjs`.
|
|
@@ -27,3 +29,58 @@ version-range compatibility or verify entry bytes. Tracked scene data is a
|
|
|
27
29
|
public mirror, not proof that every file matches a particular private source
|
|
28
30
|
revision or distributed pack. Manifest and payload acceptance need separate
|
|
29
31
|
checks against the intended pack.
|
|
32
|
+
|
|
33
|
+
## Hosted Agent Sim bridge (`@adea-ai/spatial/sim-bridge`)
|
|
34
|
+
|
|
35
|
+
Schema, validators and a launch contract for a hosted Agent Sim page embedded in an
|
|
36
|
+
iframe. It is a subpath export so the default desktop payload and the app bundle do not
|
|
37
|
+
include it, and it contains no private Agent Sim or asset code. It has no transport of its
|
|
38
|
+
own: callers pass `MessageEvent`-shaped values in and post through `postToFrame` /
|
|
39
|
+
`postToHost`.
|
|
40
|
+
|
|
41
|
+
- **Messages** (`messages.ts`): a closed, versioned set. Frame to host: `frame.ready`,
|
|
42
|
+
`frame.state`, `frame.close`, `frame.error`. Host to frame: `host.launch`,
|
|
43
|
+
`host.dispose`. There is no generic call, eval, filesystem, native-RPC or credential
|
|
44
|
+
message, and the strict payload shapes reject any extra key (a `token` field included).
|
|
45
|
+
Each message is accepted only when its sender origin is on the exact allowlist, its
|
|
46
|
+
`event.source` is the exact frame (or parent) window, its envelope carries the
|
|
47
|
+
per-mount `bridgeId` the host issued (`createBridgeId`), the version is supported, and
|
|
48
|
+
it stays inside a finite size, depth, width and node budget (about 2 KiB; cyclic, deep and
|
|
49
|
+
wide payloads are refused without being walked). Accessor properties are never read, and
|
|
50
|
+
every discriminant is read as an own data property. Posts never use `*`.
|
|
51
|
+
- **Origins** (`origin.ts`): exact canonical `scheme://host[:port]` strings. No
|
|
52
|
+
wildcards, suffix matching, paths or `null`. Loopback `http:` needs an explicit
|
|
53
|
+
`allowLoopbackHttp` opt-in for local development. Being on the allowlist says where a
|
|
54
|
+
frame was served from. It is not proof of an official desktop identity or an
|
|
55
|
+
entitlement, and neither is localhost or any value embedded in the app.
|
|
56
|
+
- **Launch** (`launch.ts`): the host asks the Adea backend for a launch
|
|
57
|
+
(`SimLaunchRequest`); the backend checks entitlement and returns a one-time code valid
|
|
58
|
+
for at most 120 seconds (`SimLaunchGrant`, which is also refused when it is stale, for
|
|
59
|
+
another surface, or for a frame origin outside the allowlist). The host posts the code
|
|
60
|
+
to the frame, which exchanges it at the sim origin for a sim-only session
|
|
61
|
+
(`SimSessionExchangeRequest` / `SimSessionExchangeResult`). Neither body carries a
|
|
62
|
+
token. `createLaunchOnce` and `createLaunchCodeLedger` pin the single-use behaviour for
|
|
63
|
+
the frame and for a backend implementation to match.
|
|
64
|
+
- **Launch service** (`service.ts`): the backend logic for both steps, written against
|
|
65
|
+
injectable ports and with no I/O, endpoint or storage of its own. `createSimLaunchIssuer`
|
|
66
|
+
checks a caller-supplied `SimEntitlementChecker` and issues a code through a
|
|
67
|
+
`SimLaunchCodeStore`; `createSimSessionExchange` redeems it once and returns the
|
|
68
|
+
`Set-Cookie` value separately from the JSON body. The sim origin and the entitlement source
|
|
69
|
+
are inputs, never defaults: an absent or invalid origin answers `not_configured` before the
|
|
70
|
+
entitlement source is asked, and any verdict other than `entitled` fails closed. Nothing
|
|
71
|
+
here chooses where entitlement comes from, where the sim is hosted, or how stores persist.
|
|
72
|
+
- **Session cookie** (`cookie.ts`): named `__Host-adea-sim`, with `HttpOnly`, `Secure`,
|
|
73
|
+
`Path=/`, `SameSite=None`, `Partitioned`, no `Domain`, and a lifetime of at most one
|
|
74
|
+
hour. It authorizes the sim origin's assets only, never an Adea account API.
|
|
75
|
+
- **Availability** (`availability.ts`): `reduceSimLaunch` is a pure state machine
|
|
76
|
+
(`idle`, `requesting`, `awaiting_frame`, `launching`, `running`, `unavailable`,
|
|
77
|
+
`offline`), and every state carries an `attempt`: `start` begins attempt N+1 and a
|
|
78
|
+
grant, frame message or timeout is applied only when it names the current attempt, so
|
|
79
|
+
a late event from an interrupted attempt cannot launch, fail or close a newer one
|
|
80
|
+
(mint a fresh `bridgeId` per attempt too). A surface is `running` only after the
|
|
81
|
+
frame says so, and
|
|
82
|
+
`describeSimAvailability` gives truthful copy that states Chat and Dev are not affected.
|
|
83
|
+
|
|
84
|
+
Not part of this package: the hosted entry, the backend endpoints, the private backing
|
|
85
|
+
origin that authorizes assets before serving cached responses, and any credentials or
|
|
86
|
+
deployment. Those are separate work.
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare const DEFAULT_CHARACTER_ID = "configurable";
|
|
|
8
8
|
/**
|
|
9
9
|
* Shell-side character param check. The engine validates against its
|
|
10
10
|
* reference/preset catalogs on mount; the shell only needs a plausible
|
|
11
|
-
* non-empty value to thread through.
|
|
11
|
+
* non-empty value to thread through. A serialized customization
|
|
12
|
+
* (`character:v1:` and 13 slots, 157 characters by default) is checked by the
|
|
13
|
+
* bridge's structural parser instead of the short-id length rule.
|
|
12
14
|
*/
|
|
13
15
|
export declare function isPlausibleCharacterId(value: string | undefined): value is string;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSerializedSimCharacter } from './sim-bridge/character';
|
|
1
2
|
export * from './asset-manifests';
|
|
2
3
|
export { agentSimEngineManifestUrl, isLocalDevHost, isOfficialAgentSimWebOrigin, parseAgentSimEngineManifest, OFFICIAL_AGENT_SIM_WEB_HOSTS, } from './engine';
|
|
3
4
|
export { hqHomeManifest, hqWorkManifest } from './manifests';
|
|
@@ -8,8 +9,12 @@ export const DEFAULT_CHARACTER_ID = configurableCharacterId;
|
|
|
8
9
|
/**
|
|
9
10
|
* Shell-side character param check. The engine validates against its
|
|
10
11
|
* reference/preset catalogs on mount; the shell only needs a plausible
|
|
11
|
-
* non-empty value to thread through.
|
|
12
|
+
* non-empty value to thread through. A serialized customization
|
|
13
|
+
* (`character:v1:` and 13 slots, 157 characters by default) is checked by the
|
|
14
|
+
* bridge's structural parser instead of the short-id length rule.
|
|
12
15
|
*/
|
|
13
16
|
export function isPlausibleCharacterId(value) {
|
|
14
|
-
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
18
|
+
return false;
|
|
19
|
+
return value.length <= 128 || isSerializedSimCharacter(value);
|
|
15
20
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SimFrameStatusMessage } from './messages';
|
|
2
|
+
import type { SimLaunchGrant } from './launch';
|
|
3
|
+
/**
|
|
4
|
+
* What the host knows about a hosted Agent Sim launch, as a pure state machine. The host shows only
|
|
5
|
+
* what this state says: nothing is "running" until the frame reports it, and every failure names a
|
|
6
|
+
* cause. Chat and Dev never read this state, so a failed or offline sim cannot affect them.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SIM_UNAVAILABLE_REASONS: readonly ['unauthenticated', 'not_entitled', 'not_configured', 'service_unavailable', 'origin_refused', 'bridge_incompatible', 'launch_rejected', 'engine_failed', 'frame_timeout'];
|
|
9
|
+
export type SimUnavailableReason = (typeof SIM_UNAVAILABLE_REASONS)[number];
|
|
10
|
+
type GrantedLaunch = Extract<SimLaunchGrant, {
|
|
11
|
+
status: 'granted';
|
|
12
|
+
}>;
|
|
13
|
+
/**
|
|
14
|
+
* Every state carries the `attempt` it belongs to. `start` begins attempt N+1, and every event
|
|
15
|
+
* that answers a specific attempt (`grant`, `frame`, `timeout`) names the attempt it was issued
|
|
16
|
+
* for. An event for any other attempt is dropped, so a late grant, frame message or timer from an
|
|
17
|
+
* abandoned attempt can never launch, fail or close a newer one. Callers also mint a fresh
|
|
18
|
+
* `bridgeId` per attempt, which stops a stale frame at the message layer as well.
|
|
19
|
+
*/
|
|
20
|
+
export type SimLaunchState = Readonly<{
|
|
21
|
+
phase: 'idle';
|
|
22
|
+
attempt: number;
|
|
23
|
+
}> | Readonly<{
|
|
24
|
+
phase: 'requesting';
|
|
25
|
+
attempt: number;
|
|
26
|
+
}> | Readonly<{
|
|
27
|
+
phase: 'awaiting_frame';
|
|
28
|
+
attempt: number;
|
|
29
|
+
grant: GrantedLaunch;
|
|
30
|
+
}> | Readonly<{
|
|
31
|
+
phase: 'launching';
|
|
32
|
+
attempt: number;
|
|
33
|
+
}> | Readonly<{
|
|
34
|
+
phase: 'running';
|
|
35
|
+
attempt: number;
|
|
36
|
+
}> | Readonly<{
|
|
37
|
+
phase: 'unavailable';
|
|
38
|
+
attempt: number;
|
|
39
|
+
reason: SimUnavailableReason;
|
|
40
|
+
}> | Readonly<{
|
|
41
|
+
phase: 'offline';
|
|
42
|
+
attempt: number;
|
|
43
|
+
}>;
|
|
44
|
+
export type SimLaunchEvent =
|
|
45
|
+
/**
|
|
46
|
+
* Begins a new attempt. `online: false` means the host already knows it is offline: the attempt
|
|
47
|
+
* is recorded as `offline` and no grant is requested, so a surface that mounts offline shows the
|
|
48
|
+
* offline state (with its retry) instead of staying idle, and the backend is never asked.
|
|
49
|
+
*/
|
|
50
|
+
Readonly<{
|
|
51
|
+
type: 'start';
|
|
52
|
+
online?: boolean;
|
|
53
|
+
}>
|
|
54
|
+
/** The backend's parsed reply for `attempt`; `null` is a reply that failed validation. */
|
|
55
|
+
| Readonly<{
|
|
56
|
+
type: 'grant';
|
|
57
|
+
attempt: number;
|
|
58
|
+
grant: SimLaunchGrant | null;
|
|
59
|
+
}> | Readonly<{
|
|
60
|
+
type: 'frame';
|
|
61
|
+
attempt: number;
|
|
62
|
+
message: SimFrameStatusMessage;
|
|
63
|
+
now: number;
|
|
64
|
+
}> | Readonly<{
|
|
65
|
+
type: 'timeout';
|
|
66
|
+
attempt: number;
|
|
67
|
+
}> | Readonly<{
|
|
68
|
+
type: 'offline';
|
|
69
|
+
}> | Readonly<{
|
|
70
|
+
type: 'dispose';
|
|
71
|
+
}>;
|
|
72
|
+
export type SimLaunchEffect = Readonly<{
|
|
73
|
+
type: 'request_grant';
|
|
74
|
+
attempt: number;
|
|
75
|
+
}> | Readonly<{
|
|
76
|
+
type: 'post_launch';
|
|
77
|
+
attempt: number;
|
|
78
|
+
grant: GrantedLaunch;
|
|
79
|
+
}> | Readonly<{
|
|
80
|
+
type: 'dispose_frame';
|
|
81
|
+
attempt: number;
|
|
82
|
+
}>;
|
|
83
|
+
export type SimLaunchStep = Readonly<{
|
|
84
|
+
state: SimLaunchState;
|
|
85
|
+
effect?: SimLaunchEffect;
|
|
86
|
+
}>;
|
|
87
|
+
export declare const SIM_IDLE: SimLaunchState;
|
|
88
|
+
export declare function reduceSimLaunch(state: SimLaunchState, event: SimLaunchEvent): SimLaunchStep;
|
|
89
|
+
export type SimAvailabilityNotice = Readonly<{
|
|
90
|
+
tone: 'neutral' | 'error';
|
|
91
|
+
title: string;
|
|
92
|
+
detail: string;
|
|
93
|
+
/** Whether trying again can help without the user changing anything else. */
|
|
94
|
+
retryable: boolean;
|
|
95
|
+
}>;
|
|
96
|
+
/** Truthful copy for a state, or null when the surface has nothing to say (idle, loading, running). */
|
|
97
|
+
export declare function describeSimAvailability(state: SimLaunchState): SimAvailabilityNotice | null;
|
|
98
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host knows about a hosted Agent Sim launch, as a pure state machine. The host shows only
|
|
3
|
+
* what this state says: nothing is "running" until the frame reports it, and every failure names a
|
|
4
|
+
* cause. Chat and Dev never read this state, so a failed or offline sim cannot affect them.
|
|
5
|
+
*/
|
|
6
|
+
export const SIM_UNAVAILABLE_REASONS = [
|
|
7
|
+
'unauthenticated',
|
|
8
|
+
'not_entitled',
|
|
9
|
+
'not_configured',
|
|
10
|
+
'service_unavailable',
|
|
11
|
+
'origin_refused',
|
|
12
|
+
'bridge_incompatible',
|
|
13
|
+
'launch_rejected',
|
|
14
|
+
'engine_failed',
|
|
15
|
+
'frame_timeout',
|
|
16
|
+
];
|
|
17
|
+
const FRAME_ERROR_REASONS = {
|
|
18
|
+
launch_rejected: 'launch_rejected',
|
|
19
|
+
asset_unavailable: 'service_unavailable',
|
|
20
|
+
engine_failed: 'engine_failed',
|
|
21
|
+
bridge_incompatible: 'bridge_incompatible',
|
|
22
|
+
};
|
|
23
|
+
export const SIM_IDLE = { phase: 'idle', attempt: 0 };
|
|
24
|
+
function stay(state) {
|
|
25
|
+
return { state };
|
|
26
|
+
}
|
|
27
|
+
function unavailable(attempt, reason) {
|
|
28
|
+
return { state: { phase: 'unavailable', attempt, reason } };
|
|
29
|
+
}
|
|
30
|
+
function reduceFrame(state, message, now) {
|
|
31
|
+
const { attempt } = state;
|
|
32
|
+
switch (message.type) {
|
|
33
|
+
case 'frame.ready': {
|
|
34
|
+
// Only the first ready of an awaited frame launches; a replay changes nothing.
|
|
35
|
+
if (state.phase !== 'awaiting_frame')
|
|
36
|
+
return stay(state);
|
|
37
|
+
if (Date.parse(state.grant.expiresAt) <= now)
|
|
38
|
+
return unavailable(attempt, 'frame_timeout');
|
|
39
|
+
return {
|
|
40
|
+
state: { phase: 'launching', attempt },
|
|
41
|
+
effect: { type: 'post_launch', attempt, grant: state.grant },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
case 'frame.state': {
|
|
45
|
+
if (state.phase !== 'launching' && state.phase !== 'running')
|
|
46
|
+
return stay(state);
|
|
47
|
+
const { state: reported, reason } = message.payload;
|
|
48
|
+
if (reported === 'running')
|
|
49
|
+
return stay({ phase: 'running', attempt });
|
|
50
|
+
if (reported === 'offline')
|
|
51
|
+
return stay({ phase: 'offline', attempt });
|
|
52
|
+
if (reported === 'unavailable')
|
|
53
|
+
return unavailable(attempt, reason ?? 'service_unavailable');
|
|
54
|
+
return stay(state);
|
|
55
|
+
}
|
|
56
|
+
case 'frame.error':
|
|
57
|
+
// A frame that fails before it ever reports ready (an engine that cannot start, a bridge
|
|
58
|
+
// version the host does not speak) is a failure of this attempt, not noise.
|
|
59
|
+
if (state.phase !== 'awaiting_frame' &&
|
|
60
|
+
state.phase !== 'launching' &&
|
|
61
|
+
state.phase !== 'running')
|
|
62
|
+
return stay(state);
|
|
63
|
+
return unavailable(attempt, FRAME_ERROR_REASONS[message.payload.code]);
|
|
64
|
+
case 'frame.close':
|
|
65
|
+
return state.phase === 'idle' ? stay(state) : stay({ phase: 'idle', attempt });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function reduceSimLaunch(state, event) {
|
|
69
|
+
const { attempt } = state;
|
|
70
|
+
switch (event.type) {
|
|
71
|
+
case 'start': {
|
|
72
|
+
if (state.phase !== 'idle' && state.phase !== 'unavailable' && state.phase !== 'offline')
|
|
73
|
+
return stay(state);
|
|
74
|
+
const next = attempt + 1;
|
|
75
|
+
if (event.online === false)
|
|
76
|
+
return stay({ phase: 'offline', attempt: next });
|
|
77
|
+
return {
|
|
78
|
+
state: { phase: 'requesting', attempt: next },
|
|
79
|
+
effect: { type: 'request_grant', attempt: next },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
case 'grant': {
|
|
83
|
+
if (event.attempt !== attempt || state.phase !== 'requesting')
|
|
84
|
+
return stay(state);
|
|
85
|
+
const { grant } = event;
|
|
86
|
+
if (!grant)
|
|
87
|
+
return unavailable(attempt, 'service_unavailable');
|
|
88
|
+
if (grant.status === 'granted')
|
|
89
|
+
return stay({ phase: 'awaiting_frame', attempt, grant });
|
|
90
|
+
return unavailable(attempt, grant.reason);
|
|
91
|
+
}
|
|
92
|
+
case 'frame':
|
|
93
|
+
return event.attempt === attempt ? reduceFrame(state, event.message, event.now) : stay(state);
|
|
94
|
+
case 'timeout':
|
|
95
|
+
return event.attempt === attempt &&
|
|
96
|
+
(state.phase === 'awaiting_frame' || state.phase === 'launching')
|
|
97
|
+
? unavailable(attempt, 'frame_timeout')
|
|
98
|
+
: stay(state);
|
|
99
|
+
case 'offline':
|
|
100
|
+
return state.phase === 'idle' ? stay(state) : stay({ phase: 'offline', attempt });
|
|
101
|
+
case 'dispose':
|
|
102
|
+
return state.phase === 'idle'
|
|
103
|
+
? stay(state)
|
|
104
|
+
: { state: { phase: 'idle', attempt }, effect: { type: 'dispose_frame', attempt } };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const REASON_COPY = {
|
|
108
|
+
unauthenticated: {
|
|
109
|
+
title: 'Sign in to open Agent Sim',
|
|
110
|
+
detail: 'Agent Sim needs your Adea account.',
|
|
111
|
+
retryable: false,
|
|
112
|
+
},
|
|
113
|
+
not_entitled: {
|
|
114
|
+
title: 'Agent Sim is not included in this account',
|
|
115
|
+
detail: 'Your account does not include Agent Sim.',
|
|
116
|
+
retryable: false,
|
|
117
|
+
},
|
|
118
|
+
not_configured: {
|
|
119
|
+
title: 'Agent Sim is not set up here',
|
|
120
|
+
detail: 'This deployment has no Agent Sim service configured.',
|
|
121
|
+
retryable: false,
|
|
122
|
+
},
|
|
123
|
+
service_unavailable: {
|
|
124
|
+
title: 'Agent Sim is unavailable right now',
|
|
125
|
+
detail: 'The Agent Sim service did not respond.',
|
|
126
|
+
retryable: true,
|
|
127
|
+
},
|
|
128
|
+
origin_refused: {
|
|
129
|
+
title: 'Agent Sim was blocked',
|
|
130
|
+
detail: 'The Agent Sim frame did not come from an allowed address.',
|
|
131
|
+
retryable: false,
|
|
132
|
+
},
|
|
133
|
+
bridge_incompatible: {
|
|
134
|
+
title: 'Agent Sim needs an update',
|
|
135
|
+
detail: 'This version of Adea and Agent Sim cannot talk to each other.',
|
|
136
|
+
retryable: false,
|
|
137
|
+
},
|
|
138
|
+
launch_rejected: {
|
|
139
|
+
title: 'Agent Sim could not start',
|
|
140
|
+
detail: 'The launch was refused or had already expired.',
|
|
141
|
+
retryable: true,
|
|
142
|
+
},
|
|
143
|
+
engine_failed: {
|
|
144
|
+
title: 'Agent Sim stopped working',
|
|
145
|
+
detail: 'The simulation hit an error.',
|
|
146
|
+
retryable: true,
|
|
147
|
+
},
|
|
148
|
+
frame_timeout: {
|
|
149
|
+
title: 'Agent Sim took too long to start',
|
|
150
|
+
detail: 'It did not finish loading in time.',
|
|
151
|
+
retryable: true,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const INDEPENDENCE = ' Chat and Dev are not affected.';
|
|
155
|
+
/** Truthful copy for a state, or null when the surface has nothing to say (idle, loading, running). */
|
|
156
|
+
export function describeSimAvailability(state) {
|
|
157
|
+
switch (state.phase) {
|
|
158
|
+
case 'offline':
|
|
159
|
+
return {
|
|
160
|
+
tone: 'error',
|
|
161
|
+
title: 'Agent Sim is offline',
|
|
162
|
+
detail: `You are offline, so Agent Sim cannot load.${INDEPENDENCE}`,
|
|
163
|
+
retryable: true,
|
|
164
|
+
};
|
|
165
|
+
case 'unavailable': {
|
|
166
|
+
const copy = REASON_COPY[state.reason];
|
|
167
|
+
return { tone: 'error', ...copy, detail: `${copy.detail}${INDEPENDENCE}` };
|
|
168
|
+
}
|
|
169
|
+
case 'requesting':
|
|
170
|
+
case 'awaiting_frame':
|
|
171
|
+
case 'launching':
|
|
172
|
+
return {
|
|
173
|
+
tone: 'neutral',
|
|
174
|
+
title: 'Opening Agent Sim',
|
|
175
|
+
detail: 'Checking your account and loading the simulation.',
|
|
176
|
+
retryable: false,
|
|
177
|
+
};
|
|
178
|
+
default:
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The character value the bridge carries in `host.launch` and `frame.character.save`.
|
|
3
|
+
*
|
|
4
|
+
* Two forms, both finite and structurally checked. The bridge never learns the private part
|
|
5
|
+
* catalog: slot contents are bounded opaque ids and the engine keeps validating membership.
|
|
6
|
+
*
|
|
7
|
+
* 1. A catalog id, such as `configurable`.
|
|
8
|
+
* 2. `character:v1:` followed by exactly 13 `|`-separated slots in `SIM_CHARACTER_SLOTS` order.
|
|
9
|
+
* An empty slot is `-`, and `body` is required.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SIM_CHARACTER_SLOTS: readonly ['body', 'ears', 'face', 'hair', 'hat', 'top', 'bottom', 'shoes', 'socks', 'glasses', 'gloves', 'accessory', 'costume'];
|
|
12
|
+
export declare const SIM_SERIALIZED_CHARACTER_PREFIX = "character:v1:";
|
|
13
|
+
/** The audited serializer's longest value is 251 characters; this leaves headroom, not slack. */
|
|
14
|
+
export declare const MAX_SIM_SERIALIZED_CHARACTER_LENGTH = 384;
|
|
15
|
+
export declare function isSerializedSimCharacter(value: unknown): value is string;
|
|
16
|
+
/** The value when it is a catalog id or a well-formed serialized character; otherwise null. */
|
|
17
|
+
export declare function parseSimCharacterValue(value: unknown): string | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The character value the bridge carries in `host.launch` and `frame.character.save`.
|
|
3
|
+
*
|
|
4
|
+
* Two forms, both finite and structurally checked. The bridge never learns the private part
|
|
5
|
+
* catalog: slot contents are bounded opaque ids and the engine keeps validating membership.
|
|
6
|
+
*
|
|
7
|
+
* 1. A catalog id, such as `configurable`.
|
|
8
|
+
* 2. `character:v1:` followed by exactly 13 `|`-separated slots in `SIM_CHARACTER_SLOTS` order.
|
|
9
|
+
* An empty slot is `-`, and `body` is required.
|
|
10
|
+
*/
|
|
11
|
+
export const SIM_CHARACTER_SLOTS = [
|
|
12
|
+
'body',
|
|
13
|
+
'ears',
|
|
14
|
+
'face',
|
|
15
|
+
'hair',
|
|
16
|
+
'hat',
|
|
17
|
+
'top',
|
|
18
|
+
'bottom',
|
|
19
|
+
'shoes',
|
|
20
|
+
'socks',
|
|
21
|
+
'glasses',
|
|
22
|
+
'gloves',
|
|
23
|
+
'accessory',
|
|
24
|
+
'costume',
|
|
25
|
+
];
|
|
26
|
+
export const SIM_SERIALIZED_CHARACTER_PREFIX = 'character:v1:';
|
|
27
|
+
/** The audited serializer's longest value is 251 characters; this leaves headroom, not slack. */
|
|
28
|
+
export const MAX_SIM_SERIALIZED_CHARACTER_LENGTH = 384;
|
|
29
|
+
const CATALOG_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
30
|
+
const SLOT_PART_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/;
|
|
31
|
+
const EMPTY_SLOT = '-';
|
|
32
|
+
export function isSerializedSimCharacter(value) {
|
|
33
|
+
if (typeof value !== 'string' ||
|
|
34
|
+
value.length > MAX_SIM_SERIALIZED_CHARACTER_LENGTH ||
|
|
35
|
+
!value.startsWith(SIM_SERIALIZED_CHARACTER_PREFIX))
|
|
36
|
+
return false;
|
|
37
|
+
const slots = value.slice(SIM_SERIALIZED_CHARACTER_PREFIX.length).split('|');
|
|
38
|
+
if (slots.length !== SIM_CHARACTER_SLOTS.length)
|
|
39
|
+
return false;
|
|
40
|
+
return slots.every((slot, index) => (slot === EMPTY_SLOT ? index !== 0 : SLOT_PART_ID.test(slot)));
|
|
41
|
+
}
|
|
42
|
+
/** The value when it is a catalog id or a well-formed serialized character; otherwise null. */
|
|
43
|
+
export function parseSimCharacterValue(value) {
|
|
44
|
+
if (typeof value !== 'string')
|
|
45
|
+
return null;
|
|
46
|
+
if (value.startsWith('character:'))
|
|
47
|
+
return isSerializedSimCharacter(value) ? value : null;
|
|
48
|
+
return CATALOG_ID.test(value) ? value : null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export declare const SIM_SESSION_COOKIE_NAME = "__Host-adea-sim";
|
|
11
|
+
export declare const MAX_SIM_SESSION_SECONDS = 3600;
|
|
12
|
+
export declare function buildSimSessionSetCookie(value: string, maxAgeSeconds: number): string;
|
|
13
|
+
/** Expires the session on the client. */
|
|
14
|
+
export declare function buildSimSessionClearCookie(): string;
|
|
15
|
+
/** Every way a `Set-Cookie` header departs from the contract; empty means it conforms. */
|
|
16
|
+
export declare function simSessionCookieViolations(header: string): readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* The session value from a request's `Cookie` header, or null unless exactly one sim cookie of
|
|
19
|
+
* the right shape is present (a duplicate is refused rather than guessed between).
|
|
20
|
+
*/
|
|
21
|
+
export declare function readSimSessionCookie(cookieHeader: string | null | undefined): string | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
export const SIM_SESSION_COOKIE_NAME = '__Host-adea-sim';
|
|
11
|
+
export const MAX_SIM_SESSION_SECONDS = 3600;
|
|
12
|
+
const SESSION_VALUE = /^[A-Za-z0-9_-]{32,256}$/;
|
|
13
|
+
export function buildSimSessionSetCookie(value, maxAgeSeconds) {
|
|
14
|
+
if (!SESSION_VALUE.test(value))
|
|
15
|
+
throw new Error('Invalid Agent Sim session value');
|
|
16
|
+
return setCookie(value, boundedMaxAge(maxAgeSeconds));
|
|
17
|
+
}
|
|
18
|
+
/** Expires the session on the client. */
|
|
19
|
+
export function buildSimSessionClearCookie() {
|
|
20
|
+
return setCookie('', 0);
|
|
21
|
+
}
|
|
22
|
+
function boundedMaxAge(seconds) {
|
|
23
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_SIM_SESSION_SECONDS)
|
|
24
|
+
throw new Error('Agent Sim session lifetime out of range');
|
|
25
|
+
return seconds;
|
|
26
|
+
}
|
|
27
|
+
function setCookie(value, maxAge) {
|
|
28
|
+
return [
|
|
29
|
+
`${SIM_SESSION_COOKIE_NAME}=${value}`,
|
|
30
|
+
`Max-Age=${maxAge}`,
|
|
31
|
+
'Path=/',
|
|
32
|
+
'Secure',
|
|
33
|
+
'HttpOnly',
|
|
34
|
+
'SameSite=None',
|
|
35
|
+
'Partitioned',
|
|
36
|
+
].join('; ');
|
|
37
|
+
}
|
|
38
|
+
/** Every way a `Set-Cookie` header departs from the contract; empty means it conforms. */
|
|
39
|
+
export function simSessionCookieViolations(header) {
|
|
40
|
+
const [pair = '', ...attributes] = header.split(';').map((part) => part.trim());
|
|
41
|
+
const violations = [];
|
|
42
|
+
if (!pair.startsWith(`${SIM_SESSION_COOKIE_NAME}=`))
|
|
43
|
+
violations.push('name');
|
|
44
|
+
const names = new Map();
|
|
45
|
+
for (const attribute of attributes) {
|
|
46
|
+
const index = attribute.indexOf('=');
|
|
47
|
+
const key = (index === -1 ? attribute : attribute.slice(0, index)).toLowerCase();
|
|
48
|
+
if (names.has(key))
|
|
49
|
+
violations.push(`duplicate:${key}`);
|
|
50
|
+
names.set(key, index === -1 ? '' : attribute.slice(index + 1));
|
|
51
|
+
}
|
|
52
|
+
if (!names.has('secure'))
|
|
53
|
+
violations.push('secure');
|
|
54
|
+
if (!names.has('httponly'))
|
|
55
|
+
violations.push('httponly');
|
|
56
|
+
if (!names.has('partitioned'))
|
|
57
|
+
violations.push('partitioned');
|
|
58
|
+
if (names.get('path') !== '/')
|
|
59
|
+
violations.push('path');
|
|
60
|
+
if (names.has('domain'))
|
|
61
|
+
violations.push('domain');
|
|
62
|
+
if (names.get('samesite')?.toLowerCase() !== 'none')
|
|
63
|
+
violations.push('samesite');
|
|
64
|
+
const maxAge = names.get('max-age');
|
|
65
|
+
if (maxAge === undefined || !/^\d+$/.test(maxAge) || Number(maxAge) > MAX_SIM_SESSION_SECONDS)
|
|
66
|
+
violations.push('max-age');
|
|
67
|
+
if (names.has('expires'))
|
|
68
|
+
violations.push('expires');
|
|
69
|
+
return violations;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The session value from a request's `Cookie` header, or null unless exactly one sim cookie of
|
|
73
|
+
* the right shape is present (a duplicate is refused rather than guessed between).
|
|
74
|
+
*/
|
|
75
|
+
export function readSimSessionCookie(cookieHeader) {
|
|
76
|
+
if (!cookieHeader)
|
|
77
|
+
return null;
|
|
78
|
+
const values = cookieHeader
|
|
79
|
+
.split(';')
|
|
80
|
+
.map((part) => part.trim())
|
|
81
|
+
.filter((part) => part.startsWith(`${SIM_SESSION_COOKIE_NAME}=`))
|
|
82
|
+
.map((part) => part.slice(SIM_SESSION_COOKIE_NAME.length + 1));
|
|
83
|
+
const [only] = values;
|
|
84
|
+
return values.length === 1 && only !== undefined && SESSION_VALUE.test(only) ? only : null;
|
|
85
|
+
}
|