dsh-agent-team-ui-testing 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +172 -36
- package/annotation-capture.js +111 -0
- package/browser-safety.js +45 -1
- package/client.js +3721 -132
- package/context-menu.js +134 -0
- package/core/clock.js +88 -0
- package/core/config-store.js +566 -0
- package/core/config.js +892 -0
- package/core/contracts.js +240 -0
- package/core/errors.js +156 -0
- package/core/evidence.js +141 -0
- package/core/geometry.js +217 -0
- package/core/identity.js +180 -0
- package/core/index.js +24 -0
- package/core/p0.js +1101 -0
- package/core/readiness.js +175 -0
- package/core/registry.js +239 -0
- package/core/tab-access.js +1350 -0
- package/docs/adapters.md +160 -0
- package/docs/architecture.md +44 -9
- package/docs/capability-tiers.md +20 -4
- package/docs/capture-p0.md +39 -2
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +24 -6
- package/docs/getting-started.md +47 -1
- package/docs/images/tutorial-01-overview.png +0 -0
- package/docs/images/tutorial-02-history-dropdown.png +0 -0
- package/docs/images/tutorial-03-pick-element.png +0 -0
- package/docs/images/tutorial-04-annotate.png +0 -0
- package/docs/index.md +11 -7
- package/docs/multi-tab.md +55 -20
- package/docs/network-p0.md +2 -2
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +2 -2
- package/docs/t3-vision.md +6 -1
- package/docs/t4-dynamic-behavior.md +1 -0
- package/docs/tools.md +65 -7
- package/docs/tutorial.md +125 -0
- package/docs/verification.md +26 -2
- package/docs/viewport-control.md +21 -8
- package/index.js +3263 -251
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/network-p0.js +24 -7
- package/package.json +4 -3
- package/select-control.js +298 -0
- package/skills/ui-testing-common/SKILL.md +54 -0
- package/skills/ui-testing-fake/SKILL.md +43 -0
- package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
- package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
- package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
- package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
- package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
- package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
- package/targets/fake/adapter.js +1163 -0
- package/targets/fake/fixtures.js +177 -0
- package/targets/web/adapter.js +1231 -0
- package/targets/web/provider.js +415 -0
- package/skills/ui-testing/references/scenarios/annotations.md +0 -87
- /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
|
@@ -0,0 +1,1350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral page-access model: Principal, page-group keys, operation effects and the
|
|
3
|
+
* ownership/control state machine (M1 of the team-tab ownership plan).
|
|
4
|
+
*
|
|
5
|
+
* Why this module exists
|
|
6
|
+
* - A page may only be authorised against identity the plugin can actually prove, and that proof
|
|
7
|
+
* lives in the Host bridge (`integration/tab-principal.js`), not here. This file is the pure,
|
|
8
|
+
* side-effect-free half: given an *already proven* Principal plus the stored ownership/control
|
|
9
|
+
* records it answers "may this actor do this, here, at this version?" — and it never guesses.
|
|
10
|
+
* - Every rule below is default-deny. An unknown operation, an unknown sub-action, an unknown
|
|
11
|
+
* effect or an unknown role is a refusal with a machine code, never a silent pass. A permission
|
|
12
|
+
* table that fails open on a typo is worse than no table at all, because the typo is invisible.
|
|
13
|
+
* - Nothing in this file talks to a Host, Cordis, the filesystem, a socket or a platform API: the
|
|
14
|
+
* contract tests import it directly and a browser must not be required to prove authorisation
|
|
15
|
+
* semantics. See docs/internal/m0-rulings-20261109.md §7.1-§7.4 (the frozen contract) and
|
|
16
|
+
* docs/internal/team-tab-ownership-concurrency-plan-20261109.md §3-§5.
|
|
17
|
+
*
|
|
18
|
+
* Unforgeability has two layers, and they are deliberately different:
|
|
19
|
+
* - `makePrincipal` brands the object with a module-private Symbol. A Principal that travelled
|
|
20
|
+
* through JSON (HTTP body, tool argument, WS query, product metadata) loses the brand and
|
|
21
|
+
* `assertPrincipal` refuses it. Transmission layers therefore cannot mint "I am the Lead".
|
|
22
|
+
* - The brand does *not* prove the fields are true: only the resolver may call `makePrincipal`,
|
|
23
|
+
* and it must have read the fields from the native registry/membership first. This module
|
|
24
|
+
* trusts the resolver for provenance and checks everything downstream of it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ENVIRONMENTS, ERROR_CODES, WEB_PROVIDER_SUPPORT } from './contracts.js';
|
|
28
|
+
import { AdapterError } from './errors.js';
|
|
29
|
+
|
|
30
|
+
/** Principal origins. Only these three entry points exist; HTTP/tool parameters are none of them. */
|
|
31
|
+
export const PRINCIPAL_SOURCE = Object.freeze({
|
|
32
|
+
AGENT: 'agent',
|
|
33
|
+
PANEL: 'panel',
|
|
34
|
+
SYSTEM: 'system',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Principal roles.
|
|
39
|
+
*
|
|
40
|
+
* `lead` and `teammate` are native Team roles, accepted as-is. `standalone` is *not* "we could not
|
|
41
|
+
* find a team": it is a host-proved independent domain whose root is the actor itself, so it can
|
|
42
|
+
* only ever reach its own pages. `undefined` membership never degrades into `standalone` — that
|
|
43
|
+
* degrading is exactly how a stale object would self-promote into a private team of one.
|
|
44
|
+
*/
|
|
45
|
+
export const PRINCIPAL_ROLE = Object.freeze({
|
|
46
|
+
LEAD: 'lead',
|
|
47
|
+
TEAMMATE: 'teammate',
|
|
48
|
+
STANDALONE: 'standalone',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Long-lived page control states (plan §5). Short leases are a different concept and stay out. */
|
|
52
|
+
export const CONTROL_STATE = Object.freeze({
|
|
53
|
+
OWNED: 'owned',
|
|
54
|
+
DRAINING: 'draining',
|
|
55
|
+
LEAD_CONTROLLED: 'lead-controlled',
|
|
56
|
+
RETIRED: 'retired',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** What a call is allowed to do. `callerLocal` is "only my own bookkeeping", never a page write. */
|
|
60
|
+
export const EFFECT = Object.freeze({
|
|
61
|
+
OBSERVE: 'observe',
|
|
62
|
+
WRITE: 'write',
|
|
63
|
+
MANAGE: 'manage',
|
|
64
|
+
CALLER_LOCAL: 'callerLocal',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** What the effect acts on. Config scope and access domain stay separate on purpose. */
|
|
68
|
+
export const RESOURCE = Object.freeze({
|
|
69
|
+
PAGE: 'page',
|
|
70
|
+
COLLECTOR: 'collector',
|
|
71
|
+
ARTIFACT: 'artifact',
|
|
72
|
+
HOST: 'host',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Control-graph events (plan §5.1). Every other name is an unknown event. */
|
|
76
|
+
export const CONTROL_EVENT = Object.freeze({
|
|
77
|
+
ACQUIRE: 'acquire',
|
|
78
|
+
ACQUIRE_COMMIT: 'acquire-commit',
|
|
79
|
+
ACQUIRE_CANCEL: 'acquire-cancel',
|
|
80
|
+
RELEASE: 'release',
|
|
81
|
+
TRANSFER: 'transfer',
|
|
82
|
+
RETIRE: 'retire',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Machine-readable denial reasons. Callers branch on `reason`; the human code is a detail.
|
|
87
|
+
*
|
|
88
|
+
* `CROSS_ROOT` deliberately carries **no** error code and no details: whether a foreign page
|
|
89
|
+
* exists at all is information the caller has no right to. The caller maps it to whatever uniform
|
|
90
|
+
* "not found" its surface uses (ruling §7.3), so the model never gets to leak existence by
|
|
91
|
+
* choosing a code that only one of the two states would produce.
|
|
92
|
+
*/
|
|
93
|
+
export const AUTHORIZE_REASON = Object.freeze({
|
|
94
|
+
ALLOWED: 'allowed',
|
|
95
|
+
CROSS_ROOT: 'cross-root',
|
|
96
|
+
IDENTITY_UNVERIFIED: 'identity-unverified',
|
|
97
|
+
ROLE_FORBIDDEN: 'role-forbidden',
|
|
98
|
+
NOT_CONTROLLER: 'not-controller',
|
|
99
|
+
CONTROL_DRAINING: 'control-draining',
|
|
100
|
+
TARGET_RETIRED: 'target-retired',
|
|
101
|
+
CONTROL_REVISION_STALE: 'control-revision-stale',
|
|
102
|
+
OWNERSHIP_REVISION_STALE: 'ownership-revision-stale',
|
|
103
|
+
HOST_MANAGE_FORBIDDEN: 'host-manage-forbidden',
|
|
104
|
+
TARGET_UNRESOLVED: 'target-unresolved',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** Versioned key prefixes. One prefix per key space so a composed key can never be mistaken. */
|
|
108
|
+
export const PAGE_GROUP_KEY_PREFIX = 'page-group:v1:';
|
|
109
|
+
export const PAGE_TARGET_KEY_PREFIX = 'page-target:v1:';
|
|
110
|
+
export const ACTOR_SELECTION_KEY_PREFIX = 'actor-selection:v1:';
|
|
111
|
+
|
|
112
|
+
const COMPOSED_KEY_PREFIXES = Object.freeze([
|
|
113
|
+
PAGE_GROUP_KEY_PREFIX,
|
|
114
|
+
PAGE_TARGET_KEY_PREFIX,
|
|
115
|
+
ACTOR_SELECTION_KEY_PREFIX,
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const KNOWN_ENVIRONMENTS = new Set(Object.values(ENVIRONMENTS));
|
|
119
|
+
// A *known* provider is a key of the one support table; a *usable* one is a separate question that
|
|
120
|
+
// this module must not re-answer (contracts.js: "never add a second 'is edge usable?' judgement").
|
|
121
|
+
const KNOWN_PROVIDERS = new Set(Object.keys(WEB_PROVIDER_SUPPORT));
|
|
122
|
+
const KNOWN_EFFECTS = new Set(Object.values(EFFECT));
|
|
123
|
+
const KNOWN_RESOURCES = new Set(Object.values(RESOURCE));
|
|
124
|
+
const KNOWN_SOURCES = new Set(Object.values(PRINCIPAL_SOURCE));
|
|
125
|
+
const KNOWN_ROLES = new Set(Object.values(PRINCIPAL_ROLE));
|
|
126
|
+
const KNOWN_CONTROL_STATES = new Set(Object.values(CONTROL_STATE));
|
|
127
|
+
const KNOWN_EVENTS = new Set(Object.values(CONTROL_EVENT));
|
|
128
|
+
|
|
129
|
+
function isPlainObject(value) {
|
|
130
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isNonEmptyString(value) {
|
|
134
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isRevision(value) {
|
|
138
|
+
return Number.isInteger(value) && value >= 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function invalid(message, details) {
|
|
142
|
+
return new AdapterError(ERROR_CODES.INVALID_ARGUMENT, message, { details });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Reject anything that is not a plain non-blank string; ids are never coerced. */
|
|
146
|
+
function requireIdentifier(value, field) {
|
|
147
|
+
if (!isNonEmptyString(value)) {
|
|
148
|
+
throw invalid(`${field} must be a non-empty string`, { field });
|
|
149
|
+
}
|
|
150
|
+
if (value !== value.trim()) {
|
|
151
|
+
throw invalid(`${field} must not carry surrounding whitespace`, { field });
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reject an id that already *is* a composed key.
|
|
158
|
+
*
|
|
159
|
+
* The ruling freezes "one factory per key space, never re-compose a composed key as a base". A
|
|
160
|
+
* `workspaceKey` that starts with a key prefix means a caller skipped the factory and is about to
|
|
161
|
+
* build a key whose root dimension is silently wrong — that is a cross-root aliasing bug waiting
|
|
162
|
+
* to happen, so it is refused here instead of being decoded later.
|
|
163
|
+
*/
|
|
164
|
+
function requireRawDimension(value, field) {
|
|
165
|
+
requireIdentifier(value, field);
|
|
166
|
+
for (const prefix of COMPOSED_KEY_PREFIXES) {
|
|
167
|
+
if (value.startsWith(prefix)) {
|
|
168
|
+
throw invalid(`${field} is a composed key, not a raw dimension`, { field, prefix });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ----------------------------------------------------------------------------------------------- *
|
|
175
|
+
* Principal
|
|
176
|
+
* ----------------------------------------------------------------------------------------------- */
|
|
177
|
+
|
|
178
|
+
const PRINCIPAL_BRAND = Symbol('tab-access.principal');
|
|
179
|
+
|
|
180
|
+
const PRINCIPAL_FIELDS = Object.freeze([
|
|
181
|
+
'actorSessionId',
|
|
182
|
+
'rootSessionId',
|
|
183
|
+
'role',
|
|
184
|
+
'membershipRevision',
|
|
185
|
+
'executionEpoch',
|
|
186
|
+
'source',
|
|
187
|
+
'hostEpoch',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate Principal field values without caring where they came from.
|
|
192
|
+
*
|
|
193
|
+
* Shape checks only: this is the layer below the resolver, so "is this really that Session" is
|
|
194
|
+
* already answered; here we guarantee the *type* invariants every rule downstream relies on (no
|
|
195
|
+
* empty ids, no non-integer versions, no invented role).
|
|
196
|
+
*/
|
|
197
|
+
function validatePrincipalFields(input) {
|
|
198
|
+
if (!isPlainObject(input)) {
|
|
199
|
+
throw invalid('principal must be a plain object', { field: 'principal' });
|
|
200
|
+
}
|
|
201
|
+
const unexpected = Object.keys(input).filter((key) => !PRINCIPAL_FIELDS.includes(key));
|
|
202
|
+
if (unexpected.length > 0) {
|
|
203
|
+
// Extra fields are not harmless: `role`/`rootSessionId` smuggled in from a request body would
|
|
204
|
+
// sit next to the real ones, and a later `{...principal, ...args}` spread would let them win.
|
|
205
|
+
throw invalid('principal carries unknown fields', { fields: unexpected.sort() });
|
|
206
|
+
}
|
|
207
|
+
if (!KNOWN_SOURCES.has(input.source)) {
|
|
208
|
+
throw invalid(`unknown principal source: ${String(input.source)}`, { field: 'source' });
|
|
209
|
+
}
|
|
210
|
+
if (!KNOWN_ROLES.has(input.role)) {
|
|
211
|
+
throw invalid(`unknown principal role: ${String(input.role)}`, { field: 'role' });
|
|
212
|
+
}
|
|
213
|
+
const actorSessionId = requireIdentifier(input.actorSessionId, 'actorSessionId');
|
|
214
|
+
const rootSessionId = requireIdentifier(input.rootSessionId, 'rootSessionId');
|
|
215
|
+
if (!isRevision(input.membershipRevision)) {
|
|
216
|
+
throw invalid('membershipRevision must be an integer >= 1', { field: 'membershipRevision' });
|
|
217
|
+
}
|
|
218
|
+
if (!isRevision(input.executionEpoch)) {
|
|
219
|
+
throw invalid('executionEpoch must be an integer >= 1', { field: 'executionEpoch' });
|
|
220
|
+
}
|
|
221
|
+
const hostEpoch = requireIdentifier(input.hostEpoch, 'hostEpoch');
|
|
222
|
+
if (input.role === PRINCIPAL_ROLE.STANDALONE && rootSessionId !== actorSessionId) {
|
|
223
|
+
// An independent domain cannot *represent* another root: that is the shape of a self-promoted
|
|
224
|
+
// principal, so it is refused even before any page is looked at.
|
|
225
|
+
throw invalid('standalone principal must be its own root', {
|
|
226
|
+
field: 'rootSessionId',
|
|
227
|
+
reason: 'standalone-root-mismatch',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
actorSessionId,
|
|
232
|
+
rootSessionId,
|
|
233
|
+
role: input.role,
|
|
234
|
+
membershipRevision: input.membershipRevision,
|
|
235
|
+
executionEpoch: input.executionEpoch,
|
|
236
|
+
source: input.source,
|
|
237
|
+
hostEpoch,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build a Principal. **Only the trusted resolver may call this** (`integration/tab-principal.js`).
|
|
243
|
+
*
|
|
244
|
+
* The result is frozen and branded. Frozen because every rule compares versions from it and a
|
|
245
|
+
* mid-call mutation would let two different identities share one call; branded because a JSON
|
|
246
|
+
* clone has to be refused (ruling §7.1: "对象注册于私有 resolver,跨层拒绝未登记对象").
|
|
247
|
+
*
|
|
248
|
+
* @param {{actorSessionId:string, rootSessionId:string, role:string, membershipRevision:number,
|
|
249
|
+
* executionEpoch:number, source:string, hostEpoch:string}} input
|
|
250
|
+
* @returns {Readonly<object>} branded principal
|
|
251
|
+
*/
|
|
252
|
+
export function makePrincipal(input) {
|
|
253
|
+
const fields = validatePrincipalFields(input);
|
|
254
|
+
const principal = { ...fields };
|
|
255
|
+
Object.defineProperty(principal, PRINCIPAL_BRAND, { value: true, enumerable: false });
|
|
256
|
+
return Object.freeze(principal);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Assert that a value is a resolver-issued Principal and return it.
|
|
261
|
+
*
|
|
262
|
+
* @param {unknown} value
|
|
263
|
+
* @param {{code?:string}} [options] `code` lets the caller keep a more specific surface code
|
|
264
|
+
* (e.g. TAB_IDENTITY_UNAVAILABLE on a page tool) while sharing this one check.
|
|
265
|
+
* @returns {Readonly<object>} the same principal
|
|
266
|
+
*/
|
|
267
|
+
export function assertPrincipal(value, { code = ERROR_CODES.INVALID_ARGUMENT } = {}) {
|
|
268
|
+
if (!isPlainObject(value) || value[PRINCIPAL_BRAND] !== true) {
|
|
269
|
+
throw new AdapterError(code, 'principal was not issued by the trusted resolver', {
|
|
270
|
+
details: { reason: 'unregistered-principal' },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// Re-validate the fields: the brand proves *origin*, not that a hostile layer did not rebuild an
|
|
274
|
+
// object around it. Cheap, and it keeps a single description of a valid principal.
|
|
275
|
+
validatePrincipalFields(value);
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Non-throwing form of {@link assertPrincipal}; used by `authorize` to deny instead of throw. */
|
|
280
|
+
export function isPrincipal(value) {
|
|
281
|
+
try {
|
|
282
|
+
assertPrincipal(value);
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ----------------------------------------------------------------------------------------------- *
|
|
290
|
+
* Page-group / target / selection keys (ruling §7.4)
|
|
291
|
+
* ----------------------------------------------------------------------------------------------- */
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The four dimensions of one physical page group. The root is part of the key, which is the whole
|
|
295
|
+
* point: two roots sharing a workspace/provider must never be able to address each other's primary.
|
|
296
|
+
*
|
|
297
|
+
* @param {{environment?:string, workspaceKey:string, provider:string, rootSessionId:string}} input
|
|
298
|
+
* @returns {string} `page-group:v1:` + JSON array (no hand-built separators, no ambiguity)
|
|
299
|
+
*/
|
|
300
|
+
export function makePageGroupKey(input) {
|
|
301
|
+
if (!isPlainObject(input)) {
|
|
302
|
+
throw invalid('page group identity must be a plain object', { field: 'input' });
|
|
303
|
+
}
|
|
304
|
+
const environment = requireIdentifier(input.environment, 'environment');
|
|
305
|
+
if (!KNOWN_ENVIRONMENTS.has(environment)) {
|
|
306
|
+
throw invalid(`unknown environment: ${environment}`, { field: 'environment' });
|
|
307
|
+
}
|
|
308
|
+
const workspaceKey = requireRawDimension(input.workspaceKey, 'workspaceKey');
|
|
309
|
+
const provider = requireIdentifier(input.provider, 'provider');
|
|
310
|
+
if (!KNOWN_PROVIDERS.has(provider)) {
|
|
311
|
+
throw invalid(`unknown provider: ${provider}`, { field: 'provider' });
|
|
312
|
+
}
|
|
313
|
+
const rootSessionId = requireRawDimension(input.rootSessionId, 'rootSessionId');
|
|
314
|
+
return PAGE_GROUP_KEY_PREFIX + JSON.stringify([environment, workspaceKey, provider, rootSessionId]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Canonical identity of one physical target inside a group.
|
|
319
|
+
*
|
|
320
|
+
* @param {{groupKey:string, tabId:string}} input
|
|
321
|
+
* @returns {string} `page-target:v1:` + JSON array. Never re-composed from a composed base key.
|
|
322
|
+
*/
|
|
323
|
+
export function makePageTargetKey(input) {
|
|
324
|
+
if (!isPlainObject(input)) {
|
|
325
|
+
throw invalid('page target identity must be a plain object', { field: 'input' });
|
|
326
|
+
}
|
|
327
|
+
const groupKey = requireIdentifier(input.groupKey, 'groupKey');
|
|
328
|
+
if (!groupKey.startsWith(PAGE_GROUP_KEY_PREFIX)) {
|
|
329
|
+
throw invalid('groupKey must come from makePageGroupKey', { field: 'groupKey' });
|
|
330
|
+
}
|
|
331
|
+
parsePageGroupKey(groupKey);
|
|
332
|
+
const tabId = requireRawDimension(input.tabId, 'tabId');
|
|
333
|
+
return PAGE_TARGET_KEY_PREFIX + JSON.stringify([groupKey, tabId]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Key of one actor's own selection inside a group.
|
|
338
|
+
*
|
|
339
|
+
* Selections are per actor *and* per root, so "recent" can no longer be one global value and a
|
|
340
|
+
* selection can never be inherited across roots.
|
|
341
|
+
*
|
|
342
|
+
* @param {{groupKey:string, actorSessionId:string}} input
|
|
343
|
+
* @returns {string} `actor-selection:v1:` + JSON array
|
|
344
|
+
*/
|
|
345
|
+
export function makeActorSelectionKey(input) {
|
|
346
|
+
if (!isPlainObject(input)) {
|
|
347
|
+
throw invalid('actor selection identity must be a plain object', { field: 'input' });
|
|
348
|
+
}
|
|
349
|
+
const groupKey = requireIdentifier(input.groupKey, 'groupKey');
|
|
350
|
+
if (!groupKey.startsWith(PAGE_GROUP_KEY_PREFIX)) {
|
|
351
|
+
throw invalid('groupKey must come from makePageGroupKey', { field: 'groupKey' });
|
|
352
|
+
}
|
|
353
|
+
parsePageGroupKey(groupKey);
|
|
354
|
+
const actorSessionId = requireRawDimension(input.actorSessionId, 'actorSessionId');
|
|
355
|
+
return ACTOR_SELECTION_KEY_PREFIX + JSON.stringify([groupKey, actorSessionId]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Decode a group key back into its dimensions.
|
|
360
|
+
*
|
|
361
|
+
* Needed by the exact-binding lookup (runtime id -> entry) which must re-check the Principal root
|
|
362
|
+
* and provider before returning anything; a decode failure means a key was not produced by the
|
|
363
|
+
* factory, which is a programming error rather than a caller error.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} key
|
|
366
|
+
* @returns {{environment:string, workspaceKey:string, provider:string, rootSessionId:string}}
|
|
367
|
+
* @throws {AdapterError} INVALID_ARGUMENT when the key did not come from `makePageGroupKey`
|
|
368
|
+
*/
|
|
369
|
+
export function parsePageGroupKey(key) {
|
|
370
|
+
if (typeof key !== 'string' || !key.startsWith(PAGE_GROUP_KEY_PREFIX)) {
|
|
371
|
+
throw invalid('group key was not produced by makePageGroupKey', { field: 'groupKey' });
|
|
372
|
+
}
|
|
373
|
+
let parts;
|
|
374
|
+
try {
|
|
375
|
+
parts = JSON.parse(key.slice(PAGE_GROUP_KEY_PREFIX.length));
|
|
376
|
+
} catch {
|
|
377
|
+
throw invalid('group key payload is not JSON', { field: 'groupKey' });
|
|
378
|
+
}
|
|
379
|
+
if (!Array.isArray(parts) || parts.length !== 4 || parts.some((part) => !isNonEmptyString(part))) {
|
|
380
|
+
throw invalid('group key payload must be four non-empty strings', { field: 'groupKey' });
|
|
381
|
+
}
|
|
382
|
+
const [environment, workspaceKey, provider, rootSessionId] = parts;
|
|
383
|
+
return { environment, workspaceKey, provider, rootSessionId };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ----------------------------------------------------------------------------------------------- *
|
|
387
|
+
* Ownership and control records
|
|
388
|
+
* ----------------------------------------------------------------------------------------------- */
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Long-lived ownership of one page (plan §3.1). Creator is audit-only and survives a transfer;
|
|
392
|
+
* `ownershipRevision` moves *only* when the owner actually changes.
|
|
393
|
+
*
|
|
394
|
+
* @param {{rootSessionId:string, creatorSessionId:string, ownerSessionId:string,
|
|
395
|
+
* ownershipRevision?:number}} input
|
|
396
|
+
* @returns {Readonly<object>}
|
|
397
|
+
*/
|
|
398
|
+
export function makeOwnership(input) {
|
|
399
|
+
if (!isPlainObject(input)) throw invalid('ownership must be a plain object', { field: 'input' });
|
|
400
|
+
const ownershipRevision = input.ownershipRevision ?? 1;
|
|
401
|
+
if (!isRevision(ownershipRevision)) {
|
|
402
|
+
throw invalid('ownershipRevision must be an integer >= 1', { field: 'ownershipRevision' });
|
|
403
|
+
}
|
|
404
|
+
return Object.freeze({
|
|
405
|
+
rootSessionId: requireIdentifier(input.rootSessionId, 'rootSessionId'),
|
|
406
|
+
creatorSessionId: requireIdentifier(input.creatorSessionId, 'creatorSessionId'),
|
|
407
|
+
ownerSessionId: requireIdentifier(input.ownerSessionId, 'ownerSessionId'),
|
|
408
|
+
ownershipRevision,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Control record of one page (plan §3.1/§5.1).
|
|
414
|
+
*
|
|
415
|
+
* `pendingControllerSessionId` exists only while a takeover is draining: the older write path must
|
|
416
|
+
* already see a blocked gate, while the controller itself has *not* changed yet (the ruling keeps
|
|
417
|
+
* the controller stable until the in-flight work reaches a safe boundary, so a timeout or a cancel
|
|
418
|
+
* leaves no silently-moved controller behind).
|
|
419
|
+
*
|
|
420
|
+
* @param {{state?:string, controllerSessionId:string|null, controlRevision?:number,
|
|
421
|
+
* pendingControllerSessionId?:string|null}} input
|
|
422
|
+
* @returns {Readonly<object>}
|
|
423
|
+
*/
|
|
424
|
+
export function makeControl(input) {
|
|
425
|
+
if (!isPlainObject(input)) throw invalid('control must be a plain object', { field: 'input' });
|
|
426
|
+
const state = input.state ?? CONTROL_STATE.OWNED;
|
|
427
|
+
if (!KNOWN_CONTROL_STATES.has(state)) {
|
|
428
|
+
throw invalid(`unknown control state: ${String(state)}`, { field: 'state' });
|
|
429
|
+
}
|
|
430
|
+
const controlRevision = input.controlRevision ?? 1;
|
|
431
|
+
if (!isRevision(controlRevision)) {
|
|
432
|
+
throw invalid('controlRevision must be an integer >= 1', { field: 'controlRevision' });
|
|
433
|
+
}
|
|
434
|
+
// A retired record has no writer at all; every other state names exactly one.
|
|
435
|
+
const controllerSessionId = state === CONTROL_STATE.RETIRED
|
|
436
|
+
? null
|
|
437
|
+
: requireIdentifier(input.controllerSessionId, 'controllerSessionId');
|
|
438
|
+
const pending = input.pendingControllerSessionId ?? null;
|
|
439
|
+
if (pending !== null && state !== CONTROL_STATE.DRAINING) {
|
|
440
|
+
throw invalid('only a draining control record may carry a pending controller', {
|
|
441
|
+
field: 'pendingControllerSessionId',
|
|
442
|
+
state,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return Object.freeze({
|
|
446
|
+
state,
|
|
447
|
+
controllerSessionId,
|
|
448
|
+
controlRevision,
|
|
449
|
+
pendingControllerSessionId: pending === null ? null : requireIdentifier(pending, 'pendingControllerSessionId'),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Fresh record for a page that just received ownership: owner controls it, revisions start at 1.
|
|
455
|
+
*
|
|
456
|
+
* @param {{rootSessionId:string, creatorSessionId:string, ownerSessionId?:string}} input
|
|
457
|
+
* @returns {{ownership:Readonly<object>, control:Readonly<object>}}
|
|
458
|
+
*/
|
|
459
|
+
export function makePageAccess(input) {
|
|
460
|
+
if (!isPlainObject(input)) throw invalid('page access must be a plain object', { field: 'input' });
|
|
461
|
+
const ownerSessionId = input.ownerSessionId ?? input.creatorSessionId;
|
|
462
|
+
return {
|
|
463
|
+
ownership: makeOwnership({
|
|
464
|
+
rootSessionId: input.rootSessionId,
|
|
465
|
+
creatorSessionId: input.creatorSessionId,
|
|
466
|
+
ownerSessionId,
|
|
467
|
+
}),
|
|
468
|
+
control: makeControl({ state: CONTROL_STATE.OWNED, controllerSessionId: ownerSessionId }),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function controlChanged(message, details) {
|
|
473
|
+
return new AdapterError(ERROR_CODES.TAB_CONTROL_CHANGED, message, { details });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function crossRootRefusal(field) {
|
|
477
|
+
// Uniform, detail-free "not found": a transition must not tell a foreign root whether the page
|
|
478
|
+
// exists (ruling §7.3 keeps existence hidden; the caller maps this to its own not-found shape).
|
|
479
|
+
return new AdapterError(ERROR_CODES.TAB_NOT_FOUND, 'page is not addressable from this principal', {
|
|
480
|
+
details: { hidden: true, field },
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Throw the uniform cross-root refusal unless the principal owns this root. */
|
|
485
|
+
function requireSameRoot(principal, rootSessionId) {
|
|
486
|
+
if (principal.rootSessionId !== rootSessionId) throw crossRootRefusal('rootSessionId');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function requireRevision(actual, expected, field) {
|
|
490
|
+
if (expected === undefined || expected === null) return;
|
|
491
|
+
if (!isRevision(expected)) {
|
|
492
|
+
throw invalid(`${field} must be an integer >= 1`, { field });
|
|
493
|
+
}
|
|
494
|
+
if (expected !== actual) {
|
|
495
|
+
const code = field === 'expectedOwnershipRevision'
|
|
496
|
+
? ERROR_CODES.TAB_OWNERSHIP_STALE
|
|
497
|
+
: ERROR_CODES.TAB_CONTROL_CHANGED;
|
|
498
|
+
throw new AdapterError(code, `${field} mismatch: expected ${expected}, current ${actual}`, {
|
|
499
|
+
details: { expected, current: actual, field },
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Apply one control-graph event, returning new records.
|
|
506
|
+
*
|
|
507
|
+
* Order of checks is the contract, not an implementation detail: **root first, role second,
|
|
508
|
+
* versions last** (plan §3.3). Checking a version before the root would answer "wrong revision" to
|
|
509
|
+
* a stranger, which confirms the page exists. A cross-root caller gets the same answer whether or
|
|
510
|
+
* not the page is there.
|
|
511
|
+
*
|
|
512
|
+
* Version rules (ruling §7.1 / plan §5.1) — only these bump, and never down:
|
|
513
|
+
* - acquire : owned -> draining, revision unchanged (nothing moved yet)
|
|
514
|
+
* - acquire-commit : draining -> lead-controlled, controlRevision + 1
|
|
515
|
+
* - acquire-cancel : draining -> owned, revision unchanged (the controller never moved)
|
|
516
|
+
* - release : lead-controlled -> owned, controlRevision + 1
|
|
517
|
+
* - transfer : owner/controller -> to, ownershipRevision + 1 **and** controlRevision + 1
|
|
518
|
+
* - retire : any live state -> retired, controlRevision + 1
|
|
519
|
+
*
|
|
520
|
+
* An already-satisfied request at the *same* current revision reports `changed: false` and writes
|
|
521
|
+
* nothing (ruling §5: "相同当前版本的 acquire/release/transfer 返回 changed:false,不加版本").
|
|
522
|
+
*
|
|
523
|
+
* @param {{ownership:object, control:object}} record
|
|
524
|
+
* @param {{type:string, principal:object, expectedControlRevision?:number,
|
|
525
|
+
* expectedOwnershipRevision?:number, toSessionId?:string}} event
|
|
526
|
+
* @returns {{ownership:object, control:object, changed:boolean}}
|
|
527
|
+
* @throws {AdapterError} TAB_NOT_FOUND (cross root), TAB_FORBIDDEN (role), TAB_CONTROL_CHANGED
|
|
528
|
+
* (state or control-revision), TAB_OWNERSHIP_STALE (ownership-revision), INVALID_ARGUMENT
|
|
529
|
+
*/
|
|
530
|
+
export function applyControlTransition({ ownership, control } = {}, event = {}) {
|
|
531
|
+
const own = makeOwnership(ownership ?? {});
|
|
532
|
+
const ctl = makeControl(control ?? {});
|
|
533
|
+
if (!isPlainObject(event)) throw invalid('control event must be a plain object', { field: 'event' });
|
|
534
|
+
const { type } = event;
|
|
535
|
+
if (!KNOWN_EVENTS.has(type)) {
|
|
536
|
+
throw invalid(`unknown control event: ${String(type)}`, { field: 'type' });
|
|
537
|
+
}
|
|
538
|
+
const principal = assertPrincipal(event.principal, { code: ERROR_CODES.TAB_IDENTITY_UNAVAILABLE });
|
|
539
|
+
|
|
540
|
+
// 1. root
|
|
541
|
+
requireSameRoot(principal, own.rootSessionId);
|
|
542
|
+
|
|
543
|
+
const actor = principal.actorSessionId;
|
|
544
|
+
const isLead = principal.role === PRINCIPAL_ROLE.LEAD;
|
|
545
|
+
const leadOnly = () => {
|
|
546
|
+
if (!isLead) {
|
|
547
|
+
throw new AdapterError(ERROR_CODES.TAB_FORBIDDEN, 'only this root Lead may manage control', {
|
|
548
|
+
details: { reason: AUTHORIZE_REASON.ROLE_FORBIDDEN, event: type, role: principal.role },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
const retiredRefusal = () => {
|
|
553
|
+
if (ctl.state === CONTROL_STATE.RETIRED) {
|
|
554
|
+
throw new AdapterError(ERROR_CODES.TARGET_GONE, 'page is retired', {
|
|
555
|
+
details: { reason: AUTHORIZE_REASON.TARGET_RETIRED, event: type },
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const commit = (nextOwn, nextCtl, changed = true) => ({
|
|
560
|
+
ownership: makeOwnership(nextOwn),
|
|
561
|
+
control: makeControl(nextCtl),
|
|
562
|
+
changed,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
switch (type) {
|
|
566
|
+
case CONTROL_EVENT.ACQUIRE: {
|
|
567
|
+
// 2. role
|
|
568
|
+
leadOnly();
|
|
569
|
+
retiredRefusal();
|
|
570
|
+
// 3. version, then state
|
|
571
|
+
requireRevision(ctl.controlRevision, event.expectedControlRevision, 'expectedControlRevision');
|
|
572
|
+
if (ctl.state === CONTROL_STATE.LEAD_CONTROLLED && ctl.controllerSessionId === actor) {
|
|
573
|
+
return commit(own, ctl, false);
|
|
574
|
+
}
|
|
575
|
+
if (ctl.state !== CONTROL_STATE.OWNED) {
|
|
576
|
+
throw controlChanged('page is not in a state that can be drained', {
|
|
577
|
+
reason: AUTHORIZE_REASON.CONTROL_DRAINING,
|
|
578
|
+
state: ctl.state,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return commit(own, {
|
|
582
|
+
state: CONTROL_STATE.DRAINING,
|
|
583
|
+
controllerSessionId: ctl.controllerSessionId,
|
|
584
|
+
controlRevision: ctl.controlRevision,
|
|
585
|
+
pendingControllerSessionId: actor,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
case CONTROL_EVENT.ACQUIRE_COMMIT: {
|
|
589
|
+
leadOnly();
|
|
590
|
+
retiredRefusal();
|
|
591
|
+
requireRevision(ctl.controlRevision, event.expectedControlRevision, 'expectedControlRevision');
|
|
592
|
+
if (ctl.state === CONTROL_STATE.LEAD_CONTROLLED && ctl.controllerSessionId === actor) {
|
|
593
|
+
return commit(own, ctl, false);
|
|
594
|
+
}
|
|
595
|
+
if (ctl.state !== CONTROL_STATE.DRAINING) {
|
|
596
|
+
throw controlChanged('no takeover is draining on this page', {
|
|
597
|
+
reason: AUTHORIZE_REASON.CONTROL_DRAINING,
|
|
598
|
+
state: ctl.state,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (ctl.pendingControllerSessionId !== actor) {
|
|
602
|
+
// Someone else's drain: committing it would hand this actor control it never asked for.
|
|
603
|
+
throw controlChanged('this takeover belongs to another actor', {
|
|
604
|
+
reason: AUTHORIZE_REASON.NOT_CONTROLLER,
|
|
605
|
+
pendingControllerSessionId: ctl.pendingControllerSessionId,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return commit(own, {
|
|
609
|
+
state: CONTROL_STATE.LEAD_CONTROLLED,
|
|
610
|
+
controllerSessionId: actor,
|
|
611
|
+
controlRevision: ctl.controlRevision + 1,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
case CONTROL_EVENT.ACQUIRE_CANCEL: {
|
|
615
|
+
leadOnly();
|
|
616
|
+
if (ctl.state !== CONTROL_STATE.DRAINING) {
|
|
617
|
+
return commit(own, ctl, false);
|
|
618
|
+
}
|
|
619
|
+
if (ctl.pendingControllerSessionId !== actor) {
|
|
620
|
+
throw controlChanged('this takeover belongs to another actor', {
|
|
621
|
+
reason: AUTHORIZE_REASON.NOT_CONTROLLER,
|
|
622
|
+
pendingControllerSessionId: ctl.pendingControllerSessionId,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
// The controller never moved, so no version moves either: a cancelled takeover must leave a
|
|
626
|
+
// record byte-identical to the one before it, otherwise a retry would suddenly see "stale".
|
|
627
|
+
return commit(own, {
|
|
628
|
+
state: CONTROL_STATE.OWNED,
|
|
629
|
+
controllerSessionId: own.ownerSessionId,
|
|
630
|
+
controlRevision: ctl.controlRevision,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
case CONTROL_EVENT.RELEASE: {
|
|
634
|
+
leadOnly();
|
|
635
|
+
retiredRefusal();
|
|
636
|
+
requireRevision(ctl.controlRevision, event.expectedControlRevision, 'expectedControlRevision');
|
|
637
|
+
if (ctl.state === CONTROL_STATE.OWNED && ctl.controllerSessionId === own.ownerSessionId) {
|
|
638
|
+
return commit(own, ctl, false);
|
|
639
|
+
}
|
|
640
|
+
if (ctl.state !== CONTROL_STATE.LEAD_CONTROLLED) {
|
|
641
|
+
throw controlChanged('only a Lead-controlled page can be released', {
|
|
642
|
+
reason: AUTHORIZE_REASON.CONTROL_DRAINING,
|
|
643
|
+
state: ctl.state,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (ctl.controllerSessionId !== actor) {
|
|
647
|
+
throw controlChanged('this actor is not the current controller', {
|
|
648
|
+
reason: AUTHORIZE_REASON.NOT_CONTROLLER,
|
|
649
|
+
controllerSessionId: ctl.controllerSessionId,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return commit(own, {
|
|
653
|
+
state: CONTROL_STATE.OWNED,
|
|
654
|
+
controllerSessionId: own.ownerSessionId,
|
|
655
|
+
controlRevision: ctl.controlRevision + 1,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
case CONTROL_EVENT.TRANSFER: {
|
|
659
|
+
leadOnly();
|
|
660
|
+
retiredRefusal();
|
|
661
|
+
const toSessionId = requireIdentifier(event.toSessionId, 'toSessionId');
|
|
662
|
+
// Roster membership of the target must be re-read from the native service *before* this is
|
|
663
|
+
// committed (ruling §2.2/§5); the model can only guarantee that the id is a real id.
|
|
664
|
+
requireRevision(ctl.controlRevision, event.expectedControlRevision, 'expectedControlRevision');
|
|
665
|
+
requireRevision(own.ownershipRevision, event.expectedOwnershipRevision, 'expectedOwnershipRevision');
|
|
666
|
+
if (own.ownerSessionId === toSessionId && ctl.controllerSessionId === toSessionId
|
|
667
|
+
&& ctl.state === CONTROL_STATE.OWNED) {
|
|
668
|
+
return commit(own, ctl, false);
|
|
669
|
+
}
|
|
670
|
+
if (toSessionId === own.ownerSessionId) {
|
|
671
|
+
// Handing control back to the current owner changes control, not ownership. Bumping
|
|
672
|
+
// ownershipRevision here would make "the owner did not change" look like a transfer and
|
|
673
|
+
// would invalidate every ownership CAS for a page that never moved.
|
|
674
|
+
return commit(own, {
|
|
675
|
+
state: CONTROL_STATE.OWNED,
|
|
676
|
+
controllerSessionId: toSessionId,
|
|
677
|
+
controlRevision: ctl.controlRevision + 1,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return commit(
|
|
681
|
+
{ ...own, ownerSessionId: toSessionId, ownershipRevision: own.ownershipRevision + 1 },
|
|
682
|
+
{
|
|
683
|
+
state: CONTROL_STATE.OWNED,
|
|
684
|
+
controllerSessionId: toSessionId,
|
|
685
|
+
controlRevision: ctl.controlRevision + 1,
|
|
686
|
+
},
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
case CONTROL_EVENT.RETIRE: {
|
|
690
|
+
// close is a lifecycle operation: the owner or the current controller may retire their own
|
|
691
|
+
// page; anyone else needs this root's Lead (plan §5.1 "关闭属于同一控制通道").
|
|
692
|
+
const allowed = isLead || actor === own.ownerSessionId || actor === ctl.controllerSessionId;
|
|
693
|
+
if (!allowed) {
|
|
694
|
+
throw new AdapterError(ERROR_CODES.TAB_FORBIDDEN, 'this actor may not close the page', {
|
|
695
|
+
details: { reason: AUTHORIZE_REASON.ROLE_FORBIDDEN, event: type, role: principal.role },
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
if (ctl.state === CONTROL_STATE.RETIRED) return commit(own, ctl, false);
|
|
699
|
+
requireRevision(ctl.controlRevision, event.expectedControlRevision, 'expectedControlRevision');
|
|
700
|
+
return commit(own, {
|
|
701
|
+
state: CONTROL_STATE.RETIRED,
|
|
702
|
+
controllerSessionId: null,
|
|
703
|
+
controlRevision: ctl.controlRevision + 1,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
default:
|
|
707
|
+
throw invalid(`unknown control event: ${String(type)}`, { field: 'type' });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/* ----------------------------------------------------------------------------------------------- *
|
|
712
|
+
* operationEffect (ruling §7.2)
|
|
713
|
+
* ----------------------------------------------------------------------------------------------- */
|
|
714
|
+
|
|
715
|
+
function requirement(effect, resource, targetRequired, sourceAccessRequired = true) {
|
|
716
|
+
return Object.freeze({ effect, resource, targetRequired, sourceAccessRequired });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Reused requirement rows. Freezing them once keeps "the same call always yields the same table".
|
|
720
|
+
const OBSERVE_HOST = requirement(EFFECT.OBSERVE, RESOURCE.HOST, false, false);
|
|
721
|
+
/** Caller's own cached view: it must be filtered by root, so source access is required. */
|
|
722
|
+
const OBSERVE_HOST_VIEW = requirement(EFFECT.OBSERVE, RESOURCE.HOST, false, true);
|
|
723
|
+
const OBSERVE_PAGE = requirement(EFFECT.OBSERVE, RESOURCE.PAGE, true);
|
|
724
|
+
const WRITE_PAGE = requirement(EFFECT.WRITE, RESOURCE.PAGE, true);
|
|
725
|
+
/** Page write whose target is created or resolved later (open, capture onto the work selection). */
|
|
726
|
+
const WRITE_PAGE_OPTIONAL = requirement(EFFECT.WRITE, RESOURCE.PAGE, false);
|
|
727
|
+
/**
|
|
728
|
+
* Write to a Host-owned document that belongs to one page view (the annotation document).
|
|
729
|
+
*
|
|
730
|
+
* It is a write, not a read: another Agent session's notes must not be rewritten by a caller who
|
|
731
|
+
* only has observe rights, so the caller's selection is the *work* selection. It is still not a
|
|
732
|
+
* page write: nothing is dispatched to CDP, so no target and no page lease are required.
|
|
733
|
+
*/
|
|
734
|
+
const WRITE_HOST_VIEW = requirement(EFFECT.WRITE, RESOURCE.HOST, false, true);
|
|
735
|
+
const MANAGE_PAGE = requirement(EFFECT.MANAGE, RESOURCE.PAGE, true);
|
|
736
|
+
const MANAGE_PAGE_OPTIONAL = requirement(EFFECT.MANAGE, RESOURCE.PAGE, false);
|
|
737
|
+
const OBSERVE_COLLECTOR = requirement(EFFECT.OBSERVE, RESOURCE.COLLECTOR, false);
|
|
738
|
+
const WRITE_COLLECTOR = requirement(EFFECT.WRITE, RESOURCE.COLLECTOR, false);
|
|
739
|
+
const CALLER_LOCAL_COLLECTOR = requirement(EFFECT.CALLER_LOCAL, RESOURCE.COLLECTOR, false);
|
|
740
|
+
const OBSERVE_ARTIFACT = requirement(EFFECT.OBSERVE, RESOURCE.ARTIFACT, false);
|
|
741
|
+
const MANAGE_ARTIFACT = requirement(EFFECT.MANAGE, RESOURCE.ARTIFACT, false);
|
|
742
|
+
/** New artifact in the caller's own namespace: never an overwrite of a shared record. */
|
|
743
|
+
const CALLER_LOCAL_ARTIFACT = requirement(EFFECT.CALLER_LOCAL, RESOURCE.ARTIFACT, false);
|
|
744
|
+
const CALLER_LOCAL_HOST = requirement(EFFECT.CALLER_LOCAL, RESOURCE.HOST, false, false);
|
|
745
|
+
/** The caller's own selection row: it is keyed by actor, so it is theirs by construction. */
|
|
746
|
+
const CALLER_LOCAL_SELECTION = requirement(EFFECT.CALLER_LOCAL, RESOURCE.HOST, true, true);
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Product/artifact bookkeeping is an *extra* effect on top of the read.
|
|
750
|
+
*
|
|
751
|
+
* `browser_screenshot` without `out`/`persist` writes no file at all, so claiming a callerLocal
|
|
752
|
+
* artifact effect for it would over-restrict; with `out` it does, so the read alone is not the
|
|
753
|
+
* whole story. This is the concrete reason `operationEffect` returns an array.
|
|
754
|
+
*/
|
|
755
|
+
function artifactWrite(hasProduct) {
|
|
756
|
+
return hasProduct ? [CALLER_LOCAL_ARTIFACT] : [];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function wantsProduct(args) {
|
|
760
|
+
const direct = (typeof args.out === 'string' && args.out.trim().length > 0) || args.persist === true;
|
|
761
|
+
const nested = isPlainObject(args.output)
|
|
762
|
+
&& ((typeof args.output.out === 'string' && args.output.out.trim().length > 0) || args.output.persist === true);
|
|
763
|
+
return direct || nested;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const UNKNOWN = Symbol('unknown');
|
|
767
|
+
|
|
768
|
+
function actionOf(args, tool, defaultValue = UNKNOWN) {
|
|
769
|
+
const raw = args.action;
|
|
770
|
+
if (raw === undefined) {
|
|
771
|
+
if (defaultValue !== UNKNOWN) return defaultValue;
|
|
772
|
+
throw invalid(`${tool} requires an explicit action`, { field: 'action', operation: tool });
|
|
773
|
+
}
|
|
774
|
+
if (!isNonEmptyString(raw)) {
|
|
775
|
+
throw invalid(`${tool} action must be a non-empty string`, { field: 'action', operation: tool });
|
|
776
|
+
}
|
|
777
|
+
return raw;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function actionTable(tool, table, defaultAction = UNKNOWN) {
|
|
781
|
+
const frozen = Object.freeze(table);
|
|
782
|
+
return (args) => {
|
|
783
|
+
// `defaultAction` mirrors a host-side schema default (only browser_network_session has one).
|
|
784
|
+
const action = actionOf(args, tool, defaultAction);
|
|
785
|
+
const row = frozen[action];
|
|
786
|
+
if (row === undefined) {
|
|
787
|
+
// Default-deny: an action the table has never seen is not "probably a read".
|
|
788
|
+
throw invalid(`unknown ${tool} action: ${action}`, { operation: tool, action });
|
|
789
|
+
}
|
|
790
|
+
return typeof row === 'function' ? row(args) : row;
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Vision sources: `live` reads a page, `product`/`baseline` read an owned artifact. */
|
|
795
|
+
function visionSourceEffect(source, { field }) {
|
|
796
|
+
const kind = isPlainObject(source)
|
|
797
|
+
? (source.kind === undefined ? 'live' : source.kind)
|
|
798
|
+
: 'live';
|
|
799
|
+
if (kind === 'live') return OBSERVE_PAGE;
|
|
800
|
+
if (kind === 'product' || kind === 'baseline') return OBSERVE_ARTIFACT;
|
|
801
|
+
throw invalid(`unknown vision source kind: ${String(kind)}`, { field });
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function nestedLiveSources(sources) {
|
|
805
|
+
return sources.some((source) => isPlainObject(source) && (source.kind === undefined || source.kind === 'live'));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const TOOL_EFFECTS = Object.freeze({
|
|
809
|
+
// --- Host/page cached reads -------------------------------------------------------------------
|
|
810
|
+
browser_capabilities: () => [OBSERVE_HOST],
|
|
811
|
+
browser_state: () => [OBSERVE_HOST_VIEW],
|
|
812
|
+
browser_tabs: actionTable('browser_tabs', {
|
|
813
|
+
list: [OBSERVE_HOST_VIEW],
|
|
814
|
+
current: [OBSERVE_HOST_VIEW],
|
|
815
|
+
// wait only *observes* an existing slot / a popup that arrives on its own: no ensure, no claim.
|
|
816
|
+
wait: [OBSERVE_HOST_VIEW],
|
|
817
|
+
select: [CALLER_LOCAL_SELECTION],
|
|
818
|
+
open: [WRITE_PAGE_OPTIONAL],
|
|
819
|
+
close: (args) => [isNonEmptyString(args.tabId) ? MANAGE_PAGE : MANAGE_PAGE_OPTIONAL],
|
|
820
|
+
acquire: [MANAGE_PAGE],
|
|
821
|
+
release: [MANAGE_PAGE],
|
|
822
|
+
transfer: [MANAGE_PAGE],
|
|
823
|
+
}),
|
|
824
|
+
browser_viewport: actionTable('browser_viewport', {
|
|
825
|
+
get: [OBSERVE_HOST_VIEW],
|
|
826
|
+
set: [WRITE_PAGE],
|
|
827
|
+
}),
|
|
828
|
+
// P0 annotations (docs/viewport-control.md): reading the document is a cached host read; every
|
|
829
|
+
// mutating action rewrites the shared document, so it routes through the caller's work selection
|
|
830
|
+
// and never through the observation-only one.
|
|
831
|
+
browser_annotation: actionTable('browser_annotation', {
|
|
832
|
+
read: [OBSERVE_HOST_VIEW],
|
|
833
|
+
draw: [WRITE_HOST_VIEW],
|
|
834
|
+
erase: [WRITE_HOST_VIEW],
|
|
835
|
+
clear: [WRITE_HOST_VIEW],
|
|
836
|
+
}),
|
|
837
|
+
|
|
838
|
+
// --- Page writes: everything that can move a page or its document ----------------------------
|
|
839
|
+
browser_navigate: () => [WRITE_PAGE],
|
|
840
|
+
browser_eval: () => [WRITE_PAGE],
|
|
841
|
+
browser_input: () => [WRITE_PAGE],
|
|
842
|
+
browser_click: () => [WRITE_PAGE],
|
|
843
|
+
browser_type: () => [WRITE_PAGE],
|
|
844
|
+
browser_scroll: () => [WRITE_PAGE],
|
|
845
|
+
browser_fill_form: () => [WRITE_PAGE],
|
|
846
|
+
browser_vision_act: () => [WRITE_PAGE],
|
|
847
|
+
|
|
848
|
+
// --- Page observation: fixed internal read paths, never a script the caller supplies ---------
|
|
849
|
+
browser_screenshot: (args) => [OBSERVE_PAGE, ...artifactWrite(wantsProduct(args))],
|
|
850
|
+
browser_snapshot: () => [OBSERVE_PAGE],
|
|
851
|
+
browser_node_state: () => [OBSERVE_PAGE],
|
|
852
|
+
browser_computed_style: () => [OBSERVE_PAGE],
|
|
853
|
+
browser_wait_for: () => [OBSERVE_PAGE],
|
|
854
|
+
|
|
855
|
+
// --- Vision: artifact sources are root-scoped reads, live sources are page reads --------------
|
|
856
|
+
browser_vision_image: (args) => [
|
|
857
|
+
visionSourceEffect(args.source, { field: 'source' }),
|
|
858
|
+
CALLER_LOCAL_ARTIFACT,
|
|
859
|
+
...artifactWrite(wantsProduct(args)),
|
|
860
|
+
],
|
|
861
|
+
browser_vision_find: (args) => [
|
|
862
|
+
visionSourceEffect(args.source, { field: 'source' }),
|
|
863
|
+
CALLER_LOCAL_ARTIFACT,
|
|
864
|
+
...artifactWrite(wantsProduct(args)),
|
|
865
|
+
],
|
|
866
|
+
browser_vision_compare: (args) => {
|
|
867
|
+
if (!isPlainObject(args.before) || !isPlainObject(args.after)) {
|
|
868
|
+
throw invalid('browser_vision_compare requires explicit before and after sources', {
|
|
869
|
+
operation: 'browser_vision_compare',
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
const effects = [OBSERVE_ARTIFACT, CALLER_LOCAL_ARTIFACT];
|
|
873
|
+
if (nestedLiveSources([args.before, args.after])) effects.push(OBSERVE_PAGE);
|
|
874
|
+
return effects.concat(artifactWrite(wantsProduct(args)));
|
|
875
|
+
},
|
|
876
|
+
browser_vision_check: (args) => {
|
|
877
|
+
const cases = Array.isArray(args.cases) ? args.cases : [];
|
|
878
|
+
const effects = [OBSERVE_ARTIFACT, CALLER_LOCAL_ARTIFACT];
|
|
879
|
+
// Each case may embed its own source; a single live source makes the whole batch a page read.
|
|
880
|
+
const caseSources = cases.map((entry) => (isPlainObject(entry) ? entry.source : undefined)).filter(Boolean);
|
|
881
|
+
if (nestedLiveSources(caseSources)) effects.push(OBSERVE_PAGE);
|
|
882
|
+
return effects.concat(artifactWrite(wantsProduct(args)));
|
|
883
|
+
},
|
|
884
|
+
browser_vision_baseline: actionTable('browser_vision_baseline', {
|
|
885
|
+
list: [OBSERVE_ARTIFACT],
|
|
886
|
+
get: [OBSERVE_ARTIFACT],
|
|
887
|
+
// create writes a *new* managed artifact; a live source additionally needs the page.
|
|
888
|
+
create: (args) => (isPlainObject(args.source) && (args.source.kind === undefined || args.source.kind === 'live')
|
|
889
|
+
? [CALLER_LOCAL_ARTIFACT, OBSERVE_PAGE]
|
|
890
|
+
: [CALLER_LOCAL_ARTIFACT]),
|
|
891
|
+
// update/delete mutate a shared managed record: readable-in-root is not writable-in-root.
|
|
892
|
+
update: (args) => (isPlainObject(args.source) && (args.source.kind === undefined || args.source.kind === 'live')
|
|
893
|
+
? [MANAGE_ARTIFACT, OBSERVE_PAGE]
|
|
894
|
+
: [MANAGE_ARTIFACT]),
|
|
895
|
+
delete: [MANAGE_ARTIFACT],
|
|
896
|
+
}),
|
|
897
|
+
|
|
898
|
+
// --- Network collector -----------------------------------------------------------------------
|
|
899
|
+
browser_network: () => [OBSERVE_COLLECTOR],
|
|
900
|
+
browser_network_events: () => [OBSERVE_COLLECTOR],
|
|
901
|
+
browser_network_detail: () => [OBSERVE_COLLECTOR],
|
|
902
|
+
// The host tool defaults a missing action to `start`; keep that default here so an action-less
|
|
903
|
+
// call is classified as the *strictest* of its three, never as a read.
|
|
904
|
+
browser_network_session: actionTable('browser_network_session', {
|
|
905
|
+
status: [OBSERVE_COLLECTOR],
|
|
906
|
+
start: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
907
|
+
stop: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
908
|
+
reset: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
909
|
+
}, 'start'),
|
|
910
|
+
browser_network_control: actionTable('browser_network_control', {
|
|
911
|
+
'intercept-list': [OBSERVE_COLLECTOR],
|
|
912
|
+
throttle: [WRITE_COLLECTOR],
|
|
913
|
+
offline: [WRITE_COLLECTOR],
|
|
914
|
+
'intercept-add': [WRITE_COLLECTOR],
|
|
915
|
+
'intercept-remove': [WRITE_COLLECTOR],
|
|
916
|
+
'intercept-clear': [WRITE_COLLECTOR],
|
|
917
|
+
hold: [WRITE_COLLECTOR],
|
|
918
|
+
resume: [WRITE_COLLECTOR],
|
|
919
|
+
replay: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
920
|
+
mock: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
921
|
+
reset: [WRITE_COLLECTOR],
|
|
922
|
+
}),
|
|
923
|
+
browser_network_trace: actionTable('browser_network_trace', {
|
|
924
|
+
window: [OBSERVE_COLLECTOR],
|
|
925
|
+
query: [OBSERVE_COLLECTOR],
|
|
926
|
+
'begin-step': [WRITE_COLLECTOR],
|
|
927
|
+
'end-step': [WRITE_COLLECTOR],
|
|
928
|
+
mark: [WRITE_COLLECTOR],
|
|
929
|
+
link: [WRITE_COLLECTOR],
|
|
930
|
+
reset: [WRITE_COLLECTOR],
|
|
931
|
+
}),
|
|
932
|
+
browser_network_evidence: actionTable('browser_network_evidence', {
|
|
933
|
+
list: [OBSERVE_ARTIFACT],
|
|
934
|
+
search: [OBSERVE_ARTIFACT],
|
|
935
|
+
integrity: [OBSERVE_ARTIFACT],
|
|
936
|
+
// New copy in the caller's own namespace (never an overwrite of a shared record).
|
|
937
|
+
export: [CALLER_LOCAL_ARTIFACT],
|
|
938
|
+
index: [CALLER_LOCAL_ARTIFACT],
|
|
939
|
+
// Shared-store mutation: needs the store's owner/controller or this root's Lead.
|
|
940
|
+
import: [MANAGE_ARTIFACT],
|
|
941
|
+
link: [MANAGE_ARTIFACT],
|
|
942
|
+
clear: [MANAGE_ARTIFACT],
|
|
943
|
+
}),
|
|
944
|
+
browser_network_subscribe: actionTable('browser_network_subscribe', {
|
|
945
|
+
create: [CALLER_LOCAL_COLLECTOR],
|
|
946
|
+
poll: [CALLER_LOCAL_COLLECTOR],
|
|
947
|
+
ack: [CALLER_LOCAL_COLLECTOR],
|
|
948
|
+
close: [CALLER_LOCAL_COLLECTOR],
|
|
949
|
+
list: [CALLER_LOCAL_COLLECTOR],
|
|
950
|
+
}),
|
|
951
|
+
|
|
952
|
+
// --- Dynamic (T4) ----------------------------------------------------------------------------
|
|
953
|
+
browser_dynamic_session: actionTable('browser_dynamic_session', {
|
|
954
|
+
status: [OBSERVE_COLLECTOR],
|
|
955
|
+
start: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
956
|
+
stop: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
957
|
+
reset: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
958
|
+
extend: [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL],
|
|
959
|
+
}),
|
|
960
|
+
// `fresh:true` is an explicit live sampling action that extends the shared capture; everything
|
|
961
|
+
// else is a stored read. The default must be the read, and it must not be reachable as a way to
|
|
962
|
+
// grow a capture session.
|
|
963
|
+
browser_motion_observe: (args) => (args.fresh === true
|
|
964
|
+
? [WRITE_COLLECTOR, WRITE_PAGE_OPTIONAL]
|
|
965
|
+
: [OBSERVE_COLLECTOR]),
|
|
966
|
+
browser_motion_analyze: () => [OBSERVE_ARTIFACT],
|
|
967
|
+
browser_motion_control: actionTable('browser_motion_control', {
|
|
968
|
+
pause: [WRITE_PAGE],
|
|
969
|
+
resume: [WRITE_PAGE],
|
|
970
|
+
seek: [WRITE_PAGE],
|
|
971
|
+
rate: [WRITE_PAGE],
|
|
972
|
+
// restore replaying another actor's restoreToken must not become a way to borrow control.
|
|
973
|
+
restore: [WRITE_PAGE],
|
|
974
|
+
}),
|
|
975
|
+
browser_dynamic_correlate: () => [OBSERVE_COLLECTOR],
|
|
976
|
+
|
|
977
|
+
// --- Independent HTTP: no page, no cookie jar, no Principal for the target --------------------
|
|
978
|
+
browser_http_request: () => [CALLER_LOCAL_HOST],
|
|
979
|
+
|
|
980
|
+
// --- Test-only fake environment probe (registered only under the explicit test hook) ---------
|
|
981
|
+
browser_fake_probe: () => [CALLER_LOCAL_HOST],
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Rows of ruling §7.2 that are *not* tools: the HTTP/WS side (GUI viewer, managed-page ops) and the
|
|
986
|
+
* provider-wide host controls. They live in the same table so M3 authorises them through one
|
|
987
|
+
* authority instead of re-classifying inline; unknown ids still refuse, so a mis-wired endpoint
|
|
988
|
+
* fails loudly rather than falling through to a permissive default.
|
|
989
|
+
*/
|
|
990
|
+
const NON_TOOL_EFFECTS = Object.freeze({
|
|
991
|
+
'gui.frame': [OBSERVE_PAGE, requirement(EFFECT.CALLER_LOCAL, RESOURCE.HOST, false, true)],
|
|
992
|
+
'gui.state': [OBSERVE_HOST_VIEW],
|
|
993
|
+
'gui.metrics': [requirement(EFFECT.CALLER_LOCAL, RESOURCE.HOST, false, true)],
|
|
994
|
+
'gui.annotations.publish': [WRITE_PAGE],
|
|
995
|
+
'gui.canvas': [WRITE_PAGE],
|
|
996
|
+
'gui.view': [WRITE_PAGE],
|
|
997
|
+
'gui.ensure': [WRITE_PAGE_OPTIONAL],
|
|
998
|
+
'gui.navigate': [WRITE_PAGE],
|
|
999
|
+
'gui.input': [WRITE_PAGE],
|
|
1000
|
+
'gui.eval': [WRITE_PAGE],
|
|
1001
|
+
'gui.close': [MANAGE_PAGE],
|
|
1002
|
+
// Provider-wide display/restart and global config are *not* page management: no Agent role
|
|
1003
|
+
// reaches them (ruling §7.2 last row), and `authorize` refuses manage/host unconditionally.
|
|
1004
|
+
'host.display': [requirement(EFFECT.MANAGE, RESOURCE.HOST, false, false)],
|
|
1005
|
+
'host.config': [requirement(EFFECT.MANAGE, RESOURCE.HOST, false, false)],
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
/** Effect strength, strongest last. Used to route a compound call by its strongest requirement. */
|
|
1009
|
+
const EFFECT_STRENGTH = Object.freeze({
|
|
1010
|
+
[EFFECT.OBSERVE]: 0,
|
|
1011
|
+
[EFFECT.CALLER_LOCAL]: 1,
|
|
1012
|
+
[EFFECT.WRITE]: 2,
|
|
1013
|
+
[EFFECT.MANAGE]: 3,
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
function effectKey(row) {
|
|
1017
|
+
return `${row.effect}:${row.resource}:${row.targetRequired}`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Expand one tool call into every effect it really has (ruling §7.2).
|
|
1022
|
+
*
|
|
1023
|
+
* The array is the whole truth for the call: a screenshot that also saves a product is an observe
|
|
1024
|
+
* *and* a callerLocal artifact write, and a check that embeds a live source is an artifact read
|
|
1025
|
+
* *plus* a page read. Authorising only the outer tool name is how a "read" tool ends up writing.
|
|
1026
|
+
*
|
|
1027
|
+
* @param {string} operation Tool name (`browser_*`) or a frozen non-tool id (`gui.*`, `host.*`).
|
|
1028
|
+
* @param {object} [args] Validated call arguments; sub-actions are read from `args.action`.
|
|
1029
|
+
* @returns {ReadonlyArray<Readonly<{effect:string, resource:string, targetRequired:boolean,
|
|
1030
|
+
* sourceAccessRequired:boolean}>>}
|
|
1031
|
+
* @throws {AdapterError} INVALID_ARGUMENT for an unknown operation **or** unknown sub-action
|
|
1032
|
+
* (default-deny: nothing is dispatched).
|
|
1033
|
+
*/
|
|
1034
|
+
export function operationEffect(operation, args = {}) {
|
|
1035
|
+
if (!isNonEmptyString(operation)) {
|
|
1036
|
+
throw invalid('operation must be a non-empty string', { field: 'operation' });
|
|
1037
|
+
}
|
|
1038
|
+
const input = isPlainObject(args) ? args : {};
|
|
1039
|
+
const nonTool = NON_TOOL_EFFECTS[operation];
|
|
1040
|
+
let rows;
|
|
1041
|
+
if (nonTool !== undefined) {
|
|
1042
|
+
rows = nonTool;
|
|
1043
|
+
} else {
|
|
1044
|
+
const rule = TOOL_EFFECTS[operation];
|
|
1045
|
+
if (rule === undefined) {
|
|
1046
|
+
throw invalid(`unknown operation: ${operation}`, { operation });
|
|
1047
|
+
}
|
|
1048
|
+
rows = rule(input);
|
|
1049
|
+
}
|
|
1050
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
1051
|
+
throw invalid(`operation ${operation} produced no effect requirement`, { operation });
|
|
1052
|
+
}
|
|
1053
|
+
const seen = new Set();
|
|
1054
|
+
const out = [];
|
|
1055
|
+
for (const row of rows) {
|
|
1056
|
+
if (!KNOWN_EFFECTS.has(row?.effect) || !KNOWN_RESOURCES.has(row?.resource)) {
|
|
1057
|
+
throw invalid(`operation ${operation} produced an invalid effect`, { operation });
|
|
1058
|
+
}
|
|
1059
|
+
const key = effectKey(row);
|
|
1060
|
+
if (seen.has(key)) continue; // the array describes effects, not repetitions of one effect
|
|
1061
|
+
seen.add(key);
|
|
1062
|
+
out.push(row);
|
|
1063
|
+
}
|
|
1064
|
+
return Object.freeze(out);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Strongest requirement of a compound call, by `manage > write > callerLocal > observe`.
|
|
1069
|
+
*
|
|
1070
|
+
* A compound call must be routed by its strongest effect: running the observe half against the
|
|
1071
|
+
* caller's own page and the manage half against a teammate's page is the classic confused-deputy.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {ReadonlyArray<object>} effects
|
|
1074
|
+
* @returns {Readonly<object>|null}
|
|
1075
|
+
*/
|
|
1076
|
+
export function strongestEffect(effects) {
|
|
1077
|
+
if (!Array.isArray(effects) || effects.length === 0) return null;
|
|
1078
|
+
let best = effects[0];
|
|
1079
|
+
for (const row of effects) {
|
|
1080
|
+
if (EFFECT_STRENGTH[row.effect] > EFFECT_STRENGTH[best.effect]) best = row;
|
|
1081
|
+
}
|
|
1082
|
+
return best;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* True when the operation id is in the frozen effect table (tool rows or non-tool rows).
|
|
1087
|
+
* @param {string} operation
|
|
1088
|
+
*/
|
|
1089
|
+
export function isKnownOperation(operation) {
|
|
1090
|
+
return isNonEmptyString(operation)
|
|
1091
|
+
&& (Object.hasOwn(TOOL_EFFECTS, operation) || Object.hasOwn(NON_TOOL_EFFECTS, operation));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/* ----------------------------------------------------------------------------------------------- *
|
|
1095
|
+
* authorise
|
|
1096
|
+
* ----------------------------------------------------------------------------------------------- */
|
|
1097
|
+
|
|
1098
|
+
/** Operations that close a page: owner/controller may run them, everyone else needs the Lead. */
|
|
1099
|
+
const CLOSE_OPERATIONS = new Set(['tabs.close', 'browser_tabs.close', 'gui.close']);
|
|
1100
|
+
/** Operations that move control between actors: Lead-only by construction. */
|
|
1101
|
+
const CONTROL_OPERATIONS = new Set([
|
|
1102
|
+
'tabs.acquire', 'tabs.release', 'tabs.transfer',
|
|
1103
|
+
'browser_tabs.acquire', 'browser_tabs.release', 'browser_tabs.transfer',
|
|
1104
|
+
]);
|
|
1105
|
+
|
|
1106
|
+
function verdict(allowed, reason, code, row, extra = {}) {
|
|
1107
|
+
return Object.freeze({
|
|
1108
|
+
allowed,
|
|
1109
|
+
reason,
|
|
1110
|
+
code: code ?? null,
|
|
1111
|
+
effect: row.effect,
|
|
1112
|
+
resource: row.resource,
|
|
1113
|
+
targetRequired: row.targetRequired,
|
|
1114
|
+
sourceAccessRequired: row.sourceAccessRequired,
|
|
1115
|
+
notFound: extra.notFound === true,
|
|
1116
|
+
autoAcquire: extra.autoAcquire === true,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function deny(row, reason, code, extra) {
|
|
1121
|
+
return verdict(false, reason, code, row, extra);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Decide one effect against one page record.
|
|
1126
|
+
*
|
|
1127
|
+
* Check order is the contract (plan §3.3): **root, then role, then controller/version**. The
|
|
1128
|
+
* consequence of that order is which answer a stranger receives, so it is not reorderable for
|
|
1129
|
+
* convenience — a "wrong revision" answer to a foreign root is an existence oracle.
|
|
1130
|
+
*
|
|
1131
|
+
* @param {{principal:object, ownership?:object|null, control?:object|null, effect:object,
|
|
1132
|
+
* operation?:string|null, expectedControlRevision?:number, expectedOwnershipRevision?:number}} input
|
|
1133
|
+
* @returns {Readonly<object>} verdict: `{allowed, reason, code, effect, resource, notFound,
|
|
1134
|
+
* autoAcquire}` — `code` is null exactly for cross-root (existence must not leak).
|
|
1135
|
+
*/
|
|
1136
|
+
export function authorize({
|
|
1137
|
+
principal,
|
|
1138
|
+
ownership = null,
|
|
1139
|
+
control = null,
|
|
1140
|
+
effect,
|
|
1141
|
+
operation = null,
|
|
1142
|
+
expectedControlRevision,
|
|
1143
|
+
expectedOwnershipRevision,
|
|
1144
|
+
} = {}) {
|
|
1145
|
+
const row = isPlainObject(effect)
|
|
1146
|
+
? requirement(effect.effect, effect.resource, effect.targetRequired === true, effect.sourceAccessRequired !== false)
|
|
1147
|
+
: null;
|
|
1148
|
+
if (row === null || !KNOWN_EFFECTS.has(row.effect) || !KNOWN_RESOURCES.has(row.resource)) {
|
|
1149
|
+
throw invalid('authorize requires a well-formed effect requirement', { field: 'effect' });
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// 0. Identity: an unproved principal never reaches a page, and `system` is not a root admin.
|
|
1153
|
+
if (!isPrincipal(principal)) {
|
|
1154
|
+
return deny(row, AUTHORIZE_REASON.IDENTITY_UNVERIFIED, ERROR_CODES.TAB_IDENTITY_UNAVAILABLE);
|
|
1155
|
+
}
|
|
1156
|
+
// Provider-wide host control is not reachable from any Agent/panel/system principal (ruling §7.2).
|
|
1157
|
+
if (row.resource === RESOURCE.HOST && row.effect === EFFECT.MANAGE) {
|
|
1158
|
+
return deny(row, AUTHORIZE_REASON.HOST_MANAGE_FORBIDDEN, ERROR_CODES.TAB_FORBIDDEN);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const needsRecord = row.targetRequired === true || row.effect === EFFECT.MANAGE;
|
|
1162
|
+
if (!needsRecord) {
|
|
1163
|
+
if (row.resource === RESOURCE.PAGE && row.effect !== EFFECT.WRITE) {
|
|
1164
|
+
// An observe/manage of a page without a resolved target would authorise "some page".
|
|
1165
|
+
return deny(row, AUTHORIZE_REASON.TARGET_UNRESOLVED, ERROR_CODES.TARGET_NOT_BOUND);
|
|
1166
|
+
}
|
|
1167
|
+
// Creation (page write without a target yet) and caller-owned resources are authorised by the
|
|
1168
|
+
// caller's verified root; the caller still has to reserve capacity and bind ownership itself.
|
|
1169
|
+
return verdict(true, AUTHORIZE_REASON.ALLOWED, null, row);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (!isPlainObject(ownership) || !isPlainObject(control)) {
|
|
1173
|
+
return deny(row, AUTHORIZE_REASON.TARGET_UNRESOLVED, ERROR_CODES.TARGET_NOT_BOUND);
|
|
1174
|
+
}
|
|
1175
|
+
const own = makeOwnership(ownership);
|
|
1176
|
+
const ctl = makeControl(control);
|
|
1177
|
+
|
|
1178
|
+
// 1. root — the only step that must be silent about existence.
|
|
1179
|
+
if (principal.rootSessionId !== own.rootSessionId) {
|
|
1180
|
+
return deny(row, AUTHORIZE_REASON.CROSS_ROOT, null, { notFound: true });
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const actor = principal.actorSessionId;
|
|
1184
|
+
const isLead = principal.role === PRINCIPAL_ROLE.LEAD;
|
|
1185
|
+
const ownsRecord = own.ownerSessionId === actor;
|
|
1186
|
+
const controls = ctl.controllerSessionId === actor;
|
|
1187
|
+
|
|
1188
|
+
// 2. role / 3. state, per effect. Versions are checked **after** these (see the tail): a caller
|
|
1189
|
+
// who lacks the role must not learn from the answer whether the revision it guessed is current.
|
|
1190
|
+
if (ctl.state === CONTROL_STATE.RETIRED) {
|
|
1191
|
+
// A retired target is honestly gone rather than silently absent — the caller already had the
|
|
1192
|
+
// right to see this page (same root), so 410 is not an existence leak.
|
|
1193
|
+
return deny(row, AUTHORIZE_REASON.TARGET_RETIRED, ERROR_CODES.TARGET_GONE);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (row.effect === EFFECT.OBSERVE) {
|
|
1197
|
+
// Same root may always look, whatever the role, and looking never claims anything.
|
|
1198
|
+
return verdict(true, AUTHORIZE_REASON.ALLOWED, null, row);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (row.effect === EFFECT.CALLER_LOCAL) {
|
|
1202
|
+
// Only the caller's own bookkeeping. The key factories already bind these rows to the actor,
|
|
1203
|
+
// so a same-root check is the whole authorisation (no controller is involved).
|
|
1204
|
+
return verdict(true, AUTHORIZE_REASON.ALLOWED, null, row);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (ctl.state === CONTROL_STATE.DRAINING) {
|
|
1208
|
+
// Old-version writes must observe a closed gate; the takeover completes in its own control
|
|
1209
|
+
// chain. Reporting BUSY here would invite an automatic retry against a moving controller.
|
|
1210
|
+
return deny(row, AUTHORIZE_REASON.CONTROL_DRAINING, ERROR_CODES.TAB_CONTROL_CHANGED);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
let outcome;
|
|
1214
|
+
if (row.effect === EFFECT.WRITE) {
|
|
1215
|
+
if (isLead) {
|
|
1216
|
+
outcome = ctl.state === CONTROL_STATE.LEAD_CONTROLLED && !controls
|
|
1217
|
+
? deny(row, AUTHORIZE_REASON.NOT_CONTROLLER, ERROR_CODES.TAB_CONTROL_CHANGED)
|
|
1218
|
+
// A Lead writing an idle member page is the atomic "acquire then dispatch" path: the caller
|
|
1219
|
+
// still has to commit the takeover, and the response says which one happened.
|
|
1220
|
+
: verdict(true, AUTHORIZE_REASON.ALLOWED, null, row, {
|
|
1221
|
+
autoAcquire: ctl.state === CONTROL_STATE.OWNED && !controls,
|
|
1222
|
+
});
|
|
1223
|
+
} else if (!ownsRecord) {
|
|
1224
|
+
// Teammate/standalone: knowing a target, a runtime id or a lease id never becomes write
|
|
1225
|
+
// permission, and a foreign teammate's page is not writable at all.
|
|
1226
|
+
outcome = deny(row, AUTHORIZE_REASON.ROLE_FORBIDDEN, ERROR_CODES.TAB_FORBIDDEN);
|
|
1227
|
+
} else if (!controls) {
|
|
1228
|
+
outcome = deny(row, AUTHORIZE_REASON.NOT_CONTROLLER, ERROR_CODES.TAB_CONTROL_CHANGED);
|
|
1229
|
+
} else {
|
|
1230
|
+
outcome = verdict(true, AUTHORIZE_REASON.ALLOWED, null, row);
|
|
1231
|
+
}
|
|
1232
|
+
} else {
|
|
1233
|
+
// MANAGE
|
|
1234
|
+
const isClose = row.resource === RESOURCE.PAGE && operation !== null && CLOSE_OPERATIONS.has(operation);
|
|
1235
|
+
const isControl = row.resource === RESOURCE.PAGE && operation !== null && CONTROL_OPERATIONS.has(operation);
|
|
1236
|
+
if (isClose) {
|
|
1237
|
+
// close is a lifecycle operation on the same control channel: owner or current controller may
|
|
1238
|
+
// retire their own page, everyone else needs this root's Lead.
|
|
1239
|
+
outcome = isLead || ownsRecord || controls
|
|
1240
|
+
? verdict(true, AUTHORIZE_REASON.ALLOWED, null, row)
|
|
1241
|
+
: deny(row, AUTHORIZE_REASON.ROLE_FORBIDDEN, ERROR_CODES.TAB_FORBIDDEN);
|
|
1242
|
+
} else if (isControl) {
|
|
1243
|
+
outcome = isLead
|
|
1244
|
+
? verdict(true, AUTHORIZE_REASON.ALLOWED, null, row)
|
|
1245
|
+
: deny(row, AUTHORIZE_REASON.ROLE_FORBIDDEN, ERROR_CODES.TAB_FORBIDDEN);
|
|
1246
|
+
} else if (isLead) {
|
|
1247
|
+
outcome = verdict(true, AUTHORIZE_REASON.ALLOWED, null, row);
|
|
1248
|
+
} else if (row.resource !== RESOURCE.PAGE && ownsRecord) {
|
|
1249
|
+
// A shared artifact/collector record owned by the caller (its own baseline, its own store
|
|
1250
|
+
// slot). An unclassified *page* manage keeps the narrowest rule: this root's Lead only.
|
|
1251
|
+
outcome = verdict(true, AUTHORIZE_REASON.ALLOWED, null, row);
|
|
1252
|
+
} else {
|
|
1253
|
+
outcome = deny(row, AUTHORIZE_REASON.ROLE_FORBIDDEN, ERROR_CODES.TAB_FORBIDDEN);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (!outcome.allowed) return outcome;
|
|
1257
|
+
|
|
1258
|
+
// 3. versions, last: the caller has already proven it stands on this page, so telling it that its
|
|
1259
|
+
// expected revision is old (and which one is current) reveals nothing it could not read anyway.
|
|
1260
|
+
if (expectedOwnershipRevision !== undefined && expectedOwnershipRevision !== own.ownershipRevision) {
|
|
1261
|
+
return deny(row, AUTHORIZE_REASON.OWNERSHIP_REVISION_STALE, ERROR_CODES.TAB_OWNERSHIP_STALE);
|
|
1262
|
+
}
|
|
1263
|
+
if (expectedControlRevision !== undefined && expectedControlRevision !== ctl.controlRevision) {
|
|
1264
|
+
return deny(row, AUTHORIZE_REASON.CONTROL_REVISION_STALE, ERROR_CODES.TAB_CONTROL_CHANGED);
|
|
1265
|
+
}
|
|
1266
|
+
return outcome;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Authorise a whole compound call by its strongest effect.
|
|
1271
|
+
*
|
|
1272
|
+
* Every requirement must pass; the answer is the strongest one's verdict so that a caller routing
|
|
1273
|
+
* on `autoAcquire`/`reason` sees the most demanding part of the call, not a permissive subset.
|
|
1274
|
+
*
|
|
1275
|
+
* @param {{principal:object, ownership?:object|null, control?:object|null,
|
|
1276
|
+
* effects:ReadonlyArray<object>, operation?:string|null, expectedControlRevision?:number,
|
|
1277
|
+
* expectedOwnershipRevision?:number}} input
|
|
1278
|
+
* @returns {{allowed:boolean, verdict:Readonly<object>, verdicts:ReadonlyArray<object>}}
|
|
1279
|
+
*/
|
|
1280
|
+
export function authorizeAll({
|
|
1281
|
+
principal,
|
|
1282
|
+
ownership = null,
|
|
1283
|
+
control = null,
|
|
1284
|
+
effects,
|
|
1285
|
+
operation = null,
|
|
1286
|
+
expectedControlRevision,
|
|
1287
|
+
expectedOwnershipRevision,
|
|
1288
|
+
} = {}) {
|
|
1289
|
+
if (!Array.isArray(effects) || effects.length === 0) {
|
|
1290
|
+
throw invalid('authorizeAll requires at least one effect', { field: 'effects' });
|
|
1291
|
+
}
|
|
1292
|
+
const rows = effects.map((effect) => {
|
|
1293
|
+
if (!isPlainObject(effect) || !KNOWN_EFFECTS.has(effect.effect) || !KNOWN_RESOURCES.has(effect.resource)) {
|
|
1294
|
+
throw invalid('authorizeAll received a malformed effect requirement', { field: 'effects' });
|
|
1295
|
+
}
|
|
1296
|
+
return requirement(
|
|
1297
|
+
effect.effect,
|
|
1298
|
+
effect.resource,
|
|
1299
|
+
effect.targetRequired === true,
|
|
1300
|
+
effect.sourceAccessRequired !== false,
|
|
1301
|
+
);
|
|
1302
|
+
});
|
|
1303
|
+
const ordered = rows.sort((a, b) => EFFECT_STRENGTH[b.effect] - EFFECT_STRENGTH[a.effect]);
|
|
1304
|
+
const verdicts = [];
|
|
1305
|
+
for (const effect of ordered) {
|
|
1306
|
+
const result = authorize({
|
|
1307
|
+
principal,
|
|
1308
|
+
ownership,
|
|
1309
|
+
control,
|
|
1310
|
+
effect,
|
|
1311
|
+
operation,
|
|
1312
|
+
expectedControlRevision,
|
|
1313
|
+
expectedOwnershipRevision,
|
|
1314
|
+
});
|
|
1315
|
+
verdicts.push(result);
|
|
1316
|
+
if (!result.allowed) {
|
|
1317
|
+
return Object.freeze({ allowed: false, verdict: result, verdicts: Object.freeze(verdicts) });
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return Object.freeze({
|
|
1321
|
+
allowed: true,
|
|
1322
|
+
verdict: verdicts[0],
|
|
1323
|
+
verdicts: Object.freeze(verdicts),
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Project the compatibility `permissions` object of ruling §5 (read/write/close/manage).
|
|
1329
|
+
*
|
|
1330
|
+
* A single source for the read projection keeps the API surface and any future GUI from
|
|
1331
|
+
* re-deriving permissions from legacy `busy`/`owner`/`manageable`, which never described ACLs.
|
|
1332
|
+
*
|
|
1333
|
+
* @param {{principal:object, ownership:object, control:object}} input
|
|
1334
|
+
* @returns {{read:boolean, write:boolean, close:boolean, manage:boolean, canAcquire:boolean}}
|
|
1335
|
+
*/
|
|
1336
|
+
export function projectPermissions({ principal, ownership, control } = {}) {
|
|
1337
|
+
const base = { principal, ownership, control };
|
|
1338
|
+
const read = authorize({ ...base, effect: OBSERVE_PAGE, operation: 'browser_node_state' }).allowed;
|
|
1339
|
+
const write = authorize({ ...base, effect: WRITE_PAGE, operation: 'browser_click' }).allowed;
|
|
1340
|
+
const close = authorize({ ...base, effect: MANAGE_PAGE, operation: 'browser_tabs.close' }).allowed;
|
|
1341
|
+
const manage = authorize({ ...base, effect: MANAGE_PAGE, operation: 'browser_tabs.acquire' }).allowed;
|
|
1342
|
+
return Object.freeze({
|
|
1343
|
+
read,
|
|
1344
|
+
write,
|
|
1345
|
+
close,
|
|
1346
|
+
manage,
|
|
1347
|
+
// "Lead may take this page over" — true only when a takeover is currently possible.
|
|
1348
|
+
canAcquire: manage && isPlainObject(control) && control.state === CONTROL_STATE.OWNED,
|
|
1349
|
+
});
|
|
1350
|
+
}
|