dsh-agent-team-ui-testing 0.0.0-stage → 0.1.2
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 +327 -2
- package/annotation-capture.js +111 -0
- package/banner.png +0 -0
- package/banner_zh.png +0 -0
- package/browser-safety.js +293 -0
- package/capture-p0.js +910 -0
- package/client.js +11302 -0
- package/context-menu.js +134 -0
- package/cordis.patch.yml +3 -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 +146 -0
- package/docs/capability-tiers.md +80 -0
- package/docs/capture-p0.md +90 -0
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +143 -0
- package/docs/getting-started.md +136 -0
- package/docs/index.md +36 -0
- package/docs/multi-tab.md +162 -0
- package/docs/network-p0.md +117 -0
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +68 -0
- package/docs/t3-vision.md +84 -0
- package/docs/t4-dynamic-behavior.md +135 -0
- package/docs/tools.md +174 -0
- package/docs/verification.md +131 -0
- package/docs/viewport-control.md +94 -0
- package/dynamic-t4.js +5699 -0
- package/frame-push.js +508 -0
- package/frame-source.js +162 -0
- package/icon.png +0 -0
- package/index.js +23028 -0
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/network-p0.js +4264 -0
- package/package.json +19 -4
- 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-web/SKILL.md +520 -0
- package/skills/ui-testing-web/manifests/declared-capabilities.json +903 -0
- package/skills/ui-testing-web/references/contracts/capability.md +68 -0
- package/skills/ui-testing-web/references/contracts/evidence.md +41 -0
- package/skills/ui-testing-web/references/contracts/operation.md +44 -0
- package/skills/ui-testing-web/references/core/dynamic-visual.md +123 -0
- package/skills/ui-testing-web/references/core/fallback.md +83 -0
- package/skills/ui-testing-web/references/core/network-and-state.md +114 -0
- package/skills/ui-testing-web/references/core/page-identity.md +91 -0
- package/skills/ui-testing-web/references/core/report-template.md +144 -0
- package/skills/ui-testing-web/references/core/reporting.md +92 -0
- package/skills/ui-testing-web/references/core/safety.md +12 -0
- package/skills/ui-testing-web/references/core/selection.md +26 -0
- package/skills/ui-testing-web/references/core/tool-routing-and-limits.md +198 -0
- package/skills/ui-testing-web/references/core/verdict-examples.md +96 -0
- package/skills/ui-testing-web/references/core/verdict-gate.md +105 -0
- package/skills/ui-testing-web/references/core/verification.md +19 -0
- package/skills/ui-testing-web/references/core/workflow.md +16 -0
- package/skills/ui-testing-web/references/platforms/index.md +19 -0
- package/skills/ui-testing-web/references/platforms/web.md +17 -0
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/ui-testing-web/references/scenarios/functional.md +205 -0
- package/skills/ui-testing-web/references/scenarios/visual-motion.md +170 -0
- package/skills/ui-testing-web/references/tiers/t1-programmatic.md +25 -0
- package/skills/ui-testing-web/references/tiers/t2-structured.md +61 -0
- package/skills/ui-testing-web/references/tiers/t3-static-visual.md +45 -0
- package/skills/ui-testing-web/references/tiers/t4-dynamic-visual.md +58 -0
- package/skills/ui-testing-web/templates/capability-note.md +23 -0
- package/skills/ui-testing-web/templates/platform.md +30 -0
- package/skills/ui-testing-web/templates/regression-case.md +15 -0
- package/skills/ui-testing-web/templates/scenario.md +13 -0
- 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/vision-baseline.js +1254 -0
- package/vision-t3.js +2657 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted identity resolver: turns one `exec.agent` into a Principal, or refuses (M1).
|
|
3
|
+
*
|
|
4
|
+
* Why this file is separate from `core/tab-access.js`
|
|
5
|
+
* - `core/` answers "given a proven Principal, may this happen?". This file answers "may we believe
|
|
6
|
+
* this is who it claims to be?" — and that question can only be answered with native Host
|
|
7
|
+
* services (the Agent registry and the Team roster). Keeping the two apart means the permission
|
|
8
|
+
* rules are testable with zero Host, and the Host-dependent part has exactly one entry point.
|
|
9
|
+
*
|
|
10
|
+
* The three things this module must never do
|
|
11
|
+
* 1. **Never invent a root.** The root comes from `membership.root.id`. Not from cwd, not from the
|
|
12
|
+
* workspace, not from `session.header.parentSession`, not from "the deepest ancestor I can
|
|
13
|
+
* find". A guessed root is a guessed permission domain.
|
|
14
|
+
* 2. **Never degrade `undefined` into `standalone`.** `tryMembership === undefined` means "this
|
|
15
|
+
* probe could not prove a team" (stale object, non-Team worker, root briefly absent, caught
|
|
16
|
+
* malformed stream) — it does *not* prove "not in a team". Downgrading it would let a stale
|
|
17
|
+
* object found a private team of one and keep its pages; the rule is TAB_IDENTITY_UNAVAILABLE.
|
|
18
|
+
* A standalone domain is only ever granted when the host hands over an explicit proof.
|
|
19
|
+
* 3. **Never call `ctx.get` itself.** Every native service arrives through the injected seam
|
|
20
|
+
* (`{agentRegistry, teamService, standaloneProof}`), so the host composition root decides what
|
|
21
|
+
* identity means and tests can drive the matrix in-process without a Cordis context.
|
|
22
|
+
*
|
|
23
|
+
* Epoch bookkeeping (ruling §2.2/§7.1) — all versions here are *plugin* versions, never claimed to
|
|
24
|
+
* be native host revisions:
|
|
25
|
+
* - `executionEpoch` is per exact agent *object*. Registering a new object (including a
|
|
26
|
+
* same-Session resume) bumps it; the previous object is revoked forever. A re-used epoch would
|
|
27
|
+
* let a tail request from the dead instance run against the resumed one.
|
|
28
|
+
* - `membershipRevision` moves only when the trusted identity state actually changes: a different
|
|
29
|
+
* root, a different role, a different roster fingerprint, a different service instance, or an
|
|
30
|
+
* identity that was invalidated and came back. `running`/`inactive` never move it: a teammate
|
|
31
|
+
* thinking, or a turn ending, is not a membership change (plan §3.4 invariant 1).
|
|
32
|
+
* - `hostEpoch` is one random id per plugin boot, so nothing minted before a restart can be
|
|
33
|
+
* compared as current afterwards.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { randomUUID } from 'node:crypto';
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
AdapterError,
|
|
40
|
+
ERROR_CODES,
|
|
41
|
+
PRINCIPAL_ROLE,
|
|
42
|
+
PRINCIPAL_SOURCE,
|
|
43
|
+
makePrincipal,
|
|
44
|
+
} from '../core/index.js';
|
|
45
|
+
|
|
46
|
+
/** Roster row statuses that mean "this Session is not (yet) an acting member". */
|
|
47
|
+
const NON_MEMBER_STATUSES = new Set(['failed', 'provisioning']);
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isNonEmptyString(value) {
|
|
54
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One refusal code for every unprovable identity.
|
|
59
|
+
*
|
|
60
|
+
* They all collapse into TAB_IDENTITY_UNAVAILABLE on purpose: distinguishing "the object was stale"
|
|
61
|
+
* from "the roster does not list you" from "the root is not loaded" in the *public* code would tell
|
|
62
|
+
* a caller things about another Session's lifecycle. The reason stays in `details` for logs.
|
|
63
|
+
*/
|
|
64
|
+
function identityUnavailable(reason, details = {}) {
|
|
65
|
+
return new AdapterError(ERROR_CODES.TAB_IDENTITY_UNAVAILABLE, `identity unavailable: ${reason}`, {
|
|
66
|
+
details: Object.freeze({ reason, ...details }),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readNow(now) {
|
|
71
|
+
if (typeof now === 'function') return now();
|
|
72
|
+
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
73
|
+
return Date.now();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalize one roster row to "is this a currently valid member?".
|
|
78
|
+
*
|
|
79
|
+
* `running` and `inactive` both mean member: turn availability is orthogonal to membership, and
|
|
80
|
+
* treating "inactive" as "left the team" is the exact bug the plan forbids (an inactive teammate
|
|
81
|
+
* can still be handed responsibility by the Lead). `failed`/`provisioning` are excluded — one never
|
|
82
|
+
* ran, the other broke — and any future status is treated as a member so a host that adds a new
|
|
83
|
+
* runtime state does not silently evict everyone.
|
|
84
|
+
*
|
|
85
|
+
* @param {{id?:string, role?:string, status?:string}} row
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isValidRosterRow(row) {
|
|
89
|
+
if (!isPlainObject(row) || !isNonEmptyString(row.id)) return false;
|
|
90
|
+
if (typeof row.status === 'string' && NON_MEMBER_STATUSES.has(row.status)) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fingerprint of a roster: the sorted `(SessionId, role)` set of valid rows.
|
|
96
|
+
*
|
|
97
|
+
* This is what makes "did the membership actually change?" answerable without a native revision
|
|
98
|
+
* (the host exposes none). Sorted, so row order is not a change; turn activity is excluded, so a
|
|
99
|
+
* busy team is not a changed team.
|
|
100
|
+
*
|
|
101
|
+
* @param {Array<object>} rows
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function rosterFingerprint(rows) {
|
|
105
|
+
if (!Array.isArray(rows)) return 'none';
|
|
106
|
+
const parts = rows
|
|
107
|
+
.filter(isValidRosterRow)
|
|
108
|
+
.map((row) => `${row.id}|${typeof row.role === 'string' ? row.role : 'unknown'}`)
|
|
109
|
+
.sort();
|
|
110
|
+
return parts.length === 0 ? 'empty' : parts.join(',');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Identity registry: the stateful half of the resolver.
|
|
115
|
+
*
|
|
116
|
+
* One instance per plugin boot (the default instance below is that one). `hostEpoch` is random, so
|
|
117
|
+
* two boots never produce comparable versions.
|
|
118
|
+
*/
|
|
119
|
+
export class TabPrincipalResolver {
|
|
120
|
+
/**
|
|
121
|
+
* @param {{hostEpoch?:string, now?:number|Function}} [options]
|
|
122
|
+
*/
|
|
123
|
+
constructor({ hostEpoch = randomUUID(), now } = {}) {
|
|
124
|
+
if (!isNonEmptyString(hostEpoch)) {
|
|
125
|
+
throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'hostEpoch must be a non-empty string');
|
|
126
|
+
}
|
|
127
|
+
this.hostEpoch = hostEpoch;
|
|
128
|
+
this.now = typeof now === 'function' ? now : () => readNow(now);
|
|
129
|
+
/** @type {WeakMap<object, object>} exact agent object -> frozen record snapshot */
|
|
130
|
+
this.records = new WeakMap();
|
|
131
|
+
/** @type {WeakSet<object>} agent objects that must never resolve again */
|
|
132
|
+
this.revoked = new WeakSet();
|
|
133
|
+
/** @type {Map<string, object>} actorId -> per-Session bookkeeping (survives object replacement) */
|
|
134
|
+
this.actors = new Map();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Per-actor bookkeeping, created on first sight.
|
|
139
|
+
* @param {string} actorId
|
|
140
|
+
*/
|
|
141
|
+
actorState(actorId) {
|
|
142
|
+
let state = this.actors.get(actorId);
|
|
143
|
+
if (state === undefined) {
|
|
144
|
+
state = {
|
|
145
|
+
actorId,
|
|
146
|
+
executionEpoch: 0,
|
|
147
|
+
membershipRevision: 0,
|
|
148
|
+
rootId: null,
|
|
149
|
+
role: null,
|
|
150
|
+
fingerprint: null,
|
|
151
|
+
agentRegistry: null,
|
|
152
|
+
teamService: null,
|
|
153
|
+
activeAgent: null,
|
|
154
|
+
invalidated: false,
|
|
155
|
+
};
|
|
156
|
+
this.actors.set(actorId, state);
|
|
157
|
+
}
|
|
158
|
+
return state;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Permanent invalidation hook for `agent/disposed` (or any teardown the host knows about).
|
|
163
|
+
*
|
|
164
|
+
* The host does not provide a "session permanently deleted" event (ruling §3), so this only ever
|
|
165
|
+
* invalidates the *instance*: the root and the page ownership stay where they are, waiting for a
|
|
166
|
+
* legitimate recovery or an explicit close.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} agent exact agent object
|
|
169
|
+
* @returns {boolean} true when the object was known to this resolver
|
|
170
|
+
*/
|
|
171
|
+
invalidate(agent) {
|
|
172
|
+
if (!isPlainObject(agent) || !isNonEmptyString(agent.id)) return false;
|
|
173
|
+
const state = this.actors.get(agent.id);
|
|
174
|
+
this.revoked.add(agent);
|
|
175
|
+
if (state === undefined) return false;
|
|
176
|
+
if (state.activeAgent === agent) {
|
|
177
|
+
state.activeAgent = null;
|
|
178
|
+
// Coming back (a resume with a fresh object) is a trusted-identity state change, so the next
|
|
179
|
+
// resolution bumps membershipRevision and every observation made before is honestly stale.
|
|
180
|
+
state.invalidated = true;
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve one exact agent into a Principal.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} agent `exec.agent` — the only credential. It is never read from arguments,
|
|
189
|
+
* headers, query strings or `sessionId` parameters.
|
|
190
|
+
* @param {{agentRegistry?:object, teamService?:object, now?:number|Function,
|
|
191
|
+
* standaloneProof?:Function}} deps injected native services
|
|
192
|
+
* @returns {Readonly<object>} branded Principal
|
|
193
|
+
* @throws {AdapterError} TAB_IDENTITY_UNAVAILABLE when any step cannot be proven
|
|
194
|
+
*/
|
|
195
|
+
resolve(agent, deps = {}) {
|
|
196
|
+
const { agentRegistry, teamService, standaloneProof } = deps;
|
|
197
|
+
const now = readNow(deps.now ?? this.now);
|
|
198
|
+
|
|
199
|
+
if (!isPlainObject(agent) || !isNonEmptyString(agent.id)) {
|
|
200
|
+
throw identityUnavailable('agent-identity-missing');
|
|
201
|
+
}
|
|
202
|
+
const actorId = agent.id;
|
|
203
|
+
if (this.revoked.has(agent)) {
|
|
204
|
+
// The object was disposed, replaced, or superseded. It never becomes valid again.
|
|
205
|
+
throw identityUnavailable('actor-instance-revoked', { actorSessionId: actorId });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 1. Exact object check. `get(id)` returning a *different* object is the normal shape of a
|
|
209
|
+
// stale reference; `get(id)` returning undefined means disposed. Both are refusals, never a
|
|
210
|
+
// "close enough" match on the id string.
|
|
211
|
+
if (!isPlainObject(agentRegistry) || typeof agentRegistry.get !== 'function') {
|
|
212
|
+
throw identityUnavailable('agent-registry-unavailable', { actorSessionId: actorId });
|
|
213
|
+
}
|
|
214
|
+
let registered;
|
|
215
|
+
try {
|
|
216
|
+
registered = agentRegistry.get(actorId);
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
throw new AdapterError(ERROR_CODES.TAB_IDENTITY_UNAVAILABLE, 'identity unavailable: agent-registry-failed', {
|
|
219
|
+
details: { reason: 'agent-registry-failed', actorSessionId: actorId },
|
|
220
|
+
cause,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (registered === undefined) {
|
|
224
|
+
throw identityUnavailable('agent-not-registered', { actorSessionId: actorId });
|
|
225
|
+
}
|
|
226
|
+
if (registered !== agent) {
|
|
227
|
+
throw identityUnavailable('agent-object-mismatch', { actorSessionId: actorId });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2. Native membership. `tryMembership` is the non-throwing probe the host documents for
|
|
231
|
+
// lifecycle observers; `membership` throws TeamError and is the fallback.
|
|
232
|
+
const membership = this.readMembership(teamService, agent);
|
|
233
|
+
let rootId;
|
|
234
|
+
let role;
|
|
235
|
+
let fingerprint;
|
|
236
|
+
let rows = null;
|
|
237
|
+
|
|
238
|
+
if (membership === undefined) {
|
|
239
|
+
// No native membership proof. The only acceptable alternative is an explicit host-provided
|
|
240
|
+
// standalone proof; otherwise this is TAB_IDENTITY_UNAVAILABLE, not a new domain.
|
|
241
|
+
const proof = this.readStandaloneProof(standaloneProof, agent, { agentRegistry, teamService });
|
|
242
|
+
if (proof === null) {
|
|
243
|
+
throw identityUnavailable('membership-undefined', { actorSessionId: actorId });
|
|
244
|
+
}
|
|
245
|
+
rootId = actorId;
|
|
246
|
+
role = PRINCIPAL_ROLE.STANDALONE;
|
|
247
|
+
fingerprint = 'standalone';
|
|
248
|
+
} else {
|
|
249
|
+
if (!isPlainObject(membership) || !isPlainObject(membership.root)) {
|
|
250
|
+
throw identityUnavailable('membership-malformed', { actorSessionId: actorId });
|
|
251
|
+
}
|
|
252
|
+
if (membership.role !== PRINCIPAL_ROLE.LEAD && membership.role !== PRINCIPAL_ROLE.TEAMMATE) {
|
|
253
|
+
throw identityUnavailable('membership-role-unknown', {
|
|
254
|
+
actorSessionId: actorId,
|
|
255
|
+
role: typeof membership.role === 'string' ? membership.role : null,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// 3. The returned root must itself still be the exact registry object. A root that is only
|
|
259
|
+
// equal by id is a stale or rebuilt object, and permissions derived from it would outlive
|
|
260
|
+
// the instance that granted them.
|
|
261
|
+
if (!isNonEmptyString(membership.root.id)) {
|
|
262
|
+
throw identityUnavailable('membership-root-missing', { actorSessionId: actorId });
|
|
263
|
+
}
|
|
264
|
+
if (agentRegistry.get(membership.root.id) !== membership.root) {
|
|
265
|
+
throw identityUnavailable('root-object-mismatch', {
|
|
266
|
+
actorSessionId: actorId,
|
|
267
|
+
rootSessionId: membership.root.id,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
rootId = membership.root.id;
|
|
271
|
+
role = membership.role;
|
|
272
|
+
|
|
273
|
+
// 4. A teammate additionally needs a *fresh* roster row. Being named in a stale membership is
|
|
274
|
+
// not enough: a failed/provisioning/removed row must not keep page rights.
|
|
275
|
+
if (role === PRINCIPAL_ROLE.TEAMMATE) {
|
|
276
|
+
rows = this.readRoster(teamService, membership.root, { actorSessionId: actorId, rootSessionId: rootId });
|
|
277
|
+
const row = rows.find((candidate) => isPlainObject(candidate) && candidate.id === actorId);
|
|
278
|
+
if (row === undefined) {
|
|
279
|
+
throw identityUnavailable('teammate-not-in-roster', {
|
|
280
|
+
actorSessionId: actorId,
|
|
281
|
+
rootSessionId: rootId,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (!isValidRosterRow(row)) {
|
|
285
|
+
throw identityUnavailable('teammate-row-invalid', {
|
|
286
|
+
actorSessionId: actorId,
|
|
287
|
+
rootSessionId: rootId,
|
|
288
|
+
status: typeof row.status === 'string' ? row.status : null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
// The Lead's own domain: read the roster too, so a membership change moves the revision
|
|
293
|
+
// even when only teammates changed (a transferred page must see that change as stale).
|
|
294
|
+
rows = this.readRoster(teamService, membership.root, { actorSessionId: actorId, rootSessionId: rootId });
|
|
295
|
+
}
|
|
296
|
+
fingerprint = rosterFingerprint(rows);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 5. Version bookkeeping.
|
|
300
|
+
const state = this.actorState(actorId);
|
|
301
|
+
const previous = this.records.get(agent);
|
|
302
|
+
let executionEpoch;
|
|
303
|
+
if (previous !== undefined && state.activeAgent === agent) {
|
|
304
|
+
executionEpoch = previous.executionEpoch;
|
|
305
|
+
} else {
|
|
306
|
+
if (state.activeAgent !== null && state.activeAgent !== agent) {
|
|
307
|
+
// A newer instance of the same Session exists: the older object is dead from now on, even
|
|
308
|
+
// if the host later hands it back to us (resume of a resume).
|
|
309
|
+
this.revoked.add(state.activeAgent);
|
|
310
|
+
}
|
|
311
|
+
state.executionEpoch += 1;
|
|
312
|
+
state.activeAgent = agent;
|
|
313
|
+
executionEpoch = state.executionEpoch;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const identityStateChanged = state.rootId !== rootId
|
|
317
|
+
|| state.role !== role
|
|
318
|
+
|| state.fingerprint !== fingerprint
|
|
319
|
+
|| state.agentRegistry !== (agentRegistry ?? null)
|
|
320
|
+
|| state.teamService !== (teamService ?? null)
|
|
321
|
+
|| state.invalidated === true;
|
|
322
|
+
if (state.membershipRevision === 0 || identityStateChanged) state.membershipRevision += 1;
|
|
323
|
+
state.rootId = rootId;
|
|
324
|
+
state.role = role;
|
|
325
|
+
state.fingerprint = fingerprint;
|
|
326
|
+
state.agentRegistry = agentRegistry ?? null;
|
|
327
|
+
state.teamService = teamService ?? null;
|
|
328
|
+
state.invalidated = false;
|
|
329
|
+
|
|
330
|
+
const record = Object.freeze({
|
|
331
|
+
actorId,
|
|
332
|
+
exactAgent: agent,
|
|
333
|
+
rootId,
|
|
334
|
+
role,
|
|
335
|
+
rosterFingerprint: fingerprint,
|
|
336
|
+
membershipRevision: state.membershipRevision,
|
|
337
|
+
executionEpoch,
|
|
338
|
+
source: PRINCIPAL_SOURCE.AGENT,
|
|
339
|
+
hostEpoch: this.hostEpoch,
|
|
340
|
+
status: 'active',
|
|
341
|
+
resolvedAt: now,
|
|
342
|
+
});
|
|
343
|
+
this.records.set(agent, record);
|
|
344
|
+
|
|
345
|
+
return makePrincipal({
|
|
346
|
+
actorSessionId: actorId,
|
|
347
|
+
rootSessionId: rootId,
|
|
348
|
+
role,
|
|
349
|
+
membershipRevision: record.membershipRevision,
|
|
350
|
+
executionEpoch: record.executionEpoch,
|
|
351
|
+
source: PRINCIPAL_SOURCE.AGENT,
|
|
352
|
+
hostEpoch: record.hostEpoch,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Last resolved record for one agent object (diagnostics only; never an authorisation input).
|
|
358
|
+
* @param {object} agent
|
|
359
|
+
* @returns {Readonly<object>|null}
|
|
360
|
+
*/
|
|
361
|
+
recordFor(agent) {
|
|
362
|
+
if (!isPlainObject(agent)) return null;
|
|
363
|
+
return this.records.get(agent) ?? null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Content-free summary for readiness/diagnostics: no ids, no roster, just counters and the boot.
|
|
368
|
+
* @returns {{hostEpoch:string, actors:number}}
|
|
369
|
+
*/
|
|
370
|
+
summary() {
|
|
371
|
+
return { hostEpoch: this.hostEpoch, actors: this.actors.size };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Call the native membership probe.
|
|
376
|
+
*
|
|
377
|
+
* @returns {object|undefined} the membership, or undefined when the host could not prove one
|
|
378
|
+
*/
|
|
379
|
+
readMembership(teamService, agent) {
|
|
380
|
+
if (!isPlainObject(teamService)) return undefined;
|
|
381
|
+
const { tryMembership, membership } = teamService;
|
|
382
|
+
if (typeof tryMembership === 'function') {
|
|
383
|
+
try {
|
|
384
|
+
return tryMembership.call(teamService, agent);
|
|
385
|
+
} catch {
|
|
386
|
+
// A throwing probe is "could not prove", not "proved false".
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (typeof membership === 'function') {
|
|
391
|
+
try {
|
|
392
|
+
return membership.call(teamService, agent);
|
|
393
|
+
} catch {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Refresh the roster from the native service. Missing or throwing service ⇒ refusal, because an
|
|
402
|
+
* unreadable roster cannot establish that a teammate is still one.
|
|
403
|
+
*
|
|
404
|
+
* @returns {Array<object>}
|
|
405
|
+
*/
|
|
406
|
+
readRoster(teamService, rootAgent, context) {
|
|
407
|
+
if (!isPlainObject(teamService) || typeof teamService.listMembers !== 'function') {
|
|
408
|
+
throw identityUnavailable('roster-unavailable', context);
|
|
409
|
+
}
|
|
410
|
+
let rows;
|
|
411
|
+
try {
|
|
412
|
+
rows = teamService.listMembers.call(teamService, rootAgent);
|
|
413
|
+
} catch (cause) {
|
|
414
|
+
throw new AdapterError(ERROR_CODES.TAB_IDENTITY_UNAVAILABLE, 'identity unavailable: roster-failed', {
|
|
415
|
+
details: { reason: 'roster-failed', ...context },
|
|
416
|
+
cause,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (!Array.isArray(rows)) throw identityUnavailable('roster-malformed', context);
|
|
420
|
+
return rows;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Explicit standalone proof.
|
|
425
|
+
*
|
|
426
|
+
* The ruling allows a standalone domain only when the *host* says so (there is no native Team
|
|
427
|
+
* service to ask). The proof is a callback, never a parameter a model can fill in: it must return
|
|
428
|
+
* a truthy value, and if it names a root that root must be the actor itself.
|
|
429
|
+
*
|
|
430
|
+
* @returns {string|null} the proved root id, or null when there is no proof
|
|
431
|
+
*/
|
|
432
|
+
readStandaloneProof(standaloneProof, agent, context) {
|
|
433
|
+
if (typeof standaloneProof !== 'function') return null;
|
|
434
|
+
let proof;
|
|
435
|
+
try {
|
|
436
|
+
proof = standaloneProof(agent, context);
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
if (proof === false || proof === null || proof === undefined) return null;
|
|
441
|
+
if (proof === true) return agent.id;
|
|
442
|
+
if (typeof proof === 'string') return proof === agent.id ? proof : null;
|
|
443
|
+
if (isPlainObject(proof)) {
|
|
444
|
+
const root = proof.rootSessionId;
|
|
445
|
+
if (root === undefined) return agent.id;
|
|
446
|
+
return root === agent.id ? root : null;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Process-wide resolver used by `resolvePrincipal` when no instance is injected.
|
|
454
|
+
*
|
|
455
|
+
* One instance per boot is the point: `hostEpoch` must not change between two calls of the same
|
|
456
|
+
* process, otherwise every observation would be "stale" the moment it is compared.
|
|
457
|
+
*/
|
|
458
|
+
const DEFAULT_RESOLVER = new TabPrincipalResolver();
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolve one `exec.agent` into a Principal through the injected seam.
|
|
462
|
+
*
|
|
463
|
+
* Deliberately a plain function with explicit dependencies: the Host composition root passes
|
|
464
|
+
* `{agentRegistry, teamService, now}` (and, only if the host really has no Team service, an
|
|
465
|
+
* explicit `standaloneProof`), and tests pass fakes. Nothing here reaches for a Cordis context.
|
|
466
|
+
*
|
|
467
|
+
* @param {object} agent
|
|
468
|
+
* @param {{agentRegistry?:object, teamService?:object, now?:number|Function,
|
|
469
|
+
* standaloneProof?:Function, identityResolver?:TabPrincipalResolver}} [deps]
|
|
470
|
+
* @returns {Readonly<object>} branded Principal
|
|
471
|
+
* @throws {AdapterError} TAB_IDENTITY_UNAVAILABLE (or INVALID_ARGUMENT for a malformed seam)
|
|
472
|
+
*/
|
|
473
|
+
export function resolvePrincipal(agent, deps = {}) {
|
|
474
|
+
const resolver = deps.identityResolver ?? DEFAULT_RESOLVER;
|
|
475
|
+
if (typeof resolver?.resolve !== 'function') {
|
|
476
|
+
throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'identityResolver must expose resolve()');
|
|
477
|
+
}
|
|
478
|
+
return resolver.resolve(agent, deps);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Create an independent resolver (one per plugin boot).
|
|
483
|
+
* @param {{hostEpoch?:string, now?:number|Function}} [options]
|
|
484
|
+
* @returns {TabPrincipalResolver}
|
|
485
|
+
*/
|
|
486
|
+
export function createTabPrincipalResolver(options = {}) {
|
|
487
|
+
return new TabPrincipalResolver(options);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The process-wide instance, for Host wiring that needs `invalidate()` on agent/disposed. */
|
|
491
|
+
export function defaultTabPrincipalResolver() {
|
|
492
|
+
return DEFAULT_RESOLVER;
|
|
493
|
+
}
|
package/locale/en.json
ADDED