instar 1.3.583 → 1.3.584
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/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +13 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/PlaywrightProfileRegistry.d.ts +269 -0
- package/dist/core/PlaywrightProfileRegistry.d.ts.map +1 -0
- package/dist/core/PlaywrightProfileRegistry.js +640 -0
- package/dist/core/PlaywrightProfileRegistry.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts +21 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +195 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +13 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +8 -1
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +8 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +341 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/scaffold/templates.ts +9 -1
- package/upgrades/1.3.584.md +84 -0
- package/upgrades/side-effects/playwright-profile-registry.md +140 -0
- package/upgrades/1.3.583.md +0 -51
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlaywrightProfileRegistry — the durable per-agent map of which Playwright
|
|
3
|
+
* browser PROFILE holds which logged-in ACCOUNT, plus the compact boot-awareness
|
|
4
|
+
* surface and the shared playwright-MCP-config resolver.
|
|
5
|
+
*
|
|
6
|
+
* Spec: docs/specs/playwright-profile-registry.md (converged + approved 2026-06-15).
|
|
7
|
+
*
|
|
8
|
+
* Three honesty disciplines are load-bearing (see spec Frontloaded Decisions):
|
|
9
|
+
* - STALENESS (D11): a login claim renders its age and is advisory, never authority.
|
|
10
|
+
* - PROVENANCE (D12/D20): every account carries an owner (agent|operator) and every
|
|
11
|
+
* write is auditable; the operator's account is flagged loud in the block.
|
|
12
|
+
* - FAIL-TOWARD-TRUTH (D15/D17): refs are re-checked on read, a dead profile dir is
|
|
13
|
+
* surfaced, a corrupt file is never silently overwritten.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY INVARIANTS:
|
|
16
|
+
* - D3 — NO secret VALUE is ever stored, returned, injected, or resolved. Only vault
|
|
17
|
+
* secret NAMES. This module NEVER calls SecretStore.read() to obtain a value — the
|
|
18
|
+
* injected `listVaultNames` returns NAMES only.
|
|
19
|
+
* - D9 — a supplied userDataDir is path-jailed: resolved, absolute, confined under the
|
|
20
|
+
* agent home, never flag-shaped, no NUL.
|
|
21
|
+
* - D16 — every field rendered into the boot block passes through sanitizeForBlock so
|
|
22
|
+
* an envelope breakout is structurally impossible.
|
|
23
|
+
*
|
|
24
|
+
* NOTE: request-driven; no background loop → NO GUARD_MANIFEST entry. The state file is
|
|
25
|
+
* machine-local BY DESIGN (D6) — a browser session lives in cookies on one disk.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
// Reuse the EXACT boot-block sanitizer (control-char/ANSI strip + angle-bracket escape
|
|
31
|
+
// + backtick neutralize) so the two boot surfaces can never drift (D16).
|
|
32
|
+
import { sanitizeForBlock } from './BootSelfKnowledge.js';
|
|
33
|
+
/** Thrown when the registry file exists but is unparseable — writes fail CLOSED (D15). */
|
|
34
|
+
export class PlaywrightRegistryCorruptError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'PlaywrightRegistryCorruptError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Thrown for caller-input validation failures (the route maps these to 400/409/422). */
|
|
41
|
+
export class PlaywrightRegistryError extends Error {
|
|
42
|
+
status;
|
|
43
|
+
constructor(message, status = 400) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'PlaywrightRegistryError';
|
|
46
|
+
this.status = status;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ── Caps & charsets (D13) ──────────────────────────────────────────────────────
|
|
50
|
+
export const MAX_PROFILES = 25;
|
|
51
|
+
export const MAX_ACCOUNTS_PER_PROFILE = 25;
|
|
52
|
+
export const MAX_DESCRIPTION_CHARS = 256;
|
|
53
|
+
export const MAX_SERVICE_CHARS = 64;
|
|
54
|
+
export const MAX_IDENTITY_CHARS = 128;
|
|
55
|
+
export const MAX_NOTE_CHARS = 256;
|
|
56
|
+
const PROFILE_ID_RE = /^[a-z0-9-]{1,64}$/;
|
|
57
|
+
const MUTATE_CAS_MAX_RETRIES = 5;
|
|
58
|
+
export const DEFAULT_BLOCK_MAX_BYTES = 800;
|
|
59
|
+
const LOGIN_METHODS = [
|
|
60
|
+
'session-cookie',
|
|
61
|
+
'password',
|
|
62
|
+
'password+totp',
|
|
63
|
+
'password+phone-2fa',
|
|
64
|
+
'oauth-token',
|
|
65
|
+
'unknown',
|
|
66
|
+
];
|
|
67
|
+
export class PlaywrightProfileRegistry {
|
|
68
|
+
stateDir;
|
|
69
|
+
projectDir;
|
|
70
|
+
listVaultNames;
|
|
71
|
+
hostname;
|
|
72
|
+
constructor(opts) {
|
|
73
|
+
this.stateDir = opts.stateDir;
|
|
74
|
+
this.projectDir = path.resolve(opts.projectDir);
|
|
75
|
+
this.listVaultNames = opts.listVaultNames;
|
|
76
|
+
this.hostname = opts.hostname ?? os.hostname();
|
|
77
|
+
}
|
|
78
|
+
/** Absolute path to the registry file. */
|
|
79
|
+
filePath() {
|
|
80
|
+
return path.resolve(path.join(this.stateDir, 'state', 'playwright-profiles.json'));
|
|
81
|
+
}
|
|
82
|
+
// ── Shared playwright-MCP-config resolver (S1/F2) ────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Locate the canonical playwright MCP server entry, checking `.claude/settings.json`
|
|
85
|
+
* `mcpServers.playwright` FIRST (authoritative — init/migrator seed it there), then
|
|
86
|
+
* `.mcp.json` `mcpServers.playwright`. Returns the file + entry + resolved
|
|
87
|
+
* --user-data-dir (null when absent — the common case, D10), or null when no playwright
|
|
88
|
+
* server is configured anywhere. Used by BOTH seed and activate so they cannot drift.
|
|
89
|
+
*/
|
|
90
|
+
resolvePlaywrightMcpConfig() {
|
|
91
|
+
const candidates = [
|
|
92
|
+
path.join(this.projectDir, '.claude', 'settings.json'),
|
|
93
|
+
path.join(this.projectDir, '.mcp.json'),
|
|
94
|
+
];
|
|
95
|
+
for (const file of candidates) {
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
if (!fs.existsSync(file))
|
|
99
|
+
continue;
|
|
100
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// @silent-fallback-ok — an unparseable config file just means "not here"; we try the next candidate.
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const mcpServers = parsed?.mcpServers;
|
|
107
|
+
const entry = mcpServers?.playwright;
|
|
108
|
+
if (entry && typeof entry === 'object') {
|
|
109
|
+
return {
|
|
110
|
+
file: path.resolve(file),
|
|
111
|
+
entry: entry,
|
|
112
|
+
userDataDir: extractUserDataDir(entry.args),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
// ── Load / seed ──────────────────────────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Read + parse the registry file. Absent → seed exactly ONE default profile
|
|
121
|
+
* (metadata-only; NEVER writes MCP config — D10 seeding). Corrupt → throw
|
|
122
|
+
* PlaywrightRegistryCorruptError (the WRITE path fails CLOSED; the boot block
|
|
123
|
+
* swallows it — D15).
|
|
124
|
+
*/
|
|
125
|
+
read() {
|
|
126
|
+
const file = this.filePath();
|
|
127
|
+
if (!fs.existsSync(file)) {
|
|
128
|
+
return this.seedSkeleton();
|
|
129
|
+
}
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
throw new PlaywrightRegistryCorruptError(`registry file unreadable: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(raw);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new PlaywrightRegistryCorruptError('registry file corrupt — will not overwrite');
|
|
143
|
+
}
|
|
144
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.profiles)) {
|
|
145
|
+
throw new PlaywrightRegistryCorruptError('registry file corrupt — will not overwrite');
|
|
146
|
+
}
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
/** Compute the in-memory seed (ONE default profile). Does NOT persist or touch MCP config. */
|
|
150
|
+
seedSkeleton() {
|
|
151
|
+
const resolved = this.resolvePlaywrightMcpConfig();
|
|
152
|
+
// userDataDir from the resolved --user-data-dir arg if present, ELSE null (D10).
|
|
153
|
+
// NEVER assert .playwright-mcp (that is the MCP output-dir, not the browser profile).
|
|
154
|
+
const userDataDir = resolved?.userDataDir ?? null;
|
|
155
|
+
return {
|
|
156
|
+
version: 1,
|
|
157
|
+
profiles: [
|
|
158
|
+
{
|
|
159
|
+
id: 'default',
|
|
160
|
+
userDataDir,
|
|
161
|
+
description: 'Default browser profile.',
|
|
162
|
+
isDefault: true,
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
accounts: [],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Ensure the file exists on disk with the seeded default (idempotent). Returns the seeded store. */
|
|
170
|
+
ensureSeeded() {
|
|
171
|
+
const file = this.filePath();
|
|
172
|
+
if (fs.existsSync(file)) {
|
|
173
|
+
// Surfaces a corrupt file (caller decides) — never auto-overwrites.
|
|
174
|
+
return this.read();
|
|
175
|
+
}
|
|
176
|
+
const seeded = this.seedSkeleton();
|
|
177
|
+
this.write(seeded);
|
|
178
|
+
return seeded;
|
|
179
|
+
}
|
|
180
|
+
write(store) {
|
|
181
|
+
const file = this.filePath();
|
|
182
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
183
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
184
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n');
|
|
185
|
+
fs.renameSync(tmp, file);
|
|
186
|
+
}
|
|
187
|
+
// ── Single-writer CAS (D14) ──────────────────────────────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* Read-version → apply → write-if-unchanged → retry. Mirrors CommitmentTracker.mutate's
|
|
190
|
+
* optimistic CAS, but file-backed: the "version" is the on-disk file's (mtimeMs, size)
|
|
191
|
+
* captured BEFORE the read; if it changed by write time, retry. A corrupt file throws
|
|
192
|
+
* (PlaywrightRegistryCorruptError) — the write path NEVER auto-overwrites (D15).
|
|
193
|
+
*
|
|
194
|
+
* `fn` receives a deep clone of the store and returns the next store plus a result.
|
|
195
|
+
*/
|
|
196
|
+
mutate(fn) {
|
|
197
|
+
const file = this.filePath();
|
|
198
|
+
let attempt = 0;
|
|
199
|
+
while (attempt <= MUTATE_CAS_MAX_RETRIES) {
|
|
200
|
+
const before = statSig(file);
|
|
201
|
+
// read() seeds an in-memory skeleton when absent; corrupt throws (fail closed).
|
|
202
|
+
const store = this.read();
|
|
203
|
+
const clone = JSON.parse(JSON.stringify(store));
|
|
204
|
+
const { next, result } = fn(clone);
|
|
205
|
+
const after = statSig(file);
|
|
206
|
+
if (before.mtimeMs !== after.mtimeMs || before.size !== after.size) {
|
|
207
|
+
// The file drifted under us between read and write-decision — retry on a fresh read.
|
|
208
|
+
attempt++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
this.write(next);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
throw new PlaywrightRegistryError('playwright-profile-registry: CAS retry budget exhausted (concurrent writers)', 503);
|
|
215
|
+
}
|
|
216
|
+
// ── Reads ─────────────────────────────────────────────────────────────────────
|
|
217
|
+
/** The FULL detail surface (GET-only): every account, vault NAMES, dangling-ref flags (D17). */
|
|
218
|
+
listProfiles() {
|
|
219
|
+
const store = this.ensureSeeded();
|
|
220
|
+
const liveNames = this.safeVaultNames();
|
|
221
|
+
return store.profiles.map((p) => ({
|
|
222
|
+
...p,
|
|
223
|
+
dirExists: this.dirExists(p.userDataDir),
|
|
224
|
+
accounts: p.accounts.map((a) => ({
|
|
225
|
+
...a,
|
|
226
|
+
danglingRefs: liveNames === null
|
|
227
|
+
? [] // vault unreadable on READ → best-effort; do not assert dangling (D17 read path)
|
|
228
|
+
: a.vaultRefs.filter((r) => !liveNames.includes(r)),
|
|
229
|
+
})),
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The selector (D18). Precedence: exact (service,identity) → else service-only.
|
|
234
|
+
* Service-only matching >1 profile → { profile:null, ambiguous:true, candidates }.
|
|
235
|
+
* No match → { profile:null }. dirExists reports whether the profile's userDataDir is
|
|
236
|
+
* physically present (null userDataDir → built-in present).
|
|
237
|
+
*/
|
|
238
|
+
resolve(service, identity) {
|
|
239
|
+
const store = this.ensureSeeded();
|
|
240
|
+
const svc = String(service);
|
|
241
|
+
if (identity !== undefined && identity !== null && identity !== '') {
|
|
242
|
+
const id = String(identity);
|
|
243
|
+
const exact = store.profiles.find((p) => p.accounts.some((a) => a.service === svc && a.identity === id));
|
|
244
|
+
if (exact)
|
|
245
|
+
return { profile: exact, dirExists: this.dirExists(exact.userDataDir) };
|
|
246
|
+
// fall through to service-only
|
|
247
|
+
}
|
|
248
|
+
const svcMatches = store.profiles.filter((p) => p.accounts.some((a) => a.service === svc));
|
|
249
|
+
if (svcMatches.length === 1) {
|
|
250
|
+
return { profile: svcMatches[0], dirExists: this.dirExists(svcMatches[0].userDataDir) };
|
|
251
|
+
}
|
|
252
|
+
if (svcMatches.length > 1) {
|
|
253
|
+
return {
|
|
254
|
+
profile: null,
|
|
255
|
+
ambiguous: true,
|
|
256
|
+
candidates: svcMatches.map((p) => ({
|
|
257
|
+
id: p.id,
|
|
258
|
+
identities: p.accounts.filter((a) => a.service === svc).map((a) => a.identity),
|
|
259
|
+
})),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { profile: null };
|
|
263
|
+
}
|
|
264
|
+
// ── CRUD (all through mutate → CAS) ─────────────────────────────────────────────
|
|
265
|
+
createProfile(input) {
|
|
266
|
+
const id = String(input.id ?? '');
|
|
267
|
+
if (!PROFILE_ID_RE.test(id)) {
|
|
268
|
+
throw new PlaywrightRegistryError(`invalid profile id (must match ${PROFILE_ID_RE})`, 400);
|
|
269
|
+
}
|
|
270
|
+
const description = sanitizeStored(input.description ?? '', MAX_DESCRIPTION_CHARS);
|
|
271
|
+
// Jail (D9) or auto-allocate.
|
|
272
|
+
const userDataDir = input.userDataDir === undefined || input.userDataDir === null
|
|
273
|
+
? this.autoAllocateDir(id)
|
|
274
|
+
: this.jailUserDataDir(input.userDataDir);
|
|
275
|
+
return this.mutate((store) => {
|
|
276
|
+
if (store.profiles.some((p) => p.id === id)) {
|
|
277
|
+
throw new PlaywrightRegistryError(`profile '${id}' already exists`, 409);
|
|
278
|
+
}
|
|
279
|
+
if (store.profiles.length >= MAX_PROFILES) {
|
|
280
|
+
throw new PlaywrightRegistryError(`maxProfiles=${MAX_PROFILES} reached`, 422);
|
|
281
|
+
}
|
|
282
|
+
const profile = {
|
|
283
|
+
id,
|
|
284
|
+
userDataDir,
|
|
285
|
+
description,
|
|
286
|
+
isDefault: false,
|
|
287
|
+
createdAt: new Date().toISOString(),
|
|
288
|
+
accounts: [],
|
|
289
|
+
};
|
|
290
|
+
store.profiles.push(profile);
|
|
291
|
+
return { next: store, result: profile };
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
assignAccount(profileId, input) {
|
|
295
|
+
const service = sanitizeStored(input.service ?? '', MAX_SERVICE_CHARS);
|
|
296
|
+
const identity = sanitizeStored(input.identity ?? '', MAX_IDENTITY_CHARS);
|
|
297
|
+
if (!service)
|
|
298
|
+
throw new PlaywrightRegistryError('service is required', 400);
|
|
299
|
+
if (!identity)
|
|
300
|
+
throw new PlaywrightRegistryError('identity is required', 400);
|
|
301
|
+
if (input.owner !== 'agent' && input.owner !== 'operator') {
|
|
302
|
+
throw new PlaywrightRegistryError("owner is required and must be 'agent' or 'operator'", 400);
|
|
303
|
+
}
|
|
304
|
+
const loginMethod = LOGIN_METHODS.includes(input.loginMethod)
|
|
305
|
+
? input.loginMethod
|
|
306
|
+
: 'unknown';
|
|
307
|
+
const note = sanitizeStored(input.note ?? '', MAX_NOTE_CHARS);
|
|
308
|
+
const vaultRefs = Array.isArray(input.vaultRefs) ? input.vaultRefs.map((r) => String(r)) : [];
|
|
309
|
+
// Ref-validation FAILS CLOSED if vault names are unreadable (D17).
|
|
310
|
+
const liveNames = this.listVaultNames();
|
|
311
|
+
if (liveNames === null) {
|
|
312
|
+
throw new PlaywrightRegistryError('vault names unreadable (absent or decrypt-failed) — refusing to assign refs', 409);
|
|
313
|
+
}
|
|
314
|
+
const unknown = vaultRefs.filter((r) => !liveNames.includes(r));
|
|
315
|
+
if (unknown.length > 0) {
|
|
316
|
+
throw new PlaywrightRegistryError(`unknown vault ref(s): ${unknown.join(', ')}`, 409);
|
|
317
|
+
}
|
|
318
|
+
return this.mutate((store) => {
|
|
319
|
+
const profile = store.profiles.find((p) => p.id === profileId);
|
|
320
|
+
if (!profile)
|
|
321
|
+
throw new PlaywrightRegistryError(`profile '${profileId}' not found`, 404);
|
|
322
|
+
const existing = profile.accounts.find((a) => a.service === service && a.identity === identity);
|
|
323
|
+
const account = {
|
|
324
|
+
service,
|
|
325
|
+
identity,
|
|
326
|
+
owner: input.owner,
|
|
327
|
+
vaultRefs,
|
|
328
|
+
loginMethod,
|
|
329
|
+
lastAsserted: existing?.lastAsserted ?? false,
|
|
330
|
+
lastVerifiedAt: existing?.lastVerifiedAt ?? null,
|
|
331
|
+
note,
|
|
332
|
+
};
|
|
333
|
+
if (existing) {
|
|
334
|
+
// Idempotent on (service, identity) — replace in place.
|
|
335
|
+
const idx = profile.accounts.indexOf(existing);
|
|
336
|
+
profile.accounts[idx] = account;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
if (profile.accounts.length >= MAX_ACCOUNTS_PER_PROFILE) {
|
|
340
|
+
throw new PlaywrightRegistryError(`maxAccountsPerProfile=${MAX_ACCOUNTS_PER_PROFILE} reached`, 422);
|
|
341
|
+
}
|
|
342
|
+
profile.accounts.push(account);
|
|
343
|
+
}
|
|
344
|
+
return { next: store, result: account };
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
patchAccount(profileId, service, identity, patch) {
|
|
348
|
+
return this.mutate((store) => {
|
|
349
|
+
const profile = store.profiles.find((p) => p.id === profileId);
|
|
350
|
+
if (!profile)
|
|
351
|
+
throw new PlaywrightRegistryError(`profile '${profileId}' not found`, 404);
|
|
352
|
+
const account = profile.accounts.find((a) => a.service === service && a.identity === identity);
|
|
353
|
+
if (!account) {
|
|
354
|
+
throw new PlaywrightRegistryError(`account (${service}, ${identity}) not found`, 404);
|
|
355
|
+
}
|
|
356
|
+
if (patch.lastAsserted !== undefined)
|
|
357
|
+
account.lastAsserted = !!patch.lastAsserted;
|
|
358
|
+
if (patch.lastVerifiedAt !== undefined) {
|
|
359
|
+
account.lastVerifiedAt = patch.lastVerifiedAt === null ? null : String(patch.lastVerifiedAt);
|
|
360
|
+
}
|
|
361
|
+
if (patch.note !== undefined)
|
|
362
|
+
account.note = sanitizeStored(patch.note, MAX_NOTE_CHARS);
|
|
363
|
+
return { next: store, result: account };
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
deleteProfile(profileId) {
|
|
367
|
+
this.mutate((store) => {
|
|
368
|
+
const profile = store.profiles.find((p) => p.id === profileId);
|
|
369
|
+
if (!profile)
|
|
370
|
+
throw new PlaywrightRegistryError(`profile '${profileId}' not found`, 404);
|
|
371
|
+
if (profile.isDefault)
|
|
372
|
+
throw new PlaywrightRegistryError('cannot delete the default profile', 409);
|
|
373
|
+
store.profiles = store.profiles.filter((p) => p.id !== profileId);
|
|
374
|
+
return { next: store, result: undefined };
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
deleteAccount(profileId, service, identity) {
|
|
378
|
+
this.mutate((store) => {
|
|
379
|
+
const profile = store.profiles.find((p) => p.id === profileId);
|
|
380
|
+
if (!profile)
|
|
381
|
+
throw new PlaywrightRegistryError(`profile '${profileId}' not found`, 404);
|
|
382
|
+
const before = profile.accounts.length;
|
|
383
|
+
profile.accounts = profile.accounts.filter((a) => !(a.service === service && a.identity === identity));
|
|
384
|
+
if (profile.accounts.length === before) {
|
|
385
|
+
throw new PlaywrightRegistryError(`account (${service}, ${identity}) not found`, 404);
|
|
386
|
+
}
|
|
387
|
+
return { next: store, result: undefined };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// ── Activation (compute + optional write) — D10 ─────────────────────────────────
|
|
391
|
+
/**
|
|
392
|
+
* Compute the intended .mcp.json/.settings.json args mutation WITHOUT writing.
|
|
393
|
+
* INSERT `--user-data-dir <dir>` as two array elements when absent; REPLACE the value
|
|
394
|
+
* (handling the joined `--user-data-dir=<x>` form) when present; for the default profile
|
|
395
|
+
* (null userDataDir) REMOVE the arg. alreadyActive=true when the target is already set.
|
|
396
|
+
*/
|
|
397
|
+
computeActivation(profileId) {
|
|
398
|
+
const store = this.ensureSeeded();
|
|
399
|
+
const profile = store.profiles.find((p) => p.id === profileId);
|
|
400
|
+
if (!profile)
|
|
401
|
+
throw new PlaywrightRegistryError(`profile '${profileId}' not found`, 404);
|
|
402
|
+
const resolved = this.resolvePlaywrightMcpConfig();
|
|
403
|
+
if (!resolved) {
|
|
404
|
+
throw new PlaywrightRegistryError('no playwright MCP server configured', 409);
|
|
405
|
+
}
|
|
406
|
+
const currentArgs = Array.isArray(resolved.entry.args)
|
|
407
|
+
? (resolved.entry.args.map((x) => String(x)))
|
|
408
|
+
: [];
|
|
409
|
+
const target = profile.userDataDir; // null for default
|
|
410
|
+
const currentValue = extractUserDataDir(currentArgs);
|
|
411
|
+
const alreadyActive = currentValue === target;
|
|
412
|
+
const nextArgs = applyUserDataDirArg(currentArgs, target);
|
|
413
|
+
return {
|
|
414
|
+
profileId,
|
|
415
|
+
file: resolved.file,
|
|
416
|
+
nextArgs,
|
|
417
|
+
userDataDir: target,
|
|
418
|
+
dirExists: this.dirExists(target),
|
|
419
|
+
alreadyActive,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Persist the activation plan into the authoritative MCP config file (the CALLER gates
|
|
424
|
+
* this behind dryRun + loop-guard + refresh). Writes mcpServers.playwright.args = nextArgs.
|
|
425
|
+
* Returns the file written.
|
|
426
|
+
*/
|
|
427
|
+
writeActivation(plan) {
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = JSON.parse(fs.readFileSync(plan.file, 'utf8'));
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
throw new PlaywrightRegistryError(`activation target config unreadable: ${err.message}`, 500);
|
|
434
|
+
}
|
|
435
|
+
if (!parsed.mcpServers || typeof parsed.mcpServers.playwright !== 'object') {
|
|
436
|
+
throw new PlaywrightRegistryError('playwright MCP entry vanished from config', 409);
|
|
437
|
+
}
|
|
438
|
+
parsed.mcpServers.playwright.args = plan.nextArgs;
|
|
439
|
+
const tmp = path.join(path.dirname(plan.file), `.${path.basename(plan.file)}.${process.pid}.${Date.now()}.tmp`);
|
|
440
|
+
fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2) + '\n');
|
|
441
|
+
fs.renameSync(tmp, plan.file);
|
|
442
|
+
return { file: plan.file };
|
|
443
|
+
}
|
|
444
|
+
// ── Boot block (D21) ────────────────────────────────────────────────────────────
|
|
445
|
+
/**
|
|
446
|
+
* Compact <playwright-profiles> boot pointer ≤maxBytes. ONE line per profile carrying
|
|
447
|
+
* only the safety-critical signals (service/identity, owner marker, login-staleness).
|
|
448
|
+
* Stable order (default first, then createdAt). Account-line truncation with a counted
|
|
449
|
+
* '…(+N)' marker (marker bytes count vs budget). Every rendered field passes through
|
|
450
|
+
* sanitizeForBlock (D16). NO vault values, NO vaultRefs (those are GET-only). Fail-open:
|
|
451
|
+
* a corrupt/unreadable file → empty (never blocks boot — D15/D22).
|
|
452
|
+
*/
|
|
453
|
+
buildSessionContextBlock(maxBytes = DEFAULT_BLOCK_MAX_BYTES, opts = {}) {
|
|
454
|
+
let store;
|
|
455
|
+
try {
|
|
456
|
+
store = this.ensureSeeded();
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// @silent-fallback-ok — corrupt/unreadable registry must never block boot (D15/D22); inject nothing.
|
|
460
|
+
return { present: false, block: '' };
|
|
461
|
+
}
|
|
462
|
+
if (store.profiles.length === 0)
|
|
463
|
+
return { present: false, block: '' };
|
|
464
|
+
const ordered = [...store.profiles].sort((a, b) => {
|
|
465
|
+
if (a.isDefault && !b.isDefault)
|
|
466
|
+
return -1;
|
|
467
|
+
if (b.isDefault && !a.isDefault)
|
|
468
|
+
return 1;
|
|
469
|
+
return String(a.createdAt).localeCompare(String(b.createdAt));
|
|
470
|
+
});
|
|
471
|
+
const header = [
|
|
472
|
+
`<playwright-profiles src='boot' machine='${sanitizeForBlock(this.hostname, 64)}'>`,
|
|
473
|
+
'## Browser profiles (background signal, not authority — verify before acting)',
|
|
474
|
+
'Profiles live on THIS machine only. Login state is LAST-ASSERTED, never a guarantee —',
|
|
475
|
+
're-verify in-browser before any privileged action, especially operator-owned accounts.',
|
|
476
|
+
'Full detail + vault key names: GET /playwright-profiles · pick one: GET /playwright-profiles/resolve',
|
|
477
|
+
'',
|
|
478
|
+
];
|
|
479
|
+
const footer = [
|
|
480
|
+
'To switch the browser onto a profile: POST /playwright-profiles/<id>/activate (restarts the session).',
|
|
481
|
+
'</playwright-profiles>',
|
|
482
|
+
];
|
|
483
|
+
const profileLines = ordered.map((p) => this.renderProfileLine(p));
|
|
484
|
+
if (opts.full) {
|
|
485
|
+
const block = [...header, ...profileLines, ...footer].join('\n');
|
|
486
|
+
return { present: true, block };
|
|
487
|
+
}
|
|
488
|
+
// Byte-bound: drop whole profile lines from the END, replacing with a counted marker.
|
|
489
|
+
let shown = profileLines.length;
|
|
490
|
+
while (shown >= 0) {
|
|
491
|
+
const hidden = profileLines.length - shown;
|
|
492
|
+
const body = profileLines.slice(0, shown);
|
|
493
|
+
if (hidden > 0) {
|
|
494
|
+
body.push(`…(+${hidden} more — GET /playwright-profiles)`);
|
|
495
|
+
}
|
|
496
|
+
const assembled = [...header, ...body, ...footer].join('\n');
|
|
497
|
+
if (Buffer.byteLength(assembled, 'utf8') <= maxBytes || shown === 0) {
|
|
498
|
+
return { present: true, block: assembled };
|
|
499
|
+
}
|
|
500
|
+
shown--;
|
|
501
|
+
}
|
|
502
|
+
// Unreachable (shown===0 returns above), but keeps the type-checker happy.
|
|
503
|
+
return { present: true, block: [...header, ...footer].join('\n') };
|
|
504
|
+
}
|
|
505
|
+
/** Render one profile's compact boot line. Sanitizes every field. */
|
|
506
|
+
renderProfileLine(p) {
|
|
507
|
+
const accountStrs = p.accounts.map((a) => {
|
|
508
|
+
const svc = sanitizeForBlock(a.service, MAX_SERVICE_CHARS);
|
|
509
|
+
const id = sanitizeForBlock(a.identity, MAX_IDENTITY_CHARS);
|
|
510
|
+
const ownerMark = a.owner === 'operator' ? 'OPERATOR; act-as only when authorized' : 'agent';
|
|
511
|
+
const staleness = renderStaleness(a.lastVerifiedAt);
|
|
512
|
+
return `${svc}/${id} (${ownerMark}) [${staleness}]`;
|
|
513
|
+
});
|
|
514
|
+
const accountPart = accountStrs.length > 0 ? accountStrs.join(', ') : '(no accounts assigned)';
|
|
515
|
+
const pid = sanitizeForBlock(p.id, 64);
|
|
516
|
+
return `- ${pid} — ${accountPart}`;
|
|
517
|
+
}
|
|
518
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────────
|
|
519
|
+
/** Best-effort vault names for READ paths — swallows the unreadable case to null. */
|
|
520
|
+
safeVaultNames() {
|
|
521
|
+
try {
|
|
522
|
+
return this.listVaultNames();
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// @silent-fallback-ok — READ-path dangling-ref check is best-effort; null = "don't assert dangling" (D17).
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** fs.existsSync on a userDataDir; null userDataDir → built-in profile present (true). */
|
|
530
|
+
dirExists(userDataDir) {
|
|
531
|
+
if (userDataDir === null)
|
|
532
|
+
return true;
|
|
533
|
+
try {
|
|
534
|
+
return fs.existsSync(userDataDir);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// @silent-fallback-ok — an unstattable path is treated as absent.
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Auto-allocate <projectDir>/.instar/state/playwright-profiles/<id>/ (recorded only). */
|
|
542
|
+
autoAllocateDir(id) {
|
|
543
|
+
return path.join(this.projectDir, '.instar', 'state', 'playwright-profiles', id);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Path-jail a caller-supplied userDataDir (D9): path.resolve'd, absolute, confined under
|
|
547
|
+
* projectDir (agent home), not flag-shaped (`-` prefix), no NUL. Else throw 400.
|
|
548
|
+
*/
|
|
549
|
+
jailUserDataDir(input) {
|
|
550
|
+
const raw = String(input);
|
|
551
|
+
if (raw.includes('\u0000')) {
|
|
552
|
+
throw new PlaywrightRegistryError('userDataDir contains a NUL byte', 400);
|
|
553
|
+
}
|
|
554
|
+
if (raw.trimStart().startsWith('-')) {
|
|
555
|
+
throw new PlaywrightRegistryError('userDataDir must not begin with "-" (flag-shaped)', 400);
|
|
556
|
+
}
|
|
557
|
+
if (!path.isAbsolute(raw)) {
|
|
558
|
+
throw new PlaywrightRegistryError('userDataDir must be an absolute path', 400);
|
|
559
|
+
}
|
|
560
|
+
const resolved = path.resolve(raw);
|
|
561
|
+
const root = this.projectDir.endsWith(path.sep) ? this.projectDir : this.projectDir + path.sep;
|
|
562
|
+
if (resolved !== this.projectDir && !resolved.startsWith(root)) {
|
|
563
|
+
throw new PlaywrightRegistryError('userDataDir must be confined under the agent home', 400);
|
|
564
|
+
}
|
|
565
|
+
return resolved;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ── Module-level pure helpers ──────────────────────────────────────────────────
|
|
569
|
+
/** Stored-field sanitizer: strip control/ANSI, clamp length (NOT for block render — that uses sanitizeForBlock on top). */
|
|
570
|
+
export function sanitizeStored(input, maxChars) {
|
|
571
|
+
let s = String(input)
|
|
572
|
+
// eslint-disable-next-line no-control-regex
|
|
573
|
+
.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
|
|
574
|
+
// eslint-disable-next-line no-control-regex
|
|
575
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
576
|
+
.trim();
|
|
577
|
+
if (s.length > maxChars)
|
|
578
|
+
s = s.slice(0, maxChars);
|
|
579
|
+
return s;
|
|
580
|
+
}
|
|
581
|
+
/** Render a login-staleness note from lastVerifiedAt: 'seen Nd ago' / 'seen today' / 'unverified'. */
|
|
582
|
+
export function renderStaleness(lastVerifiedAt) {
|
|
583
|
+
if (!lastVerifiedAt)
|
|
584
|
+
return 'unverified';
|
|
585
|
+
const t = Date.parse(lastVerifiedAt);
|
|
586
|
+
if (Number.isNaN(t))
|
|
587
|
+
return 'unverified';
|
|
588
|
+
const days = Math.floor((Date.now() - t) / (24 * 60 * 60 * 1000));
|
|
589
|
+
if (days <= 0)
|
|
590
|
+
return 'seen today';
|
|
591
|
+
return `seen ${days}d ago`;
|
|
592
|
+
}
|
|
593
|
+
/** Extract the --user-data-dir value from an args array (two-element form OR joined `=` form). null if absent. */
|
|
594
|
+
export function extractUserDataDir(args) {
|
|
595
|
+
if (!Array.isArray(args))
|
|
596
|
+
return null;
|
|
597
|
+
const a = args.map((x) => String(x));
|
|
598
|
+
for (let i = 0; i < a.length; i++) {
|
|
599
|
+
if (a[i] === '--user-data-dir') {
|
|
600
|
+
return i + 1 < a.length ? a[i + 1] : null;
|
|
601
|
+
}
|
|
602
|
+
if (a[i].startsWith('--user-data-dir=')) {
|
|
603
|
+
return a[i].slice('--user-data-dir='.length);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Return a NEW args array with --user-data-dir set to `target` (two separate elements),
|
|
610
|
+
* replacing any existing value/joined form. When target is null, REMOVE the arg entirely.
|
|
611
|
+
*/
|
|
612
|
+
export function applyUserDataDirArg(args, target) {
|
|
613
|
+
const out = [];
|
|
614
|
+
for (let i = 0; i < args.length; i++) {
|
|
615
|
+
if (args[i] === '--user-data-dir') {
|
|
616
|
+
i++; // skip the following value element too
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (args[i].startsWith('--user-data-dir=')) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
out.push(args[i]);
|
|
623
|
+
}
|
|
624
|
+
if (target !== null) {
|
|
625
|
+
out.push('--user-data-dir', target);
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
/** Capture a file's (mtimeMs, size) signature for the CAS, or zeros when absent. */
|
|
630
|
+
function statSig(file) {
|
|
631
|
+
try {
|
|
632
|
+
const st = fs.statSync(file);
|
|
633
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// @silent-fallback-ok — absent file is a valid CAS baseline (a concurrent create flips the signature).
|
|
637
|
+
return { mtimeMs: 0, size: 0 };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=PlaywrightProfileRegistry.js.map
|