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.
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/client/accounts-inbox.mjs +42 -0
- package/client/accounts-mcp-core.mjs +116 -0
- package/client/accounts-mcp.mjs +48 -0
- package/client/accounts-registry.mjs +25 -0
- package/client/accounts-runtime.mjs +36 -0
- package/client/accounts-server.mjs +34 -0
- package/client/accounts.mjs +130 -0
- package/client/browser/providers.mjs +51 -0
- package/client/browser/runner.mjs +272 -0
- package/client/browser-adapter.mjs +108 -0
- package/client/caller.mjs +39 -0
- package/client/configure.mjs +45 -0
- package/client/credential-helper.mjs +24 -0
- package/client/external-caller.mjs +79 -0
- package/client/helper.mjs +12 -0
- package/client/identity.mjs +95 -0
- package/client/management.mjs +74 -0
- package/client/package-cli.mjs +8 -0
- package/client/public-cli.mjs +32 -0
- package/client/vault.mjs +130 -0
- package/package.json +28 -4
- package/scripts/proof-client.mjs +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BRAIN TRUST PRIVATE LIMITED
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ActAs MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give an AI agent its own identity, email, saved accounts and scoped credentials.
|
|
4
|
+
ActAs Pro costs US$12/month or US$120/year; there is no free tier. The MCP server
|
|
5
|
+
is available remotely over Streamable HTTP or locally over stdio.
|
|
6
|
+
|
|
7
|
+
## Remote: no install
|
|
8
|
+
|
|
9
|
+
Add `https://actas.dev/mcp` in an MCP client that supports bearer headers. Use
|
|
10
|
+
the token from your scoped ActAs connection in the Authorization bearer header,
|
|
11
|
+
saved through your client's protected credential settings. Do not put the token
|
|
12
|
+
in the endpoint URL, a prompt, or source control. OAuth is not offered.
|
|
13
|
+
|
|
14
|
+
The remote endpoint shares the 19 stdio tool schemas and applies the same API
|
|
15
|
+
permissions and revocation checks on every call. Browser flows still need a
|
|
16
|
+
local browser adapter. Private verification handles and registered executable
|
|
17
|
+
consumers return `needs_local_runtime` remotely; use the local client below.
|
|
18
|
+
|
|
19
|
+
Stored passwords and TOTP codes are withheld by default. To return them remotely,
|
|
20
|
+
explicitly configure `ActAs-MCP-Secret-Output: allow` as an additional header.
|
|
21
|
+
Existing reveal scopes and vault grants still apply. A hosted client may retain
|
|
22
|
+
secret tool results in chat history; prefer the local runtime to avoid that.
|
|
23
|
+
Inbox sign-in codes also enter the client's results. Workspace management tokens
|
|
24
|
+
require `ActAs-MCP-Management: allow`; prefer scoped identity connections.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
Requires Node.js 22 or newer and an ActAs subscription at https://actas.dev.
|
|
29
|
+
Download https://actas.dev/actas-mcp.tgz and run:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install -g ./actas-mcp.tgz
|
|
33
|
+
actas-accounts-mcp --help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Create an identity in your workspace, give it an inbox and grant only the
|
|
37
|
+
resources and actions it needs. Download its private connection from the Access
|
|
38
|
+
tab. Store it outside your source repository with owner-only file permissions.
|
|
39
|
+
|
|
40
|
+
Add this configuration to a stdio MCP client (replace the example file path):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"actas": {
|
|
46
|
+
"command": "actas-accounts-mcp",
|
|
47
|
+
"env": {
|
|
48
|
+
"ACTAS_CONNECTION_FILE": "/absolute/path/to/actas-identity.json"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The connection file contains a bearer token. Never put its contents in prompts,
|
|
56
|
+
URLs, logs or git. Revoke the connection in ActAs to stop its future requests.
|
|
57
|
+
|
|
58
|
+
## Tools and permissions
|
|
59
|
+
|
|
60
|
+
The server lists saved accounts, runs scoped account actions, retrieves granted
|
|
61
|
+
login credentials and current TOTP codes, and waits for verification mail. Some
|
|
62
|
+
tools return sensitive credentials or codes to the authorized agent: grant only
|
|
63
|
+
what that agent should see. Management tools for identity creation and domain
|
|
64
|
+
setup require a separate management connection; ordinary identity connections
|
|
65
|
+
cannot gain management access by choosing a tool.
|
|
66
|
+
|
|
67
|
+
The optional browser adapter needs a locally installed Playwright Chromium
|
|
68
|
+
browser (`npx playwright install chromium`). Website signup and login depend on
|
|
69
|
+
the website and its policies. Phone, ID checks and CAPTCHAs require a human
|
|
70
|
+
handoff; saving an account record is not proof of external registration.
|
|
71
|
+
|
|
72
|
+
Documentation: https://actas.dev/docs
|
|
73
|
+
|
|
74
|
+
Plain-text quickstart: https://actas.dev/mcp.md
|
|
75
|
+
|
|
76
|
+
API reference: https://actas.dev/openapi.json
|
|
77
|
+
|
|
78
|
+
Support: support@actas.dev
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {randomBytes} from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Values never leave this trusted runtime through MCP. Handles expire and consume once.
|
|
4
|
+
export class PrivateValues {
|
|
5
|
+
#values=new Map();
|
|
6
|
+
constructor(now=Date.now){this.now=now;}
|
|
7
|
+
put(value){for(const [key,item] of this.#values)if(item.expiresAt<=this.now())this.#values.delete(key);if(this.#values.size>=1000)throw Error('Private value capacity reached.');const handle=randomBytes(32).toString('base64url'),expiresAt=this.now()+60000;this.#values.set(handle,{value,expiresAt});return {valueHandle:handle,expiresAt};}
|
|
8
|
+
consume(handle){const item=this.#values.get(handle);this.#values.delete(handle);if(!item||item.expiresAt<=this.now())throw Error('Private value is unavailable.');return item.value;}
|
|
9
|
+
}
|
|
10
|
+
function senderAddress(value){if(typeof value!=='string')return '';const match=/^(?:[^<>]*<)?([^<>\s]+@[^<>\s]+)>?$/.exec(value.trim());return match?.[1].toLowerCase()??'';}
|
|
11
|
+
export function validateInboxArgs(args){
|
|
12
|
+
const keys=['emailId','sender','subject','after','linkOrigin','linkPath','codePattern','timeoutMs'];if(!args||Object.keys(args).some(k=>!keys.includes(k)))throw Error('Invalid inbox request.');
|
|
13
|
+
if(typeof args.emailId!=='string'||!args.emailId||args.emailId.length>160||typeof args.sender!=='string'||!/^\S+@\S+\.\S+$/.test(args.sender)||args.sender.length>254||typeof args.subject!=='string'||!args.subject||args.subject.length>200||typeof args.after!=='string'||!Number.isFinite(Date.parse(args.after))||Date.parse(args.after)>Date.now())throw Error('Invalid inbox request.');
|
|
14
|
+
if(args.codePattern){if(args.linkOrigin||args.linkPath||! /^(?:\\d|\[A-Z0-9\])\{(?:[4-9]|1[0-2])\}$/.test(args.codePattern))throw Error('Use a bounded digit or uppercase code pattern.');}
|
|
15
|
+
else{const url=new URL(args.linkOrigin);if(url.protocol!=='https:'||url.origin!==args.linkOrigin||typeof args.linkPath!=='string'||!args.linkPath.startsWith('/')||/[?#\\]/.test(args.linkPath))throw Error('Use an exact HTTPS link origin and path.');}
|
|
16
|
+
if(args.timeoutMs!==undefined&&(!Number.isInteger(args.timeoutMs)||args.timeoutMs<0||args.timeoutMs>30000))throw Error('Invalid timeout.');return args;
|
|
17
|
+
}
|
|
18
|
+
export function verificationValue(message,args,now=Date.now()){
|
|
19
|
+
const timestamp=Date.parse(message.timestamp),after=Date.parse(args.after);
|
|
20
|
+
if(!message.labels?.includes('received')||!Number.isFinite(timestamp)||timestamp<after||timestamp>now||senderAddress(message.from)!==args.sender.toLowerCase()||typeof message.subject!=='string'||!message.subject.includes(args.subject)||typeof message.text!=='string')return;
|
|
21
|
+
// ActAs wraps received mail as untrusted; match only the body, and only
|
|
22
|
+
// when ActAs authenticated the sender's domain.
|
|
23
|
+
if(message.sender_check?.domain_verified!==true)return;
|
|
24
|
+
const wrapped=message.untrusted?/^<(untrusted-email-[a-f0-9]+)>\n[\s\S]*?\n\n([\s\S]*)\n<\/\1>$/.exec(message.text):null;
|
|
25
|
+
if(message.untrusted&&!wrapped)return;
|
|
26
|
+
const body=(wrapped?wrapped[2]:message.text).slice(0,200*1024);
|
|
27
|
+
if(args.codePattern){const re=new RegExp(`(?:^|[^A-Za-z0-9])(${args.codePattern})(?=$|[^A-Za-z0-9])`),match=re.exec(body);return match?.[1];}
|
|
28
|
+
for(const candidate of body.match(/https:\/\/[^\s<>"']+/g)??[]){try{const url=new URL(candidate);if(url.origin===args.linkOrigin&&url.pathname===args.linkPath&&!url.username&&!url.password&&!url.hash)return url.href;}catch{}}
|
|
29
|
+
}
|
|
30
|
+
export async function waitForInbox(args,request,values,{now=Date.now,sleep=ms=>new Promise(r=>setTimeout(r,ms))}={}){
|
|
31
|
+
validateInboxArgs(args);const deadline=now()+(args.timeoutMs??0);
|
|
32
|
+
do{
|
|
33
|
+
const listing=await request('/emails/'+encodeURIComponent(args.emailId)+'/messages?limit=20','GET');
|
|
34
|
+
for(const item of listing.messages??listing){
|
|
35
|
+
if(!item.labels?.includes('received'))continue;const id=item.message_id??item.id;if(typeof id!=='string')continue;
|
|
36
|
+
const detail=await request('/emails/'+encodeURIComponent(args.emailId)+'/messages/'+encodeURIComponent(id),'GET');
|
|
37
|
+
const value=verificationValue(detail,args,now());if(value!==undefined)return {status:'matched',...values.put(value)};
|
|
38
|
+
}
|
|
39
|
+
if(now()>=deadline)return {status:'pending'};await sleep(Math.min(1000,deadline-now()));
|
|
40
|
+
}while(now()<=deadline);
|
|
41
|
+
return {status:'pending'};
|
|
42
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {PrivateValues,waitForInbox} from './accounts-inbox.mjs';
|
|
2
|
+
|
|
3
|
+
// Routing metadata stays outside the strict browser report/vault boundary.
|
|
4
|
+
// The step ID survives token rotation so callers can persist one Turnless request.
|
|
5
|
+
function humanResult(result,name,args,account,action){
|
|
6
|
+
if(result.status!=='needs_human')return result;
|
|
7
|
+
account=result.account??account;
|
|
8
|
+
const platform={x:'X',instagram:'Instagram',github:'GitHub',bluesky:'Bluesky',reddit:'Reddit',discord:'Discord'}[account?.platform]??account?.platform??'the provider';
|
|
9
|
+
const ref=account?.accountRef??args.accountRef;
|
|
10
|
+
const reason=result.handoff??result.reason??(account?.method==='browser'?'human_credential_entry':'api_credentials');
|
|
11
|
+
const actions={captcha:'Complete the CAPTCHA',mfa:'Complete the authentication challenge',phone:'Complete the phone verification',provider_policy:'Review the provider approval',human_credential_entry:`Sign in as ${account?.handle??ref}`,api_credentials:`Create and save official ${platform} API credentials for ${ref} in its ActAs vault`};
|
|
12
|
+
const where=reason==='api_credentials'?`${platform} account ${account?.handle??ref}: developer settings, then a private Terminal running actas secret set`: `ActAs ${platform} ${action==='signup'?'signup':'sign-in'} window for ${ref} at ${result.origin??result.humanRequest?.openedUrl}`;
|
|
13
|
+
const question=reason==='api_credentials'?`${actions[reason]}.`:`${actions[reason]??actions.provider_policy} in the ActAs ${action==='signup'?'signup':'sign-in'} window.`;
|
|
14
|
+
const resumeHandle=result.resumeToken?{tool:name,arguments:{accountRef:ref,resumeToken:result.resumeToken}}:name==='actas_account_connect'?{tool:account?.method==='browser'?'actas_account_signup':name,arguments:account?.method==='browser'?{accountRef:ref}:{...args}}:undefined;
|
|
15
|
+
return {...result,kind:'action',question,where,
|
|
16
|
+
why:`${action==='signup'?'Signup':action==='login'?'Sign-in':'Account connection'} for ${platform} account ${ref}${account?.product?` (product ${account.product})`:''} is paused until a person completes this step.`,
|
|
17
|
+
brianOnly:true,humanStepId:JSON.stringify([account?.workspace,account?.product,ref,result.flowId??'connect',reason,result.flowId?result.humanRequest?.createdAt:null]),
|
|
18
|
+
...(resumeHandle?{resumeHandle}:{})};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The caller cannot supply argv. Only owner-registered exact arguments execute.
|
|
22
|
+
export async function runAccountCommand(consumer,env,spawnChild){
|
|
23
|
+
const {childEnvironment}=await import('./vault.mjs');
|
|
24
|
+
spawnChild??=(await import('node:child_process')).spawn;
|
|
25
|
+
if(!consumer||typeof consumer.executable!=='string'||!consumer.executable.startsWith('/')||!/^[a-f0-9]{64}$/.test(consumer.contentHash)||!Array.isArray(consumer.args)||consumer.args.some(x=>typeof x!=='string'||x.includes('\0')))throw Error('Invalid registered consumer.');
|
|
26
|
+
if(!env||typeof env!=='object'||Object.values(env).some(x=>typeof x!=='string'||x.includes('\0'))||'ACTAS_BROWSER_STATE' in env)throw Error('Invalid private session.');
|
|
27
|
+
const {open,mkdtemp,writeFile,rm}=await import('node:fs/promises'),{createHash}=await import('node:crypto'),{tmpdir}=await import('node:os'),{join}=await import('node:path');
|
|
28
|
+
const binary=await open(consumer.executable,'r');let directory;
|
|
29
|
+
try{
|
|
30
|
+
const stat=await binary.stat();if(!stat.isFile()||stat.size>100*1024*1024||stat.mode&0o022)throw Error('Consumer binary must not be writable by its group or others.');
|
|
31
|
+
const bytes=await binary.readFile();if(createHash('sha256').update(bytes).digest('hex')!==consumer.contentHash)throw Error('Registered consumer changed.');
|
|
32
|
+
// Execute a private verified snapshot: replacing the registered path cannot
|
|
33
|
+
// substitute code between validation and exec (portable to macOS).
|
|
34
|
+
directory=await mkdtemp(join(tmpdir(),'actas-consumer-'));
|
|
35
|
+
const executable=join(directory,'consumer');await writeFile(executable,bytes,{mode:0o500,flag:'wx'});
|
|
36
|
+
return await new Promise((resolve,reject)=>{
|
|
37
|
+
let child;try{child=spawnChild(executable,consumer.args,{env:{...childEnvironment(),...env},shell:false,stdio:'ignore'});}catch{reject(Error('Account consumer could not start.'));return;}
|
|
38
|
+
const timer=setTimeout(()=>child.kill('SIGKILL'),30000);
|
|
39
|
+
child.once('error',()=>{clearTimeout(timer);reject(Error('Account consumer could not start.'));});
|
|
40
|
+
child.once('exit',code=>{clearTimeout(timer);resolve({status:code===0?'succeeded':'failed'});});
|
|
41
|
+
});
|
|
42
|
+
}finally{await binary.close();if(directory)await rm(directory,{recursive:true,force:true});}
|
|
43
|
+
}
|
|
44
|
+
// Same contract as the SDK's vault.login: username/email, password and a current TOTP code, never the seed.
|
|
45
|
+
export async function vaultLogin(request,scope,path){
|
|
46
|
+
const read=body=>request('/vault/read','POST',{...scope,path,...body});
|
|
47
|
+
const entry=await read({action:'read'});if(entry.kind!=='login')throw Error('Not a login item.');
|
|
48
|
+
const field=async name=>(await read({action:'reveal',field:name})).value||null;
|
|
49
|
+
const [username,email,password]=await Promise.all([field('username'),field('email'),entry.login?.hasPassword?field('password'):null]);
|
|
50
|
+
const totp=entry.login?.hasTotp?await read({action:'totp:code'}):null;
|
|
51
|
+
return {path,username,email,password,totp:totp?{code:totp.code,secondsRemaining:totp.secondsRemaining}:null};
|
|
52
|
+
}
|
|
53
|
+
export function createAccountsRuntime({request,codesRequest=request,scope,identityId,browserAdapter,spawnChild,runConsumer,values=new PrivateValues()}){
|
|
54
|
+
const call=(route,body={})=>request('/account-tools'+route,'POST',{...body,...scope});
|
|
55
|
+
const inbox=args=>waitForInbox(args,request,values);
|
|
56
|
+
const flows=new Map();
|
|
57
|
+
async function saveReport(report){
|
|
58
|
+
const state=flows.get(report.flowId);if(!state||state.accountRef!==report.accountRef||state.uncertain)throw Error('Flow must be reconciled before reporting.');
|
|
59
|
+
const key=JSON.stringify(report);if(state.lastReport===key)return state.latest;
|
|
60
|
+
if(state.saving)throw Error('Flow report already in flight.');state.saving=true;
|
|
61
|
+
try{
|
|
62
|
+
state.latest=await call('/flow/report',{accountRef:state.accountRef,flowId:report.flowId,report,...(state.resumeToken?{resumeToken:state.resumeToken}:{})});
|
|
63
|
+
state.resumeToken=state.latest.resumeToken;state.lastReport=key;return state.latest;
|
|
64
|
+
}catch(error){state.uncertain=true;throw error;}finally{state.saving=false;}
|
|
65
|
+
}
|
|
66
|
+
const adapterOptions={request:(route,method,body)=>request(route,method,body===undefined?undefined:{...body,...scope}),scope,saveReport,waitForInbox:inbox,consumePrivateValue:handle=>values.consume(handle)};
|
|
67
|
+
async function flow(action,args){
|
|
68
|
+
// Without an installed browser adapter do not create a dispatched operation.
|
|
69
|
+
const account=await call('/status',{accountRef:args.accountRef});
|
|
70
|
+
if(account.status==='active'&&action==='signup')return {status:'complete',account,source:'saved_account'};
|
|
71
|
+
if(account.status==='active'&&account.method!=='browser')return call('/flow/start',{accountRef:args.accountRef,action});
|
|
72
|
+
if(!browserAdapter)return {status:'step',step:'inspect_form',reason:'browser_adapter_required',account};
|
|
73
|
+
const started=args.resumeToken?await call('/flow/resume',args):await call('/flow/start',{accountRef:args.accountRef,action});
|
|
74
|
+
if(!started.dispatch)return humanResult(flows.get(started.flowId)?.latest??started,`actas_account_${action}`,args,account,action);
|
|
75
|
+
const state={accountRef:args.accountRef};flows.set(started.flowId,state);
|
|
76
|
+
await browserAdapter.run({version:1,action:started.action??action,flowId:started.flowId,account:started.account,origin:started.origin,...(started.report?{report:started.report}:{})});
|
|
77
|
+
if(!state.latest)throw Error('Browser adapter did not persist its report.');
|
|
78
|
+
return humanResult(state.latest,`actas_account_${action}`,args,account,action);
|
|
79
|
+
}
|
|
80
|
+
return {adapterOptions,setBrowserAdapter(adapter){browserAdapter=adapter;},async invoke(name,args){
|
|
81
|
+
switch(name){
|
|
82
|
+
case 'actas_domains_list':return request('/domains?ownerId='+encodeURIComponent(scope.ownerId));
|
|
83
|
+
case 'actas_domains_add':return request('/domains','POST',{ownerId:scope.ownerId,domain:args.domain});
|
|
84
|
+
case 'actas_domains_verify':return request('/domains/'+encodeURIComponent(args.domain)+'/verify','POST',{});
|
|
85
|
+
case 'actas_domains_remove':return request('/domains/'+encodeURIComponent(args.domain),'DELETE');
|
|
86
|
+
case 'actas_identity_create':{
|
|
87
|
+
const {inbox,...fields}=args,identity=await request('/identities','POST',{...fields,...scope});if(!inbox)return identity;
|
|
88
|
+
const mailbox=await request('/identities/'+encodeURIComponent(identity.id)+'/email','POST',{});
|
|
89
|
+
return {...identity,inbox:{id:mailbox.id,address:mailbox.metadata?.address,status:mailbox.status}};
|
|
90
|
+
}
|
|
91
|
+
case 'actas_inbox_wait_for_code':{
|
|
92
|
+
const result=await codesRequest('/emails/'+encodeURIComponent(args.inboxId)+'/code','POST',{sender:args.sender,subject:args.subject,after:args.after,codePattern:args.pattern??'\\d{6}',timeoutMs:args.timeoutMs??30000});
|
|
93
|
+
return result.status==='matched'?{status:'matched',code:result.code,receivedAt:result.receivedAt}:{status:'pending'};
|
|
94
|
+
}
|
|
95
|
+
case 'actas_vault_login':return vaultLogin(request,scope,args.path);
|
|
96
|
+
case 'actas_totp_code':return request('/vault/read','POST',{...scope,path:args.path,action:'totp:code'});
|
|
97
|
+
case 'actas_identity_accounts':{const id=args.identityId??identityId;if(!id)throw Error('Choose an identity for this registry.');return request('/identities/'+encodeURIComponent(id)+'/accounts','GET');}
|
|
98
|
+
case 'actas_identity_account_close':{const result=await request('/identities/'+encodeURIComponent(args.identityId)+'/accounts/'+encodeURIComponent(args.registryId)+'/close','POST',args.revokeProvider===undefined?{}:{revokeProvider:args.revokeProvider});return {...result,accountStatus:result.status,...result.closure};}
|
|
99
|
+
case 'actas_account_list':return call('/list');
|
|
100
|
+
case 'actas_account_status':return call('/status',args);
|
|
101
|
+
case 'actas_account_connect':return humanResult(await call('/connect',args),name,args,undefined,'connect');
|
|
102
|
+
case 'actas_account_revoke':{const account=await call('/status',args),result=await call('/revoke',args);await browserAdapter?.revoke?.(account);return result;}
|
|
103
|
+
case 'actas_account_post':return call('/post',args);
|
|
104
|
+
case 'actas_account_signup':return flow('signup',args);
|
|
105
|
+
case 'actas_account_login':return flow('login',args);
|
|
106
|
+
case 'actas_account_use':{
|
|
107
|
+
if(Object.keys(args).some(k=>!['accountRef','consumerId'].includes(k))||!args.consumerId||!runConsumer)throw Error('An owner-registered consumer and isolated runtime are required.');
|
|
108
|
+
const handle=await call('/use',args);
|
|
109
|
+
const result=await runConsumer({...scope,accountRef:args.accountRef,sessionHandle:handle.sessionHandle});
|
|
110
|
+
return {status:result?.status==='succeeded'?'succeeded':'failed'};
|
|
111
|
+
}
|
|
112
|
+
case 'actas_inbox_wait_for':return inbox(args);
|
|
113
|
+
default:throw Error('Unknown account tool.');
|
|
114
|
+
}
|
|
115
|
+
}};
|
|
116
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import {z} from 'zod';
|
|
4
|
+
import {pathToFileURL} from 'node:url';
|
|
5
|
+
import {readIdentityConnection,identityRequest} from './identity.mjs';
|
|
6
|
+
import {createAccountsRuntime} from './accounts-mcp-core.mjs';
|
|
7
|
+
import {PrivateValues,validateInboxArgs} from './accounts-inbox.mjs';
|
|
8
|
+
import {createBrowserAdapter} from './browser-adapter.mjs';
|
|
9
|
+
import {request as socketRequest} from 'node:http';
|
|
10
|
+
import {readFile} from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
export {createAccountsMcp} from './accounts-server.mjs';
|
|
13
|
+
import {createAccountsMcp} from './accounts-server.mjs';
|
|
14
|
+
const ref=z.string().regex(/^[A-Za-z0-9_-]{1,80}$/);
|
|
15
|
+
export async function main(){
|
|
16
|
+
if(process.argv.includes('--help')||process.argv.includes('-h')){
|
|
17
|
+
process.stdout.write('ActAs MCP — identity, email and scoped accounts for AI agents.\nRequires Node.js 22+ and ActAs Pro (US$12/month or US$120/year).\nSet ACTAS_CONNECTION_FILE to your private identity connection JSON file.\nStart actas-accounts-mcp from a stdio MCP client.\nDocumentation: https://actas.dev/docs\n');return;
|
|
18
|
+
}
|
|
19
|
+
const connection=await readIdentityConnection(),request=(route,method='GET',body)=>identityRequest(connection,route,method,body);
|
|
20
|
+
const identity=connection.kind==='management'?null:await request('/identities/'+connection.identityId);
|
|
21
|
+
const scope={ownerId:identity?.ownerId??connection.ownerId,productId:identity?identity.productId:process.env.ACTAS_PRODUCT_ID??null};
|
|
22
|
+
// Trusted operator configuration contains selectors/inbox filters, never credentials or executable code.
|
|
23
|
+
let browserOptions={};
|
|
24
|
+
if(process.env.ACTAS_BROWSER_CONFIG_FILE){
|
|
25
|
+
const raw=await readFile(process.env.ACTAS_BROWSER_CONFIG_FILE,'utf8');if(raw.length>65536)throw Error('Browser configuration too large.');
|
|
26
|
+
browserOptions=JSON.parse(raw);
|
|
27
|
+
if(!browserOptions||Array.isArray(browserOptions)||Object.keys(browserOptions).some(k=>!['stateDir','emailRules'].includes(k))||browserOptions.stateDir!==undefined&&(typeof browserOptions.stateDir!=='string'||!browserOptions.stateDir))throw Error('Invalid browser configuration.');
|
|
28
|
+
const rules=browserOptions.emailRules??{};
|
|
29
|
+
if(!rules||typeof rules!=='object'||Array.isArray(rules))throw Error('Invalid email rules.');
|
|
30
|
+
for(const [accountRef,rule] of Object.entries(rules)){
|
|
31
|
+
ref.parse(accountRef);
|
|
32
|
+
if(!rule||typeof rule!=='object'||Array.isArray(rule)||Object.keys(rule).some(k=>!['selector','submitSelector','match'].includes(k))||typeof rule.selector!=='string'||!rule.selector||rule.selector.length>1024||typeof rule.submitSelector!=='string'||!rule.submitSelector||rule.submitSelector.length>1024)throw Error('Invalid email rule.');
|
|
33
|
+
validateInboxArgs(rule.match);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Codes connections hold only emails:codes, so they ride alongside the main connection.
|
|
37
|
+
const codes=process.env.ACTAS_CODES_CONNECTION_FILE?await readIdentityConnection(process.env.ACTAS_CODES_CONNECTION_FILE):null;
|
|
38
|
+
const codesRequest=codes?(route,method='GET',body)=>identityRequest(codes,route,method,body):request;
|
|
39
|
+
const values=new PrivateValues(),runtime=createAccountsRuntime({request,codesRequest,scope,identityId:connection.identityId,values,runConsumer:process.env.ACTAS_RUNTIME_SOCKET?body=>new Promise((resolve,reject)=>{const req=socketRequest({socketPath:process.env.ACTAS_RUNTIME_SOCKET,path:'/use',method:'POST',timeout:35000},res=>{let raw='';res.on('data',b=>{raw+=b;if(raw.length>1024)req.destroy();});res.on('end',()=>{try{resolve(JSON.parse(raw));}catch{reject(Error('Runtime unavailable'));}});});req.on('error',()=>reject(Error('Runtime unavailable')));req.on('timeout',()=>req.destroy());req.end(JSON.stringify(body));}):undefined});
|
|
40
|
+
const adapter=createBrowserAdapter({...browserOptions,...runtime.adapterOptions});runtime.setBrowserAdapter(adapter);
|
|
41
|
+
const server=createAccountsMcp(runtime);
|
|
42
|
+
let closing;
|
|
43
|
+
const close=()=>closing??=Promise.resolve().then(async()=>{await adapter.close();await server.close();}).catch(()=>{});
|
|
44
|
+
server.server.onclose=()=>{void close();};
|
|
45
|
+
for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>{void close();});
|
|
46
|
+
await server.connect(new StdioServerTransport());
|
|
47
|
+
}
|
|
48
|
+
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(()=>{process.stderr.write('ActAs accounts MCP could not start. Verify the private connection file and account permissions.\n');process.exitCode=1;});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {readIdentityConnection,identityRequest} from './identity.mjs';
|
|
2
|
+
export const registryHelp=`ActAs — identity accounts registry
|
|
3
|
+
actas accounts list [identity-id]
|
|
4
|
+
actas accounts close <registry-id> [identity-id] [--revoke-provider]
|
|
5
|
+
actas accounts retire <identity-id> --confirm-retire
|
|
6
|
+
Owners and management may close. Agents list only granted records.
|
|
7
|
+
Close erases ActAs credentials; follow the returned external-deletion checklist.
|
|
8
|
+
`;
|
|
9
|
+
export async function runRegistryCommand(args){
|
|
10
|
+
if(args[0]!=='accounts')return false;
|
|
11
|
+
try{
|
|
12
|
+
const [,action,...raw]=args;
|
|
13
|
+
const revokeProvider=raw.includes('--revoke-provider');
|
|
14
|
+
if(revokeProvider&&(action!=='close'||raw.filter(v=>v==='--revoke-provider').length!==1))throw Error(registryHelp);
|
|
15
|
+
const rest=raw.filter(v=>v!=='--revoke-provider');
|
|
16
|
+
if(!['list','close','retire'].includes(action)||action==='list'&&rest.length>1||action==='close'&&(rest.length<1||rest.length>2)||action==='retire'&&(rest.length!==2||rest[1]!=='--confirm-retire'))throw Error(registryHelp);
|
|
17
|
+
const connection=await readIdentityConnection();
|
|
18
|
+
const identity=action==='list'?rest[0]??connection.identityId:action==='close'?rest[1]??connection.identityId:rest[0];
|
|
19
|
+
if(!identity)throw Error('Management connections must select an identity ID.');
|
|
20
|
+
const base='/identities/'+encodeURIComponent(identity)+'/accounts';
|
|
21
|
+
const result=await identityRequest(connection,action==='list'?base:action==='close'?base+'/'+encodeURIComponent(rest[0])+'/close':base+'/retire',action==='list'?'GET':'POST',action==='list'?undefined:action==='close'?(revokeProvider?{revokeProvider:true}:{}):{confirm:'RETIRE ACCOUNTS'});
|
|
22
|
+
process.stdout.write(JSON.stringify(action==='close'?{...result,accountStatus:result.status,...result.closure}:result,null,2)+'\n');
|
|
23
|
+
}catch{process.stderr.write('Accounts registry request failed. Check identity, permissions and command arguments; refresh the list before retrying an uncertain close.\n');process.exitCode=1;}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Owner-operated service. Run under a dedicated OS account; its config and
|
|
2
|
+
* credentials must be inaccessible to the agent user. Socket grants operation only. */
|
|
3
|
+
import {createServer} from 'node:http';
|
|
4
|
+
import {readFile,lstat,chmod,unlink} from 'node:fs/promises';
|
|
5
|
+
import {runAccountCommand} from './accounts-mcp-core.mjs';
|
|
6
|
+
export function createConsumerService({connection,runtimeCredential,scope,consumers=[],fetcher=fetch,execute=runAccountCommand}){
|
|
7
|
+
let busy=false;
|
|
8
|
+
return createServer(async(req,res)=>{
|
|
9
|
+
const reply=status=>{res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({status}));};
|
|
10
|
+
if(req.method!=='POST'||req.url!=='/use'||busy){reply('failed');return;}
|
|
11
|
+
busy=true;
|
|
12
|
+
try{
|
|
13
|
+
let raw='';for await(const chunk of req){raw+=chunk;if(raw.length>2048)throw Error();}
|
|
14
|
+
const body=JSON.parse(raw);
|
|
15
|
+
if(Object.keys(body).some(k=>!['ownerId','productId','accountRef','sessionHandle'].includes(k))||body.ownerId!==scope.ownerId||body.productId!==scope.productId||typeof body.accountRef!=='string'||typeof body.sessionHandle!=='string')throw Error();
|
|
16
|
+
const response=await fetcher(connection.apiUrl+'/api/v1/account-tools/session/consume',{method:'POST',redirect:'error',headers:{Authorization:'Bearer '+connection.token,'X-ActAs-Runtime':runtimeCredential,'Content-Type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(15000)});
|
|
17
|
+
if(!response.ok)throw Error();const {data}=await response.json();
|
|
18
|
+
const consumer=consumers.find(pin=>pin.id===data.consumer?.id&&pin.accountRef===body.accountRef);
|
|
19
|
+
if(!consumer||consumer.executable!==data.consumer.executable||consumer.contentHash!==data.consumer.contentHash||JSON.stringify(consumer.args)!==JSON.stringify(data.consumer.args))throw Error();
|
|
20
|
+
const allowed=new Set(['GH_TOKEN','BLUESKY_ACCESS_JWT','BLUESKY_HANDLE','BLUESKY_DID','BLUESKY_SERVICE','REDDIT_ACCESS_TOKEN','REDDIT_USERNAME']);
|
|
21
|
+
if(!data.env||typeof data.env!=='object'||Array.isArray(data.env)||Object.keys(data.env).some(k=>!allowed.has(k)))throw Error();
|
|
22
|
+
const result=await execute(consumer,data.env);reply(result.status==='succeeded'?'succeeded':'failed');
|
|
23
|
+
}catch{reply('failed');}finally{busy=false;}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if(process.argv[1]&&import.meta.url===new URL('file://'+process.argv[1]).href){
|
|
27
|
+
const configPath=process.argv[2];
|
|
28
|
+
try{
|
|
29
|
+
const info=await lstat(configPath);if(!info.isFile()||info.isSymbolicLink()||info.uid!==process.getuid()||info.mode&0o077)throw Error();
|
|
30
|
+
const config=JSON.parse(await readFile(configPath,'utf8'));
|
|
31
|
+
if(!config.socket||!config.runtimeCredential||!config.scope||!config.connection||!Array.isArray(config.consumers)||!config.consumers.length)throw Error();
|
|
32
|
+
try{const socket=await lstat(config.socket);if(!socket.isSocket()||socket.uid!==process.getuid())throw Error();await unlink(config.socket);}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
33
|
+
const server=createConsumerService(config);server.listen(config.socket,async()=>{await chmod(config.socket,0o660);});
|
|
34
|
+
for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>server.close(async()=>{await unlink(config.socket);process.exit(0);}));
|
|
35
|
+
}catch{process.stderr.write('Private ActAs runtime configuration unavailable.\n');process.exitCode=1;}
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import {z} from 'zod';
|
|
3
|
+
|
|
4
|
+
const ref=z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),path=z.string().regex(/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/).max(512);
|
|
5
|
+
const select={accountRef:ref};
|
|
6
|
+
export function createAccountsMcp(runtime){
|
|
7
|
+
const server=new McpServer({name:'actas-accounts',version:'0.1.0'},{maxToolInputElements:300});
|
|
8
|
+
const definitions=[
|
|
9
|
+
['actas_identity_create','Create an identity with a permanently reserved chosen email name (management). inbox:true also allocates its inbox.',{name:z.string().min(1).max(80),localPart:z.string().min(3).max(40).optional(),domain:z.string().min(3).max(253).optional(),inbox:z.boolean().optional()}],
|
|
10
|
+
['actas_inbox_wait_for_code','Wait up to 60s for one sign-in code from an authenticated sender (emails:codes). Returns only the code.',{inboxId:z.string().min(1).max(160),sender:z.string().email().max(254),subject:z.string().min(1).max(200),after:z.string().datetime(),pattern:z.string().regex(/^(?:\\d|\[A-Z0-9\])\{(?:[4-9]|1[0-2])\}$/).optional(),timeoutMs:z.number().int().min(0).max(60000).optional()}],
|
|
11
|
+
['actas_vault_login','Return a granted login item: username/email, password and a current TOTP code. Never the TOTP seed (secrets:reveal plus read, reveal and totp:code grants).',{path}],
|
|
12
|
+
['actas_domains_list','List workspace domains and exact DNS records (domain management).',{}],
|
|
13
|
+
['actas_domains_add','Claim a workspace mail domain (domain management); reconcile by checking the same domain.',{domain:z.string().min(3).max(253)}],
|
|
14
|
+
['actas_domains_verify','Check ownership, DKIM and MX and reconcile SES setup (domain management).',{domain:z.string().min(3).max(253)}],
|
|
15
|
+
['actas_domains_remove','Permanently retire a domain and stop delivery (domain management).',{domain:z.string().min(3).max(253)}],
|
|
16
|
+
['actas_totp_code','Return only a granted current six-digit TOTP code and seconds remaining.',{path}],
|
|
17
|
+
['actas_account_list','List accounts granted to this product agent.',{}],
|
|
18
|
+
['actas_identity_accounts','List this identity’s registry. Agents see only granted accounts.',{identityId:z.string().min(1).max(160).optional()}],
|
|
19
|
+
['actas_identity_account_close','Remove all ActAs grants and saved login; return external-deletion checklist (owner/management).',{identityId:z.string().min(1).max(160),registryId:z.string().min(1).max(512),revokeProvider:z.boolean().optional()}],
|
|
20
|
+
['actas_account_status','Read account status without exposing credentials.',select],
|
|
21
|
+
['actas_account_connect','Connect and verify official API credentials saved in the product vault (owner/management).',{...select,platform:z.enum(['bluesky','github','reddit','x','instagram','discord']),handle:z.string().regex(/^[A-Za-z0-9_.-]+$/).max(120),email:z.string().email().max(254),identityId:z.string().min(1).max(160).optional(),secretRefs:z.record(z.enum(['token','appPassword','clientId','clientSecret','password','appId','installationId','privateKey']),path).optional()}],
|
|
22
|
+
['actas_account_signup','Start or resume a saved account signup; pause for platform human checks.',{...select,resumeToken:z.string().max(160).optional()}],
|
|
23
|
+
['actas_account_login','Start or resume login for a saved account.',{...select,resumeToken:z.string().max(160).optional()}],
|
|
24
|
+
['actas_account_use','Run an owner-registered fixed consumer in the isolated ActAs runtime.',{...select,consumerId:z.string().uuid()}],
|
|
25
|
+
['actas_account_post','Post through a fixed official API action without exposing credentials.',{...select,action:z.enum(['post','issue','submit','comment']),target:z.string().max(180).optional(),title:z.string().max(300).optional(),text:z.string().min(1).max(10000)}],
|
|
26
|
+
['actas_account_revoke','Revoke future ActAs account use and pending handles (owner/management).',select],
|
|
27
|
+
['actas_inbox_wait_for','Wait for a received verification message. Returns a private value handle; never the code/link.',{emailId:z.string().min(1).max(160),sender:z.string().email().max(254),subject:z.string().min(1).max(200),after:z.string().datetime(),linkOrigin:z.string().url().optional(),linkPath:z.string().max(1024).optional(),codePattern:z.string().max(30).optional(),timeoutMs:z.number().int().min(0).max(30000).optional()}]
|
|
28
|
+
];
|
|
29
|
+
for(const [name,description,shape] of definitions)server.registerTool(name,{description,inputSchema:z.object(shape).strict()},async args=>{
|
|
30
|
+
try{const result=await runtime.invoke(name,args);return {content:[{type:'text',text:JSON.stringify(result)}]};}
|
|
31
|
+
catch{return {isError:true,content:[{type:'text',text:'ActAs account operation failed. Check account status and permissions before retrying; no automatic retry was made.'}]};}
|
|
32
|
+
});
|
|
33
|
+
return server;
|
|
34
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {createHash, randomUUID} from 'node:crypto';
|
|
2
|
+
import {mkdir, open, readFile, rename, unlink} from 'node:fs/promises';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {isAbsolute, join} from 'node:path';
|
|
5
|
+
import {spawn} from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const reasons = {
|
|
8
|
+
custodian_action_required: 'Chief must validate the broker request, perform the authorised fresh login in the operations browser, and return a nonsecret observation.',
|
|
9
|
+
broker_scope_denied: 'This broker only permits the assigned Infisical operations account login.',
|
|
10
|
+
authorization_required: 'The site can be inspected, but the account owner must assign this exact origin and account before credential entry or signup submission.',
|
|
11
|
+
verification_pending: 'Signup is saved. Resume after email verification; no duplicate account was requested.',
|
|
12
|
+
account_lookup_failed: 'The mapped existing account could not be retrieved. No signup or login was performed.',
|
|
13
|
+
registry_unavailable: 'The saved account registry could not be updated. Check the registry and connection before continuing; no browser credentials were delivered.',
|
|
14
|
+
vault_unavailable: 'The assigned development vault key could not be read. No browser credentials were delivered.',
|
|
15
|
+
helper_failed: 'The local credential-delivery helper could not start.',
|
|
16
|
+
browser_action_required: 'Continue the returned browser step, preserving required human handoffs and the operation ID.',
|
|
17
|
+
account_exists: 'This development profile maps an existing account. Use login; creating a new account is not enabled for this profile.',
|
|
18
|
+
integration_not_configured: 'No browser account driver is configured. Signup/login has not been performed.',
|
|
19
|
+
human_credential_entry: 'The browser requires a person to enter and submit credentials. Resume after that step.',
|
|
20
|
+
mfa: 'The service requires a human authentication step.',
|
|
21
|
+
captcha: 'The service requires a human verification step.',
|
|
22
|
+
provider_policy: 'The service requires a human approval or onboarding step.',
|
|
23
|
+
unsupported_site: 'The configured driver does not support this website.',
|
|
24
|
+
unverified: 'The driver did not verify a successful signup/login at the requested origin.',
|
|
25
|
+
driver_failed: 'The account driver failed. Its raw output was withheld because it may contain credentials.',
|
|
26
|
+
driver_timeout: 'The account driver timed out. Progress is retained; completion is unknown.',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function targetUrl(input) {
|
|
30
|
+
let url;
|
|
31
|
+
try {url = new URL(input);} catch {throw new Error('Provide a full website URL, such as https://example.com.');}
|
|
32
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname))) {
|
|
33
|
+
throw new Error('Use HTTPS, or loopback HTTP for a local test service.');
|
|
34
|
+
}
|
|
35
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
36
|
+
throw new Error('Use a public website URL without credentials, query parameters or fragments.');
|
|
37
|
+
}
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function save(path, state) {
|
|
42
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
43
|
+
const file = await open(temp, 'wx', 0o600);
|
|
44
|
+
try {await file.writeFile(JSON.stringify(state, null, 2)+'\n');} finally {await file.close();}
|
|
45
|
+
await rename(temp, path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runDriver(executable, request, timeoutMs) {
|
|
49
|
+
return new Promise(resolve => {
|
|
50
|
+
// A trusted, explicitly configured driver. No shell or inherited provider keys.
|
|
51
|
+
const env = Object.fromEntries(['PATH','HOME','TMPDIR','TEMP','SYSTEMROOT'].filter(k=>process.env[k]).map(k=>[k,process.env[k]]));
|
|
52
|
+
const child = spawn(executable, [], {shell:false, env, stdio:['pipe','pipe','pipe']});
|
|
53
|
+
let stdout = ''; let oversized = false; let timedOut = false;
|
|
54
|
+
const timer = setTimeout(()=>{timedOut=true;child.kill('SIGKILL');}, timeoutMs);
|
|
55
|
+
child.stdout.on('data', chunk => {stdout += chunk.toString();if(Buffer.byteLength(stdout)>65536){oversized=true;child.kill('SIGKILL');}});
|
|
56
|
+
child.stderr.resume(); // Never forward driver output: it can contain secrets.
|
|
57
|
+
child.stdin.on('error',()=>{});
|
|
58
|
+
child.on('error',()=>{clearTimeout(timer);resolve({status:'failed',reason:'driver_failed'});});
|
|
59
|
+
child.on('close',code=>{
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
if(timedOut)return resolve({status:'blocked',reason:'driver_timeout'});
|
|
62
|
+
if(code!==0||oversized)return resolve({status:'failed',reason:'driver_failed'});
|
|
63
|
+
try {resolve(JSON.parse(stdout));} catch {resolve({status:'failed',reason:'driver_failed'});}
|
|
64
|
+
});
|
|
65
|
+
child.stdin.end(JSON.stringify(request)+'\n');
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function accountFlow(action, input, options={}) {
|
|
70
|
+
if(!['signup','login'].includes(action))throw new Error('Choose signup or login.');
|
|
71
|
+
const url = targetUrl(input);
|
|
72
|
+
const stateDir = options.stateDir || process.env.ACTAS_STATE_DIR || join(homedir(),'.local','state','actas','accounts');
|
|
73
|
+
const driver = options.driver ?? process.env.ACTAS_ACCOUNT_DRIVER;
|
|
74
|
+
if(driver && !isAbsolute(driver))throw new Error('ACTAS_ACCOUNT_DRIVER must be an absolute path to a trusted executable.');
|
|
75
|
+
await mkdir(stateDir,{recursive:true,mode:0o700});
|
|
76
|
+
// Paths share an operation only within the selected origin/account pair.
|
|
77
|
+
const accountRef=options.accountRef;
|
|
78
|
+
if(accountRef!==undefined&&(typeof accountRef!=='string'||!accountRef))throw new Error('A nonempty account reference is required.');
|
|
79
|
+
const key = createHash('sha256').update(accountRef===undefined?url.origin:JSON.stringify([url.origin,accountRef])).digest('hex');
|
|
80
|
+
const path = join(stateDir,`${action}-${key}.json`);
|
|
81
|
+
const lockPath = join(stateDir,`${key}.lock`);
|
|
82
|
+
let lock;
|
|
83
|
+
try {lock = await open(lockPath,'wx',0o600);} catch(error) {
|
|
84
|
+
if(error.code==='EEXIST')return {status:'blocked',action,origin:url.origin,reason:'operation_in_progress',message:'An account operation is already running for this website. No second operation was started.',exitCode:2};
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await lock.writeFile(JSON.stringify({pid:process.pid,startedAt:new Date().toISOString()}));
|
|
89
|
+
let previous;
|
|
90
|
+
try {previous=JSON.parse(await readFile(path,'utf8'));} catch(error) {if(error.code!=='ENOENT')throw new Error('Saved operation state could not be read. It was not replaced.');}
|
|
91
|
+
if(previous && (previous.version!==1 || previous.action!==action || previous.origin!==url.origin || previous.accountRef!==accountRef || typeof previous.flowId!=='string'))throw new Error('Saved operation state does not match this request. It was not replaced.');
|
|
92
|
+
if(action==='signup' && previous?.status==='succeeded'&&!options.revalidateSignup)return {...receipt(previous),reused:true,exitCode:0};
|
|
93
|
+
const state = previous && (previous.status!=='succeeded'||action==='signup'&&options.revalidateSignup) ? previous : {version:1,flowId:randomUUID(),action,origin:url.origin,...(accountRef?{accountRef}:{}),url:url.href,createdAt:new Date().toISOString()};
|
|
94
|
+
state.updatedAt=new Date().toISOString();
|
|
95
|
+
state.status='pending';
|
|
96
|
+
delete state.verification;
|
|
97
|
+
delete state.reason;
|
|
98
|
+
await save(path,state); // Preserve the request before any external work.
|
|
99
|
+
let result;
|
|
100
|
+
if(options.execute)result=await options.execute(state);
|
|
101
|
+
else if(!driver)result={status:'blocked',reason:'integration_not_configured'};
|
|
102
|
+
else result=await runDriver(driver,{version:1,flowId:state.flowId,action,url:state.url,origin:state.origin,...(accountRef?{accountRef}:{}),requirements:{respectBrowserHumanTakeover:true,credentialsOutsideOutput:true,verifyAtRequestedOrigin:true}},options.timeoutMs||60000);
|
|
103
|
+
if(result?.status==='succeeded' && result.verification?.origin===url.origin && result.verification?.authenticated===true && ['authenticated_page','provider_api'].includes(result.verification?.method)) {
|
|
104
|
+
state.status='succeeded';
|
|
105
|
+
state.verification={origin:url.origin,authenticated:true,method:result.verification.method,...(result.verification.source==='authorised_caller_observation'?{source:result.verification.source}:{}),...(options.execute&&result.verification.custody==='chief-caller'?{custody:'chief-caller'}:{})};
|
|
106
|
+
} else {
|
|
107
|
+
state.status=result?.status==='needs_custodian'?'needs_custodian':result?.status==='needs_browser'?'needs_browser':result?.status==='needs_human'?'needs_human':result?.status==='blocked'?'blocked':'failed';
|
|
108
|
+
state.reason=Object.hasOwn(reasons,result?.reason)?result.reason:result?.status==='succeeded'?'unverified':'driver_failed';
|
|
109
|
+
}
|
|
110
|
+
if(result?.status==='needs_browser'&&options.execute)state.browser=result.browser;else delete state.browser;
|
|
111
|
+
if(options.execute && result?.brokerRequest)state.brokerRequest=result.brokerRequest;else delete state.brokerRequest;
|
|
112
|
+
if(options.execute && result?.progress)state.progress=result.progress;else delete state.progress;
|
|
113
|
+
state.updatedAt=new Date().toISOString();
|
|
114
|
+
await save(path,state);
|
|
115
|
+
return {...receipt(state),exitCode:state.status==='succeeded'?0:state.status==='failed'?1:2};
|
|
116
|
+
} finally {
|
|
117
|
+
await lock.close();
|
|
118
|
+
await unlink(lockPath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function receipt(state) {
|
|
123
|
+
return {action:state.action,origin:state.origin,...(state.accountRef?{accountRef:state.accountRef}:{}),flowId:state.flowId,status:state.status,
|
|
124
|
+
...(state.reason?{reason:state.reason,message:reasons[state.reason]}:{}),
|
|
125
|
+
...(state.verification?{verification:state.verification}:{}),
|
|
126
|
+
...(state.browser?{browser:state.browser}:{}),
|
|
127
|
+
...(state.progress?{progress:state.progress}:{}),
|
|
128
|
+
...(state.brokerRequest?{brokerRequest:state.brokerRequest}:{}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// First-party browser fallback. Challenge fields and policy buttons are observed
|
|
2
|
+
// only. Trusted credential references may be filled on the exact assigned origin.
|
|
3
|
+
const visible = async (page, selector) => page.locator(selector).first().isVisible().catch(() => false);
|
|
4
|
+
export async function detectHumanStep(page) {
|
|
5
|
+
for (const frame of page.frames()) {
|
|
6
|
+
if (/recaptcha|hcaptcha|challenges\.cloudflare|arkoselabs|funcaptcha/.test(frame.url())) {
|
|
7
|
+
const element = await frame.frameElement().catch(() => undefined);
|
|
8
|
+
if (element && await element.isVisible()) return 'captcha';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
if (await visible(page, 'input[autocomplete="one-time-code"], input[name="verificationCode"], input[name="challenge_response"]')) return 'mfa';
|
|
12
|
+
if (await visible(page, 'input[type="tel"], input[name="phone_number"]')) return 'phone';
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function createBrowserProviders({credentials, emailRules = {}} = {}) {
|
|
16
|
+
function provider(origins, entry, profileSelector, username, password, next, submit) {
|
|
17
|
+
const verify = async (page, account) => {
|
|
18
|
+
const handle = account.handle?.replace(/^@/, '');
|
|
19
|
+
if (!origins.includes(new URL(page.url()).origin) || !/^[A-Za-z0-9_.]{1,80}$/.test(handle)) return false;
|
|
20
|
+
return visible(page, profileSelector(handle));
|
|
21
|
+
};
|
|
22
|
+
return {origins, entry, verify,
|
|
23
|
+
async observe(page, account, action) {
|
|
24
|
+
if (!origins.includes(new URL(page.url()).origin)) return {kind:'human',reason:'provider_policy',id:'unexpected_origin'};
|
|
25
|
+
const reason = await detectHumanStep(page);
|
|
26
|
+
if (reason === 'captcha' || reason === 'phone') return {kind:'human',reason};
|
|
27
|
+
if (reason) return {kind:'human',reason};
|
|
28
|
+
if (await verify(page, account)) return {kind:'authenticated'};
|
|
29
|
+
const hasUsername=await visible(page,username), hasPassword=await visible(page,password);
|
|
30
|
+
// Signup has provider-specific policy/age/name requirements; do not
|
|
31
|
+
// silently consent or invent a person's details.
|
|
32
|
+
if (hasUsername || hasPassword) return {kind:'human',reason:'human_credential_entry'};
|
|
33
|
+
// Let client-rendered form/navigation arrive before asking a person to
|
|
34
|
+
// review an unknown page. Waiting is observation, never a login retry.
|
|
35
|
+
try { await page.locator(`${username}, ${password}, ${profileSelector(account.handle?.replace(/^@/,'') || '')}`).first().waitFor({state:'visible',timeout:3000}); return {kind:'waiting'}; }
|
|
36
|
+
catch { return {kind:'human',reason:'provider_policy'}; }
|
|
37
|
+
},
|
|
38
|
+
async verifyEmail() { throw Error('Browser verification requires a human; automated entry is blocked.'); },
|
|
39
|
+
async advance() { throw Error('Browser credential entry requires a human; automated entry is blocked.'); },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
x: provider(['https://x.com'], {login:'https://x.com/i/flow/login',signup:'https://x.com/i/flow/signup'},
|
|
44
|
+
handle=>`a[data-testid="AppTabBar_Profile_Link"][href="/${handle}"]`,
|
|
45
|
+
'input[autocomplete="username"]','input[name="password"]','button:has-text("Next"), div[role="button"]:has-text("Next")','[data-testid="LoginForm_Login_Button"]'),
|
|
46
|
+
instagram: provider(['https://www.instagram.com'], {login:'https://www.instagram.com/accounts/login/',signup:'https://www.instagram.com/accounts/emailsignup/'},
|
|
47
|
+
handle=>`nav a[href="/${handle}/"] img[alt*="profile picture"], [role="navigation"] a[href="/${handle}/"] img[alt*="profile picture"]`,
|
|
48
|
+
'input[name="username"]','input[name="password"]','button[type="submit"]','button[type="submit"]'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export const browserProviders = createBrowserProviders();
|