actas 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,272 @@
1
+ import {createHash, randomBytes, randomUUID} from 'node:crypto';
2
+ import {mkdir, open, readFile, rename, unlink, chmod, rm, mkdtemp} from 'node:fs/promises';
3
+ import {tmpdir} from 'node:os';
4
+ import {join, resolve} from 'node:path';
5
+ import {targetUrl} from '../accounts.mjs';
6
+
7
+ const digest = value => createHash('sha256').update(value).digest('hex');
8
+ const reasons = new Set(['captcha', 'mfa', 'phone', 'provider_policy', 'human_credential_entry']);
9
+ const instructions = {
10
+ captcha: ['complete the human check', 'requires a human check'],
11
+ mfa: ['complete the authentication challenge', 'requires multi-factor authentication'],
12
+ phone: ['complete the phone verification', 'requires phone verification'],
13
+ provider_policy: ['review and complete the approval shown', 'requires your approval'],
14
+ human_credential_entry: ['enter your account credentials and submit', 'requires your credentials'],
15
+ };
16
+ async function writePrivate(path, value) {
17
+ const tmp = `${path}.${randomUUID()}.tmp`;
18
+ const file = await open(tmp, 'wx', 0o600);
19
+ try { await file.writeFile(JSON.stringify(value)); } finally { await file.close(); }
20
+ await rename(tmp, path);
21
+ }
22
+ function scope(account) {
23
+ for (const key of ['workspace', 'product', 'platform', 'accountRef']) {
24
+ if (key === 'product' && account?.product === null) continue;
25
+ if (typeof account?.[key] !== 'string' || !account[key] || account[key].length > 256) throw Error('Invalid browser account scope.');
26
+ }
27
+ return Object.fromEntries(['workspace', 'product', 'platform', 'accountRef'].map(k => [k, account[k]]));
28
+ }
29
+
30
+ /** Local long-lived service; adapters are trusted code, never supplied by model input.
31
+ * authorize must check current grants AND revocation on every call. Vault adapters
32
+ * enforce the same scope server-side. Raw state is passed only to vault adapters.
33
+ */
34
+ export class BrowserHandoffRunner {
35
+ constructor({stateDir, authorize, vault, providers, launch, notifier = async () => {}, inbox,
36
+ now = Date.now, pollMs = 1000, humanTimeoutMs = 86400000}) {
37
+ if (!stateDir || !authorize || !vault?.save || !vault?.load || !providers) throw Error('Browser adapters are required.');
38
+ if (!Number.isFinite(pollMs) || pollMs < 1 || !Number.isFinite(humanTimeoutMs) || humanTimeoutMs < 1 || humanTimeoutMs > 86400000) throw Error('Invalid browser deadlines.');
39
+ Object.assign(this, {stateDir: resolve(stateDir), authorize, vault, providers, launch, notifier, inbox, now, pollMs, humanTimeoutMs});
40
+ this.jobs = new Map();
41
+ }
42
+ async allowed(account, action = 'account:manage') {
43
+ if (await this.authorize(scope(account), action) !== true) throw Error('Browser account access denied.');
44
+ }
45
+ key(account) { return digest(JSON.stringify(scope(account))); }
46
+ path(account) { return join(this.stateDir, this.key(account)); }
47
+ async read(account) {
48
+ try { return JSON.parse(await readFile(join(this.path(account), 'operation.json'), 'utf8')); }
49
+ catch (e) { if (e.code === 'ENOENT') return undefined; throw Error('Browser progress unreadable; reconcile before retrying.'); }
50
+ }
51
+ async persist(job) { await writePrivate(join(this.path(job.account), 'operation.json'), job.state); }
52
+ receipt(state, token) {
53
+ return {version: 1, flowId: state.flowId, action: state.action, origin: state.origin,
54
+ accountRef: state.accountRef, status: state.status, step: state.step,
55
+ ...(state.reason ? {reason: state.reason} : {}),
56
+ ...(state.humanRequest ? {humanRequest: state.humanRequest} : {}),
57
+ ...(token ? {resumeToken: token} : {}),
58
+ ...(state.lastVerifiedAt ? {lastVerifiedAt: state.lastVerifiedAt} : {})};
59
+ }
60
+ async status(account) {
61
+ await this.allowed(account, 'account:use');
62
+ const state = await this.read(account);
63
+ return state ? this.receipt(state) : {status: 'pending', step: 'not_started'};
64
+ }
65
+ async start({account, action, url, flowId = randomUUID()}) {
66
+ await this.allowed(account);
67
+ if (!['signup', 'login'].includes(action)) throw Error('Invalid browser action.');
68
+ const current = this.jobs.get(this.key(account));
69
+ if (current?.flight) await current.flight;
70
+ if (current && this.jobs.has(this.key(account))) return this.receipt(current.state);
71
+ const target = targetUrl(url), provider = this.providers[account.platform];
72
+ if (!provider || !provider.origins.includes(target.origin)) throw Error('Unsupported browser target.');
73
+ await mkdir(this.stateDir, {recursive: true, mode: 0o700});
74
+
75
+ await mkdir(this.path(account), {recursive: true, mode: 0o700});
76
+ await chmod(this.path(account), 0o700);
77
+ const lock = await this.lock(account);
78
+ let job;
79
+ try {
80
+ const previous = await this.read(account);
81
+ // Never repeat uncertain signup/submit operations, including after a crash.
82
+ if (previous && (previous.flowId === flowId || !['complete', 'revoked'].includes(previous.status))) {
83
+ await this.unlock(account, lock);
84
+ return this.receipt(previous);
85
+ }
86
+ if (previous?.status === 'revoked') throw Error('Account revoked; reconnect through account management.');
87
+ const state = {version: 1, ...scope(account), flowId, action, origin: target.origin,
88
+ url: target.href, status: 'step', step: 'inspect_form', acted: [], notified: [], createdAt: new Date(this.now()).toISOString()};
89
+ job = {account: {...account}, provider, state, lock};
90
+ await this.persist(job);
91
+ await this.open(job);
92
+ this.jobs.set(this.key(account), job);
93
+ const result = await this.tick(job);
94
+ this.schedule(job);
95
+ return result;
96
+ } catch {
97
+ if (job) { job.state.status = 'failed'; job.state.reason = 'browser_failed'; await this.persist(job); await this.close(job); }
98
+ else await this.unlock(account, lock);
99
+ throw Error('Browser could not start; inspect saved progress before retrying.');
100
+ }
101
+ }
102
+ async lock(account) {
103
+ try { return await open(join(this.path(account), 'runner.lock'), 'wx', 0o600); }
104
+ catch { throw Error('Browser operation locked; reconcile the existing runner before retrying.'); }
105
+ }
106
+ async unlock(account, lock) {
107
+ if (!lock) return;
108
+ await lock.close();
109
+ await unlink(join(this.path(account), 'runner.lock'));
110
+ }
111
+ async open(job) {
112
+ await rm(join(this.path(job.account),'profile'),{recursive:true,force:true});
113
+ const profile = await mkdtemp(join(tmpdir(),'actas-browser-'));
114
+ job.profile = profile;
115
+ await chmod(profile, 0o700);
116
+ const launch = this.launch || (async path => {
117
+ const {chromium} = await import('playwright');
118
+ const browser=await chromium.launch({headless:false,chromiumSandbox:true,ignoreDefaultArgs:['--use-mock-keychain']});
119
+ const context=await browser.newContext({acceptDownloads:false});
120
+ const close=context.close.bind(context);context.close=async()=>{try{await close();}finally{await browser.close();}};
121
+ return context;
122
+ });
123
+ job.context = await launch(profile);
124
+ const saved = await this.vault.load(scope(job.account));
125
+ // Vault restore is only for a fresh operation, never overwrites a human's
126
+ // in-progress profile when continuing a challenge.
127
+ if (saved && job.state.step === 'inspect_form') await job.context.setStorageState(saved);
128
+ job.page = job.context.pages()[0] || await job.context.newPage();
129
+ let url = job.state.url;
130
+ if (job.state.humanRequest && this.vault.loadProgress) {
131
+ const progress = await this.vault.loadProgress(scope(job.account));
132
+ if (progress?.flowId === job.state.flowId && job.provider.origins.includes(new URL(progress.url).origin)) {
133
+ await job.context.setStorageState(progress.storage); url = progress.url;
134
+ }
135
+ }
136
+ await job.page.goto(url);
137
+ await job.page.bringToFront();
138
+ }
139
+ schedule(job) {
140
+ if (!this.jobs.has(this.key(job.account)) || job.timer || ['complete', 'failed', 'revoked'].includes(job.state.status)) return;
141
+ if (job.state.humanRequest && this.now() >= Date.parse(job.state.humanRequest.expiresAt)) return;
142
+ job.timer = setTimeout(async () => {
143
+ job.timer = undefined;
144
+ try { await this.tick(job); }
145
+ catch { job.state.status = 'failed'; job.state.reason = 'browser_failed'; await this.persist(job).catch(() => {}); await this.close(job); }
146
+ this.schedule(job);
147
+ }, this.pollMs);
148
+ job.timer.unref?.();
149
+ }
150
+ async tick(job) {
151
+ // Single flight prevents a timer and explicit resume from submitting twice.
152
+ if (job.flight) return job.flight;
153
+ job.flight = this.advance(job);
154
+ try { return await job.flight; } finally { job.flight = undefined; }
155
+ }
156
+ async advance(job) {
157
+ await this.allowed(job.account);
158
+ const s = job.state;
159
+ if (s.humanRequest && this.now() >= Date.parse(s.humanRequest.expiresAt)) return this.receipt(s);
160
+ const observation = await job.provider.observe(job.page, job.account, s.action);
161
+ if (observation.kind === 'authenticated') {
162
+ // A provider verifier must establish the expected account at an allowed
163
+ // origin. Disappearing CAPTCHA or changed URL alone is never completion.
164
+ if (!job.provider.origins.includes(new URL(job.page.url()).origin) ||
165
+ await job.provider.verify(job.page, job.account) !== true) throw Error('Unverified browser account.');
166
+ await this.allowed(job.account);
167
+ await this.vault.save(scope(job.account), await job.context.storageState({indexedDB: true}));
168
+ s.status = 'complete'; s.step = 'verify_session'; s.lastVerifiedAt = new Date(this.now()).toISOString();
169
+ delete s.reason; delete s.humanRequest; delete s.resumeDigest;
170
+ await this.persist(job);
171
+ await this.close(job);
172
+ return this.receipt(s);
173
+ }
174
+ if (observation.kind === 'human') {
175
+ const reason = reasons.has(observation.reason) ? observation.reason : 'provider_policy';
176
+ const id = `${reason}:${observation.id || reason}`;
177
+ if (s.humanId !== id) {
178
+ s.humanId = id; delete s.humanRequest; delete s.resumeDigest;
179
+ }
180
+ if (!s.humanRequest) {
181
+ const token = randomBytes(32).toString('base64url');
182
+ const [instruction, why] = instructions[reason];
183
+ s.status = 'needs_human'; s.step = 'human'; s.reason = reason;
184
+ s.resumeDigest = digest(token);
185
+ const requestedAt=this.now();
186
+ s.humanRequest = {text: `In the ActAs browser window at ${s.origin}, ${instruction}. This service ${why}.`,
187
+ openedUrl: s.origin, createdAt: new Date(requestedAt).toISOString(), expiresAt: new Date(requestedAt + this.humanTimeoutMs).toISOString()};
188
+ // Persist deduplication BEFORE delivery. Uncertain delivery is never retried.
189
+ const notify = !s.notified.includes(id);
190
+ if (notify) s.notified.push(id);
191
+ if (this.vault.saveProgress) await this.vault.saveProgress(scope(job.account), {flowId: s.flowId, url: job.page.url(), storage: await job.context.storageState({indexedDB: true})});
192
+ await this.persist(job);
193
+ await job.page.bringToFront();
194
+ if (notify) { try { await this.notifier(this.receipt(s)); } catch { /* Caller receives the same request. */ } }
195
+ return this.receipt(s, token);
196
+ }
197
+ return this.receipt(s);
198
+ }
199
+ delete s.humanRequest; delete s.reason; delete s.resumeDigest; delete s.humanId;
200
+ s.status = 'step'; if (s.step === 'human') s.step = 'inspect_form';
201
+ if (observation.kind === 'email') {
202
+ if (!this.inbox || !job.provider.verifyEmail) throw Error('Product inbox adapter required.');
203
+ const id = `email:${observation.id}`;
204
+ if (!s.acted.includes(id)) {
205
+ s.status = 'step'; s.step = 'verify_email'; await this.persist(job);
206
+ // The inbox adapter validates product scope, sender, subject and since;
207
+ // the message/code stays entirely inside tool code, never a receipt.
208
+ await this.allowed(job.account);
209
+ const message = await this.inbox.waitFor(scope(job.account), observation.match);
210
+ if (message === undefined) return this.receipt(s);
211
+ await this.allowed(job.account);
212
+ s.acted.push(id); await this.persist(job);
213
+ await job.provider.verifyEmail(job.page, message, job.account);
214
+ }
215
+ } else if (observation.kind === 'step' && typeof observation.id === 'string') {
216
+ if (!s.acted.includes(observation.id)) {
217
+ s.acted.push(observation.id); s.status = 'step'; s.step = observation.id;
218
+ await this.persist(job); // Write-ahead: no automatic submission retry on crash.
219
+ await job.provider.advance(job.page, observation.id, job.account, s.action);
220
+ }
221
+ } else if (observation.kind !== 'waiting') throw Error('Unknown browser observation.');
222
+ await this.persist(job);
223
+ return this.receipt(s);
224
+ }
225
+ async resume({account, flowId, resumeToken}) {
226
+ await this.allowed(account);
227
+ let job = this.jobs.get(this.key(account));
228
+ const s = job?.state || await this.read(account);
229
+ if (!s || s.flowId !== flowId || typeof resumeToken !== 'string' || s.resumeDigest !== digest(resumeToken) ||
230
+ !s.humanRequest || this.now() >= Date.parse(s.humanRequest.expiresAt)) throw Error('Invalid or expired browser continuation.');
231
+ // Consume before any awaited browser work, including concurrent resumes.
232
+ delete s.resumeDigest;
233
+ if (!job) {
234
+ const lock = await this.lock(account);
235
+ job = {account: {...account}, provider: this.providers[account.platform], state: s, lock};
236
+ await this.persist(job);
237
+ try { await this.open(job); } catch { await this.close(job); throw Error('Browser continuation could not open.'); }
238
+ this.jobs.set(this.key(account), job);
239
+ } else await this.persist(job);
240
+ const result = await this.tick(job); this.schedule(job); return result;
241
+ }
242
+ // Only the trusted core calls this after its server-side resume token check.
243
+ async continueTrusted({account, flowId}) {
244
+ await this.allowed(account);
245
+ let job = this.jobs.get(this.key(account));
246
+ const state = job?.state || await this.read(account);
247
+ if (!state || state.flowId !== flowId) throw Error('Browser flow mismatch.');
248
+ if (['complete', 'failed', 'revoked'].includes(state.status)) return this.receipt(state);
249
+ if (!job) {
250
+ const lock = await this.lock(account);
251
+ job = {account: {...account}, provider: this.providers[account.platform], state, lock};
252
+ try { await this.open(job); } catch { await this.close(job); throw Error('Browser continuation could not open.'); }
253
+ this.jobs.set(this.key(account), job);
254
+ }
255
+ const result = await this.tick(job); this.schedule(job); return result;
256
+ }
257
+ async close(job) {
258
+ clearTimeout(job.timer); job.timer = undefined;
259
+ try { await job.context?.close(); } finally {
260
+ const lock = job.lock; job.lock = undefined;
261
+ await rm(job.profile || join(this.path(job.account),'profile'),{recursive:true,force:true});
262
+ await this.unlock(job.account, lock); this.jobs.delete(this.key(job.account));
263
+ }
264
+ }
265
+ async revoke(account) {
266
+ const job=this.jobs.get(this.key(account));if(job){if(job.flight)await job.flight.catch(()=>{});await this.close(job);}
267
+ await rm(this.path(account),{recursive:true,force:true});
268
+ }
269
+ async shutdown() {
270
+ for (const job of [...this.jobs.values()]) { if (job.flight) await job.flight.catch(() => {}); await this.close(job); }
271
+ }
272
+ }
@@ -0,0 +1,108 @@
1
+ import {homedir} from 'node:os';
2
+ import {join} from 'node:path';
3
+ import {BrowserHandoffRunner} from './browser/runner.mjs';
4
+ import {createBrowserProviders} from './browser/providers.mjs';
5
+
6
+ /** Accounts MCP v1 boundary. All API bodies are scoped by trusted core options. */
7
+ export function createBrowserAdapter(options) {
8
+ const {request, scope, waitForInbox, consumePrivateValue, saveReport} = options;
9
+ if (!request || !scope?.ownerId || typeof saveReport !== 'function') throw Error('Scoped browser API connection required.');
10
+ const body = data => ({...data, ownerId: scope.ownerId, productId: scope.productId ?? null});
11
+ const path = (account, suffix) => `/accounts_browser/${account.accountRef}_${suffix}`;
12
+ async function load(account, suffix) {
13
+ try {
14
+ const result = await request('/account-tools/flow/store', 'POST', body({accountRef:account.accountRef,flowId:activeFlows.get(account.accountRef)?.flowId,...(activeFlows.get(account.accountRef)?.resumeToken?{resumeToken:activeFlows.get(account.accountRef).resumeToken}:{}),store:suffix}));
15
+ return JSON.parse(result.value);
16
+ } catch { return undefined; } // Missing state requires verification, never success.
17
+ }
18
+ async function save(account, suffix, value) {
19
+ await request('/account-tools/flow/store', 'POST', body({accountRef:account.accountRef,flowId:activeFlows.get(account.accountRef)?.flowId,...(activeFlows.get(account.accountRef)?.resumeToken?{resumeToken:activeFlows.get(account.accountRef).resumeToken}:{}),store:suffix,value:JSON.stringify(value)}));
20
+ return path(account, suffix);
21
+ }
22
+ const reports = new Map();
23
+ const activeFlows = new Map();
24
+ const toReport = state => {
25
+ const account = reports.get(state.flowId)?.account;
26
+ return {flowId: state.flowId, accountRef: state.accountRef, origin: state.origin,
27
+ step: state.status === 'complete' ? 'complete' : state.step === 'human' || state.step.startsWith('enter_credentials') ? 'enter_credentials' : state.step.startsWith('submit') ? 'submit' : state.step,
28
+ ...(state.humanRequest ? {handoff: state.reason === 'phone' ? 'mfa' : state.reason, humanRequest: state.humanRequest} : {}),
29
+ ...(state.status === 'complete' ? {authenticated: true, principal: account.handle, observedUrl: state.origin,
30
+ observedAt: state.lastVerifiedAt, browserState: path(account, 'state')} : {})};
31
+ };
32
+ async function persistReport(report) {
33
+ const metadata = reports.get(report.flowId);
34
+ if (JSON.stringify(metadata.report) === JSON.stringify(report)) return;
35
+ if (metadata.uncertain) throw Error('Browser report delivery uncertain; reconcile before retrying.');
36
+ metadata.uncertain = true; // Never repeat a possibly accepted core mutation.
37
+ const result=await saveReport(report);
38
+ metadata.resumeToken=result?.resumeToken;
39
+ metadata.report = report; delete metadata.uncertain;
40
+ }
41
+ const runner = new BrowserHandoffRunner({
42
+ stateDir: options.stateDir || join(homedir(), '.local', 'state', 'actas', 'browser'),
43
+ launch: options.launch, pollMs: options.pollMs,
44
+ providers: options.providers || createBrowserProviders({emailRules: options.emailRules, async credentials(account) {
45
+ const ref = account.secretRefs?.password;
46
+ if (typeof ref !== 'string' || !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(ref)) throw Error('Invalid credential reference.');
47
+ const result = await request('/vault/read', 'POST', body({path: ref, action: 'reveal'}));
48
+ return {password: result.value};
49
+ }}),
50
+ async authorize(account, action) {
51
+ if (account.workspace !== scope.ownerId || account.product !== (scope.productId ?? null)) return false;
52
+ const current = await request('/account-tools/status', 'POST', body({accountRef: account.accountRef}));
53
+ const record = current.account || current;
54
+ if (record.status === 'revoked' || record.workspace !== account.workspace || record.product !== account.product) return false;
55
+ if (action === 'account:manage') {
56
+ const metadata = activeFlows.get(account.accountRef);
57
+ if (!metadata) return false;
58
+ await request('/account-tools/flow/check', 'POST', body({accountRef: account.accountRef, flowId: metadata.flowId}));
59
+ }
60
+ return true;
61
+ },
62
+ vault: {load: a => load(a, 'state'), save: (a, value) => save(a, 'state', value),
63
+ loadProgress: a => load(a, 'progress'), saveProgress: (a, value) => save(a, 'progress', value)},
64
+ notifier: async state => {
65
+ const report = toReport(state);
66
+ await persistReport(report);
67
+ await options.notifier?.(report);
68
+ },
69
+ inbox: waitForInbox && consumePrivateValue ? {async waitFor(account, match) {
70
+ const result = await waitForInbox(match);
71
+ if (result.status === 'pending') return undefined;
72
+ if (result.status !== 'matched' || !result.valueHandle) throw Error('Invalid verification email result.');
73
+ return consumePrivateValue(result.valueHandle);
74
+ }} : undefined,
75
+ });
76
+ // Background completion must reach the server without another MCP invocation.
77
+ const advance = runner.advance.bind(runner);
78
+ runner.advance = async job => {
79
+ const result = await advance(job), report = toReport(result);
80
+ const metadata = reports.get(result.flowId);
81
+ if (metadata && JSON.stringify(metadata.report) !== JSON.stringify(report)) {
82
+ await persistReport(report);
83
+ }
84
+ return result;
85
+ };
86
+ return {
87
+ async run(input) {
88
+ if (input.version !== 1 || !/^[A-Za-z0-9_-]{1,80}$/.test(input.account?.accountRef) ||
89
+ input.account.workspace !== scope.ownerId || input.account.product !== (scope.productId ?? null) ||
90
+ typeof input.flowId !== 'string' || !input.flowId) throw Error('Invalid scoped browser request.');
91
+ const provider = runner.providers[input.account.platform];
92
+ if (!provider?.origins.includes(input.origin)) throw Error('Unsupported browser origin.');
93
+ const metadata = {account: input.account, flowId: input.flowId, report: undefined};
94
+ reports.set(input.flowId, metadata); activeFlows.set(input.account.accountRef, metadata);
95
+ const state = await runner.read(input.account);
96
+ if (state && state.flowId !== input.flowId && !['complete','revoked'].includes(state.status)) throw Error('Existing browser flow requires reconciliation.');
97
+ if (state?.status === 'failed') throw Error('Browser flow failed; reconcile saved progress before continuing.');
98
+ const result = state?.flowId === input.flowId
99
+ ? await runner.continueTrusted({account: input.account, flowId: input.flowId})
100
+ : await runner.start({account: input.account, action: input.action, flowId: input.flowId, url: provider.entry[input.action]});
101
+ const report = toReport(result);
102
+ await persistReport(report); // Every dispatched invocation must settle core dispatch, even unchanged resume.
103
+ return report;
104
+ },
105
+ async revoke(account){if(account.workspace!==scope.ownerId||account.product!==(scope.productId??null))throw Error('Browser account scope denied.');await runner.revoke(account);activeFlows.delete(account.accountRef);},
106
+ close: () => runner.shutdown(),
107
+ };
108
+ }
@@ -0,0 +1,39 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {targetUrl} from './accounts.mjs';
3
+
4
+ export async function loadMappings(accountsPath) {
5
+ let mappings=[];try{if(accountsPath)mappings=JSON.parse(await readFile(accountsPath,'utf8'));}catch{throw new Error('Account mappings could not be read.');}
6
+ if(!Array.isArray(mappings)||mappings.some(m=>targetUrl(m.origin).origin!==m.origin||!Array.isArray(m.actions)||m.actions.some(a=>!['signup','login'].includes(a))||typeof m.accountRef!=='string'||typeof m.principal!=='string'||!m.principal||typeof m.custodian!=='string'||typeof m.authenticatedPath!=='string'||!m.authenticatedPath.startsWith('/')))throw new Error('Invalid authorised account mappings.');
7
+ if(new Set(mappings.map(m=>JSON.stringify([m.origin,m.accountRef]))).size!==mappings.length)throw new Error('Duplicate origin/account mapping.');
8
+ return mappings;
9
+ }
10
+ export function selectMapping(mappings,origin,accountRef) {
11
+ const candidates=mappings.filter(m=>m.origin===origin);
12
+ if(accountRef!==undefined){const selected=candidates.find(m=>m.accountRef===accountRef);if(!selected)throw new Error('The requested account is not assigned to this origin.');return selected;}
13
+ if(candidates.length>1)throw new Error('Multiple accounts are assigned to this origin. Choose --account: '+candidates.map(m=>m.accountRef).join(', '));
14
+ return candidates[0];
15
+ }
16
+ // Authority comes from owner configuration, never browser content or reports.
17
+ export async function callerExecutor({accountsPath,reportPath,brokerHandler}={}) {
18
+ const mappings=await loadMappings(accountsPath);
19
+ let report;try{if(reportPath)report=JSON.parse(await readFile(reportPath,'utf8'));}catch{throw new Error('Browser continuation could not be read.');}
20
+ return async state=>{
21
+ const selected=selectMapping(mappings,state.origin,state.accountRef);
22
+ if(selected?.broker){if(!brokerHandler||selected.broker.kind!=='chief-infisical-caller')return {status:'blocked',reason:'broker_scope_denied'};return brokerHandler(state,selected,report);}
23
+ const mapping=selected?.actions.includes(state.action)?selected:undefined;
24
+ const browser={url:state.url,flowId:state.flowId,step:state.browser?.step||(state.progress?.step==='authorize_account'?undefined:state.progress?.step)||(mapping?'inspect_form':'assess_site'),credentialCustody:'external',...(mapping?{accountRef:mapping.accountRef,custodian:mapping.custodian,principal:mapping.principal,...(mapping.organization?{organization:mapping.organization}:{})}:{})};
25
+ if(!report&&state.progress?.step&&['mfa','captcha','provider_policy','human_credential_entry'].includes(state.progress.step))return {status:'needs_human',reason:state.progress.step,progress:state.progress};
26
+ if(!report)return {status:'needs_browser',reason:'browser_action_required',browser};
27
+ if(report.flowId!==state.flowId||report.origin!==state.origin)return {status:'failed',reason:'unverified'};
28
+ if(!mapping)return {status:'needs_human',reason:'authorization_required',progress:{step:'authorize_account'}};
29
+ if(['mfa','captcha','provider_policy','human_credential_entry'].includes(report.handoff))return {status:'needs_human',reason:report.handoff,progress:{step:report.handoff}};
30
+ const steps=['inspect_form','enter_credentials','submit','verify_email','verify_session'];
31
+ if(steps.includes(report.step))return {status:'needs_browser',reason:'browser_action_required',browser:{...browser,step:report.step}};
32
+ if(report.authenticated===true&&report.step==='complete'&&typeof report.observedUrl==='string'){
33
+ let observed;try{observed=targetUrl(report.observedUrl)}catch{return {status:'failed',reason:'unverified'}}
34
+ if(observed.origin!==mapping.origin||typeof mapping.authenticatedPath!=='string'||observed.pathname!==mapping.authenticatedPath||report.accountRef!==mapping.accountRef||report.principal!==mapping.principal||(mapping.organization&&report.organization!==mapping.organization)||!Number.isFinite(Date.parse(report.observedAt))||Date.parse(report.observedAt)>Date.now()||Date.now()-Date.parse(report.observedAt)>300000)return {status:'failed',reason:'unverified'};
35
+ return {status:'succeeded',verification:{origin:state.origin,authenticated:true,method:'authenticated_page',source:'authorised_caller_observation'}};
36
+ }
37
+ return {status:'failed',reason:'unverified'};
38
+ };
39
+ }
@@ -0,0 +1,45 @@
1
+ import {readFile,mkdir,open,rename,unlink} from 'node:fs/promises';
2
+ import {homedir} from 'node:os';
3
+ import {join,dirname,resolve} from 'node:path';
4
+ import {createHash,randomUUID} from 'node:crypto';
5
+ import {targetUrl} from './accounts.mjs';
6
+ import {loadMappings} from './caller.mjs';
7
+ function verificationRule(rule) {
8
+ if(rule===undefined)return undefined;
9
+ if(!rule||!Array.isArray(rule.senders)||!rule.senders.length||rule.senders.length>20||rule.senders.some(s=>typeof s!=='string'||!/^[-a-zA-Z0-9_.+]+@[-a-zA-Z0-9.]+$/.test(s)))throw Error();
10
+ const senders=[...new Set(rule.senders)];
11
+ if(rule.kind==='code'){
12
+ if(typeof rule.label!=='string'||!rule.label||rule.label.length>200||/[\r\n]/.test(rule.label)||!Number.isInteger(rule.digits)||rule.digits<4||rule.digits>10)throw Error();
13
+ return {kind:'code',senders,label:rule.label,digits:rule.digits};
14
+ }
15
+ if(rule.kind!=='link'||!Array.isArray(rule.links)||!rule.links.length||rule.links.length>20)throw Error();
16
+ const links=rule.links.map(r=>{
17
+ if(!r||targetUrl(r.origin).origin!==r.origin||typeof r.path!=='string'||!r.path.startsWith('/')||r.path.startsWith('//')||/[?#]/.test(r.path)||new URL(r.path,r.origin).pathname!==r.path)throw Error();
18
+ return {origin:r.origin,path:r.path};
19
+ });
20
+ return {kind:'link',senders,links};
21
+ }
22
+ export async function configureAccount(bundlePath,{accountsPath=process.env.ACTAS_ACCOUNTS_FILE||join(homedir(),'.config','actas','accounts.json')}={}){
23
+ let bundle;try{const raw=await readFile(bundlePath,'utf8');if(raw.length>65536)throw Error();bundle=JSON.parse(raw);}catch{throw new Error('Could not read account connection file.');}
24
+ const m=bundle?.mapping;let verification;
25
+ try{
26
+ if(bundle.version!==1||typeof bundle.token!=='string'||!/^actas_[\w-]+\.[\w-]+$/.test(bundle.token)||!m||targetUrl(m.origin).origin!==m.origin||typeof m.accountRef!=='string'||!m.accountRef||typeof m.principal!=='string'||!m.principal||!Array.isArray(m.actions)||!m.actions.length||m.actions.some(a=>!['signup','login'].includes(a))||typeof m.authenticatedPath!=='string'||!m.authenticatedPath.startsWith('/')||m.authenticatedPath.startsWith('//')||/[?#]/.test(m.authenticatedPath)||m.credentials?.kind!=='actas-external'||targetUrl(m.credentials.url).origin!==m.credentials.url||typeof m.credentials.secretId!=='string'||!m.credentials.secretId||typeof m.mailbox?.emailId!=='string'||!m.mailbox.emailId)throw Error();
27
+ verification=verificationRule(m.mailbox.verification);
28
+ }catch{throw new Error('Invalid account connection. No configuration was changed.');}
29
+ const file=resolve(accountsPath),dir=dirname(file);await mkdir(dir,{recursive:true,mode:0o700});
30
+ let lock;try{lock=await open(file+'.lock','wx',0o600);}catch{throw new Error('Account configuration is busy. Retry after the other operation finishes.');}
31
+ try{
32
+ let mappings;try{mappings=await loadMappings(file);}catch(error){try{await readFile(file);throw error;}catch(e){if(e.code!=='ENOENT')throw error;mappings=[];}}
33
+ const id=createHash('sha256').update(JSON.stringify([m.origin,m.accountRef])).digest('hex');
34
+ const tokenPath=join(dir,'tokens',id+'.token');
35
+ // Construct an allowlisted mapping; imported paths, drivers and brokers are ignored.
36
+ const mapping={origin:m.origin,accountRef:m.accountRef,principal:m.principal,custodian:'ActAs',actions:m.actions,authenticatedPath:m.authenticatedPath,credentials:{kind:'actas-external',url:m.credentials.url,secretId:m.credentials.secretId,tokenPath},mailbox:{emailId:m.mailbox.emailId,...(verification?{verification}:{})}};
37
+ const prior=mappings.find(x=>x.origin===m.origin&&x.accountRef===m.accountRef);
38
+ if(prior&&JSON.stringify(prior)!==JSON.stringify(mapping))throw new Error('An existing account mapping differs. It was preserved.');
39
+ await mkdir(dirname(tokenPath),{recursive:true,mode:0o700});
40
+ async function save(path,value){const tmp=path+'.'+randomUUID();const out=await open(tmp,'wx',0o600);try{await out.writeFile(value);}finally{await out.close();}await rename(tmp,path);}
41
+ await save(tokenPath,bundle.token);
42
+ if(!prior)await save(file,JSON.stringify([...mappings,mapping],null,2)+'\n');
43
+ return {status:'configured',origin:m.origin,accountRef:m.accountRef};
44
+ }finally{await lock.close();await unlink(file+'.lock');}
45
+ }
@@ -0,0 +1,24 @@
1
+ // Ephemeral loopback delivery UI used by the calling agent's documented browser.
2
+ import {createServer} from 'node:http';
3
+ import {randomBytes,timingSafeEqual} from 'node:crypto';
4
+ process.once('message',({values})=>{
5
+ const token=randomBytes(32).toString('hex');let origin;
6
+ const page=`<!doctype html><html><head><title>ActAs local credential delivery</title><meta name="viewport" content="width=device-width"></head><body><h1>ActAs credential delivery</h1><p>Assigned account. Load each field for transfer into its authorised signup, verification or login form. Values are masked. Use Copy loaded value to transfer through your authorised browser clipboard, then clear the helper and clipboard.</p><button data-field="code">Load verification code</button><button data-field="username">Load username</button><button data-field="verificationUrl">Load verification link</button><button data-field="email">Load email</button><button data-field="password">Load password</button><button data-field="operator">Load operator key</button><label>Credential delivery<input id="delivery" type="password" readonly autocomplete="off" spellcheck="false"></label><button id="copy">Copy loaded value</button><button id="clear">Clear loaded value</button><button id="clear-clipboard">Clear clipboard</button><p id="status"></p><script>document.querySelectorAll('button[data-field]').forEach(b=>b.addEventListener('click',async()=>{const r=await fetch('/copy/'+b.dataset.field,{method:'POST'});if(!r.ok){document.getElementById('status').textContent='Delivery unavailable';return}document.getElementById('delivery').value=await r.text();document.getElementById('status').textContent=b.textContent.replace('Load','Loaded')}));document.getElementById('copy').onclick=async()=>{try{await navigator.clipboard.writeText(document.getElementById('delivery').value);document.getElementById('status').textContent='Copied loaded value'}catch{document.getElementById('status').textContent='Clipboard unavailable'}};document.getElementById('clear-clipboard').onclick=async()=>{try{await navigator.clipboard.writeText('');document.getElementById('status').textContent='Clipboard cleared'}catch{document.getElementById('status').textContent='Clipboard unavailable'}};document.getElementById('clear').onclick=()=>{document.getElementById('delivery').value='';document.getElementById('status').textContent='Cleared'};</script></body></html>`;
7
+ const server=createServer((req,res)=>{
8
+ res.setHeader('Cache-Control','no-store');res.setHeader('Referrer-Policy','no-referrer');res.setHeader('X-Frame-Options','DENY');res.setHeader('X-Content-Type-Options','nosniff');
9
+ if(req.headers.host!==new URL(origin).host){res.writeHead(403);return res.end();}
10
+ if(req.method==='GET'&&req.url==='/health'){res.writeHead(200);return res.end('ready-v3');}
11
+ if(req.method==='GET'&&req.url==='/'){
12
+ res.setHeader('Content-Type','text/html');res.setHeader('Set-Cookie',`delivery=${token}; HttpOnly; SameSite=Strict; Path=/`);
13
+ res.setHeader('Content-Security-Policy',"default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'");
14
+ return res.end(page);
15
+ }
16
+ const cookie=(req.headers.cookie||'').split('; ').find(x=>x.startsWith('delivery='))?.slice(9)||'';
17
+ if(req.method!=='POST'||req.headers.origin!==origin||Buffer.byteLength(cookie)!==Buffer.byteLength(token)||!timingSafeEqual(Buffer.from(cookie),Buffer.from(token))){res.writeHead(403);return res.end();}
18
+ const field=req.url?.slice('/copy/'.length);
19
+ if(!req.url?.startsWith('/copy/')||!['email','password','operator','code','username','verificationUrl'].includes(field)||typeof values[field]!=='string'){res.writeHead(404);return res.end();}
20
+ res.setHeader('Content-Type','text/plain');res.end(values[field]);
21
+ });
22
+ server.listen(0,'127.0.0.1',()=>{origin=`http://127.0.0.1:${server.address().port}`;process.send({url:origin+'/'});process.disconnect();});
23
+ setTimeout(()=>{server.close();process.exit(0)},300000).unref();
24
+ });
@@ -0,0 +1,79 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {ActAs} from '../scripts/proof-client.mjs';
3
+ import {loadMappings,selectMapping,callerExecutor} from './caller.mjs';
4
+ import {helper} from './helper.mjs';
5
+
6
+ // Provider messages are untrusted. Only exact owner-assigned senders, recipients,
7
+ // verification paths and recent received messages can supply a challenge.
8
+ export function emailChallenge(message,config,principal,since,now=Date.now()) {
9
+ const timestamp=Date.parse(message.timestamp);
10
+ if(!Number.isFinite(since)||since>now||!Number.isFinite(timestamp)||timestamp<since||timestamp>now||now-timestamp>900000||!message.labels?.includes('received'))return;
11
+ const address=value=>typeof value==='string'?value.match(/<([^<>]+)>$/)?.[1]||value:undefined;
12
+ if(!config.senders?.includes(address(message.from))||!Array.isArray(message.to)||!message.to.some(v=>address(v)===principal)||typeof message.text!=='string')return;
13
+ if(config.kind==='code'){
14
+ // A fixed label avoids arbitrary regex execution and ambiguous numbers in mail.
15
+ if(typeof config.label!=='string'||!config.label||!Number.isInteger(config.digits)||config.digits<4||config.digits>10)return;
16
+ const candidates=message.text.split(/\r?\n/).filter(line=>line.startsWith(config.label)).map(line=>line.slice(config.label.length).trim()).filter(v=>/^\d+$/.test(v)&&v.length===config.digits);
17
+ return candidates.length===1?{code:candidates[0]}:undefined;
18
+ }
19
+ if(config.kind==='link'){
20
+ const candidates=[...new Set(message.text.match(/https:\/\/[^\s<>"']+/g)||[])].filter(value=>{
21
+ try{const url=new URL(value);return !url.username&&!url.password&&config.links?.some(rule=>rule.origin===url.origin&&rule.path===url.pathname);}catch{return false;}
22
+ });
23
+ return candidates.length===1?{verificationUrl:candidates[0]}:undefined;
24
+ }
25
+ }
26
+
27
+ export async function externalExecutor({accountsPath,reportPath,clientFactory=options=>new ActAs(options),deliver=helper}={}) {
28
+ const mappings=await loadMappings(accountsPath);
29
+ const caller=await callerExecutor({accountsPath,reportPath});
30
+ let report;if(reportPath)report=JSON.parse(await readFile(reportPath,'utf8'));
31
+ return async state=>{
32
+ const mapping=selectMapping(mappings,state.origin,state.accountRef);
33
+ if(mapping?.credentials?.kind!=='actas-external')return null;
34
+ if(!mapping.actions.includes(state.action))return {status:'blocked',reason:'authorization_required'};
35
+ const continuation=await caller(state);
36
+ if(continuation.status==='failed'||continuation.status==='needs_browser'&&continuation.browser.step==='inspect_form')return continuation;
37
+ try{
38
+ const token=(await readFile(mapping.credentials.tokenPath,'utf8')).trim();
39
+ const client=clientFactory({url:mapping.credentials.url,token});
40
+ await client.request('/accounts/'+encodeURIComponent(mapping.accountRef)+'/registry-observe',{method:'POST',body:{action:state.action,completed:continuation.status==='succeeded'}});
41
+ }catch{return {status:'blocked',reason:'registry_unavailable'};}
42
+ if(continuation.status!=='needs_browser')return continuation;
43
+ const step=continuation.browser.step;
44
+ if(!['enter_credentials','verify_email'].includes(step))return continuation;
45
+ // The continuation report is account-bound before any secret or mail access.
46
+ if(report&&(report.accountRef!==mapping.accountRef||report.principal!==mapping.principal))return {status:'failed',reason:'unverified'};
47
+ try{
48
+ const token=(await readFile(mapping.credentials.tokenPath,'utf8')).trim();
49
+ const client=clientFactory({url:mapping.credentials.url,token});
50
+ const [account,email]=await Promise.all([client.get(mapping.accountRef),client.get(mapping.mailbox.emailId)]);
51
+ if(account.metadata.emailId!==email.id||!account.metadata.secretIds?.includes(mapping.credentials.secretId)||email.metadata.address!==mapping.principal)throw new Error('scope');
52
+ let values;
53
+ if(step==='enter_credentials'){
54
+ const credential=JSON.parse((await client.reveal(mapping.credentials.secretId)).value);
55
+ if(credential.origin!==state.origin||credential.email!==mapping.principal||typeof credential.password!=='string'||!credential.password)throw new Error('scope');
56
+ values={email:credential.email,password:credential.password,...(typeof credential.username==='string'?{username:credential.username}:{})};
57
+ }else{
58
+ if(!mapping.mailbox.verification)return {...continuation,browser:{...continuation.browser,step:'verify_email',mailboxUrl:mapping.credentials.url+'/app/inboxes/'+encodeURIComponent(email.id),nextStep:'Open the assigned inbox, use the matching verification message in the website, then report verify_session. No automatic extraction rule is configured.'}};
59
+ const since=Date.parse(state.progress?.verificationRequestedAt||report?.verificationRequestedAt);
60
+ if(!Number.isFinite(since)||since<Date.parse(state.createdAt)||since>Date.now())return {status:'failed',reason:'unverified'};
61
+ const progress={step:'verify_email',verificationRequestedAt:new Date(since).toISOString()};
62
+ const listing=await client.request('/emails/'+encodeURIComponent(email.id)+'/messages?limit=20');
63
+ const matches=[];
64
+ for(const item of listing.messages||listing){
65
+ if(!item.labels?.includes('received'))continue;
66
+ const detail=await client.message(email.id,item.message_id||item.id);
67
+ const candidate=emailChallenge({...detail,labels:item.labels,timestamp:item.timestamp},mapping.mailbox.verification,mapping.principal,since);
68
+ if(candidate)matches.push(candidate);
69
+ }
70
+ // Never guess between concurrent challenges on a shared mailbox.
71
+ if(matches.length!==1)return {status:'blocked',reason:'verification_pending',progress};
72
+ values=matches[0];
73
+ continuation.progress=progress;
74
+ }
75
+ continuation.browser={...continuation.browser,credentialCustody:'actas',credentialHelperUrl:await deliver(values)};
76
+ return continuation;
77
+ }catch{return {status:'blocked',reason:'vault_unavailable'};}
78
+ };
79
+ }
@@ -0,0 +1,12 @@
1
+ import {fork} from 'node:child_process';
2
+ import {fileURLToPath} from 'node:url';
3
+ export async function helper(values){
4
+ return new Promise((resolve,reject)=>{
5
+ const env=Object.fromEntries(['PATH','HOME','TMPDIR','SYSTEMROOT'].filter(k=>process.env[k]).map(k=>[k,process.env[k]]));
6
+ const child=fork(fileURLToPath(new URL('./credential-helper.mjs',import.meta.url)),[],{env,detached:true,stdio:['ignore','ignore','ignore','ipc']});
7
+ const timer=setTimeout(()=>{child.kill();reject(new Error('helper'))},10000);
8
+ child.once('error',()=>{clearTimeout(timer);reject(new Error('helper'))});
9
+ child.once('message',data=>{clearTimeout(timer);child.unref();resolve(data.url)});
10
+ child.send({values});
11
+ });
12
+ }