actas 0.0.0-stage → 0.4.0
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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {readFile,mkdir,open,rename} from 'node:fs/promises';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import {join,dirname} from 'node:path';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const path=()=>process.env.ACTAS_CONNECTION_FILE||join(homedir(),'.config','actas','identity.json');
|
|
7
|
+
function validate(value){
|
|
8
|
+
if(value?.kind==='management'){
|
|
9
|
+
if(value.version!==1||typeof value.ownerId!=='string'||!value.ownerId||typeof value.managementId!=='string'||!/^actas_m_[0-9a-f-]{36}\.[A-Za-z0-9_-]{32,}$/.test(value.token)||value.token.split('.')[0]!=='actas_m_'+value.managementId)throw Error('Invalid management connection.');
|
|
10
|
+
const url=new URL(value.apiUrl);if(url.username||url.password||url.search||url.hash||url.pathname!=='/'||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname))))throw Error('Connection requires HTTPS or local development.');
|
|
11
|
+
return {version:1,kind:'management',apiUrl:url.origin,ownerId:value.ownerId,managementId:value.managementId,token:value.token};
|
|
12
|
+
}
|
|
13
|
+
if(value?.version!==1||typeof value.identityId!=='string'||!/^agt_[0-9a-f-]{36}$/.test(value.identityId)||typeof value.token!=='string'||!/^actas_[0-9a-f-]{36}\.[A-Za-z0-9_-]{32,}$/.test(value.token))throw Error('Invalid identity connection.');
|
|
14
|
+
const url=new URL(value.apiUrl);
|
|
15
|
+
if(url.username||url.password||url.search||url.hash||url.pathname!=='/'||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname))))throw Error('Identity connection requires HTTPS or local development.');
|
|
16
|
+
return {version:1,apiUrl:url.origin,identityId:value.identityId,token:value.token};
|
|
17
|
+
}
|
|
18
|
+
async function readConnection(file){
|
|
19
|
+
try{const raw=await readFile(file,'utf8');if(raw.length>16384)throw Error();return validate(JSON.parse(raw));}catch{throw Error('Could not read a valid identity connection. Run actas connect <connection.json>.');}
|
|
20
|
+
}
|
|
21
|
+
export async function readIdentityConnection(file=path()){return readConnection(file);}
|
|
22
|
+
export async function identityRequest(connection,route,method='GET',body,extraHeaders={}){
|
|
23
|
+
let response;
|
|
24
|
+
try{response=await fetch(connection.apiUrl+'/api/v1'+route,{method,redirect:'error',headers:{Origin:connection.apiUrl,Authorization:'Bearer '+connection.token,'Content-Type':'application/json',...extraHeaders},signal:AbortSignal.timeout(15000),...(body===undefined?{}:{body:JSON.stringify(body)})});}catch{throw Error('ActAs could not be reached. No automatic retry was made.');}
|
|
25
|
+
if(!response.ok)throw Error(response.status===401?'Access expired or was revoked. Reconnect through your workspace.':response.status===403?'This connection does not permit that action.':`ActAs request failed (${response.status}).`);
|
|
26
|
+
try{return (await response.json()).data;}catch{throw Error('Invalid ActAs response.');}
|
|
27
|
+
}
|
|
28
|
+
export async function connectIdentity(file){
|
|
29
|
+
const connection=await readConnection(file);
|
|
30
|
+
const identity=await identityRequest(connection,connection.kind==='management'?'/me':'/identities/'+encodeURIComponent(connection.identityId));
|
|
31
|
+
if(connection.kind==='management'?(identity.id!==connection.managementId||identity.owners?.[0]?.id!==connection.ownerId):identity.id!==connection.identityId)throw Error('Identity connection mismatch.');
|
|
32
|
+
const destination=path();await mkdir(dirname(destination),{recursive:true,mode:0o700});
|
|
33
|
+
const temp=destination+'.'+randomUUID();const handle=await open(temp,'wx',0o600);
|
|
34
|
+
try{await handle.writeFile(JSON.stringify(connection)+'\n');}finally{await handle.close();}
|
|
35
|
+
await rename(temp,destination);
|
|
36
|
+
return {status:'connected',...(connection.kind==='management'?{managementId:identity.id,ownerId:connection.ownerId}:{identityId:identity.id}),name:identity.name};
|
|
37
|
+
}
|
|
38
|
+
export const identityHelp=`ActAs — identity, email and accounts for agents
|
|
39
|
+
|
|
40
|
+
actas connect <connection.json> Import scoped identity access
|
|
41
|
+
actas identity Show your connected identity
|
|
42
|
+
actas resources List permitted identity resources
|
|
43
|
+
actas resources save Save a resource from JSON on stdin
|
|
44
|
+
actas mail <inbox-id> List inbox messages
|
|
45
|
+
actas mail <inbox-id> <message-id> Read one message
|
|
46
|
+
actas mail send <inbox-id> --key <stable-key> Send JSON on stdin
|
|
47
|
+
actas mail reply <inbox-id> <message-id> --key <stable-key> Reply with JSON on stdin
|
|
48
|
+
actas secret <resource-id> Retrieve a permitted secret
|
|
49
|
+
|
|
50
|
+
Download a connection from your workspace's identity Access tab.
|
|
51
|
+
Secret retrieval and message reads can return sensitive values; use only in the
|
|
52
|
+
intended authorized runtime. Never pass secret values as command arguments.
|
|
53
|
+
Sending needs emails:send plus the owner’s Can send email switch. Reply also needs
|
|
54
|
+
emails:read. Send: {"to":"person@example.com","subject":"Hello","text":"Hello"}.
|
|
55
|
+
Reply: {"text":"Thanks"}. Optional simple HTML; no attachments, CC or BCC.
|
|
56
|
+
Reuse the SAME key and SAME body to inspect uncertain sends; never use a fresh key
|
|
57
|
+
while a pending/unknown result needs operator reconciliation. Exit 2 means unconfirmed.
|
|
58
|
+
Legacy configure/signup/login commands remain available for existing clients.
|
|
59
|
+
`;
|
|
60
|
+
export async function runIdentityCommand(args){
|
|
61
|
+
const [command,...rest]=args;
|
|
62
|
+
if(!['connect','identity','resources','mail','secret'].includes(command))return false;
|
|
63
|
+
try{
|
|
64
|
+
let result;
|
|
65
|
+
if(command==='connect'){
|
|
66
|
+
if(rest.length!==1)throw Error('Use actas connect <connection.json>.');
|
|
67
|
+
result=await connectIdentity(rest[0]);
|
|
68
|
+
}else{
|
|
69
|
+
const connection=await readConnection(path());
|
|
70
|
+
if(connection.kind==='management')throw Error('Use management commands: actas identity list/create, email create, connection issue/revoke, or activity.');
|
|
71
|
+
if(command==='identity'&&!rest.length)result=await identityRequest(connection,'/identities/'+connection.identityId);
|
|
72
|
+
else if(command==='resources'&&!rest.length)result=await identityRequest(connection,'/identities/'+connection.identityId+'/resources');
|
|
73
|
+
else if(command==='resources'&&rest.length===1&&rest[0]==='save'){
|
|
74
|
+
let input='';for await(const chunk of process.stdin){input+=chunk;if(input.length>100000)throw Error('Resource input is too large.');}
|
|
75
|
+
let body;try{body=JSON.parse(input);}catch{throw Error('Expected a resource JSON object on stdin.');}
|
|
76
|
+
if(!body||typeof body!=='object'||Array.isArray(body))throw Error('Expected a resource JSON object on stdin.');
|
|
77
|
+
const identity=await identityRequest(connection,'/identities/'+connection.identityId);
|
|
78
|
+
result=await identityRequest(connection,'/resources','POST',{...body,ownerId:identity.ownerId,productId:identity.productId,identityId:identity.id});
|
|
79
|
+
}else if(command==='mail'&&['send','reply'].includes(rest[0])){
|
|
80
|
+
const reply=rest[0]==='reply',keyIndex=reply?3:2;
|
|
81
|
+
if(rest.length!==keyIndex+2||rest[keyIndex]!=='--key'||! /^[A-Za-z0-9_-]{16,128}$/.test(rest[keyIndex+1]))throw Error('Use actas mail send <inbox-id> --key <stable-key> or mail reply <inbox-id> <message-id> --key <stable-key>.');
|
|
82
|
+
let input='';for await(const chunk of process.stdin){input+=chunk;if(Buffer.byteLength(input)>256*1024)throw Error('Message input is too large.');}
|
|
83
|
+
let body;try{body=JSON.parse(input);}catch{throw Error('Expected message JSON on stdin.');}
|
|
84
|
+
if(!body||typeof body!=='object'||Array.isArray(body))throw Error('Expected message JSON on stdin.');
|
|
85
|
+
result=await identityRequest(connection,'/emails/'+encodeURIComponent(rest[1])+(reply?'/messages/'+encodeURIComponent(rest[2])+'/reply':'/send'),'POST',body,{'Idempotency-Key':rest[keyIndex+1]});
|
|
86
|
+
if(!['sent','delivered'].includes(result.status))process.exitCode=2;
|
|
87
|
+
}else if(command==='mail'&&rest.length>=1&&rest.length<=2){
|
|
88
|
+
result=await identityRequest(connection,'/emails/'+encodeURIComponent(rest[0])+'/messages'+(rest[1]?'/'+encodeURIComponent(rest[1]):''));
|
|
89
|
+
}else if(command==='secret'&&rest.length===1)result=await identityRequest(connection,'/resources/'+encodeURIComponent(rest[0])+'/reveal','POST',{});
|
|
90
|
+
else throw Error('Invalid command arguments. Run actas help.');
|
|
91
|
+
}
|
|
92
|
+
process.stdout.write(JSON.stringify(result,null,2)+'\n');
|
|
93
|
+
}catch(error){process.stderr.write(JSON.stringify({status:'failed',message:error.message})+'\n');process.exitCode=1;}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {readFile,open} from 'node:fs/promises';
|
|
2
|
+
import {identityRequest,readIdentityConnection} from './identity.mjs';
|
|
3
|
+
export const managementHelp=`Workspace management
|
|
4
|
+
actas connect ./mgmt.json
|
|
5
|
+
actas identity list
|
|
6
|
+
actas identity create <name> [--product ID] [--local-part NAME] [--domain DOMAIN]
|
|
7
|
+
actas identity rename <id> <name>
|
|
8
|
+
actas identity revoke <id>
|
|
9
|
+
actas domains list
|
|
10
|
+
actas domains add <domain>
|
|
11
|
+
actas domains verify <domain>
|
|
12
|
+
actas domains remove <domain>
|
|
13
|
+
actas sending <identity> on|off
|
|
14
|
+
actas email create <identity>
|
|
15
|
+
actas connection list
|
|
16
|
+
actas connection issue <identity> --scopes emails:read --inbox ID --out ./agent.json
|
|
17
|
+
actas connection revoke <id>
|
|
18
|
+
actas activity
|
|
19
|
+
actas management list --owner ID
|
|
20
|
+
actas management issue --owner ID --name NAME --scopes LIST --confirm WORKSPACE --out ./mgmt.json
|
|
21
|
+
actas management rotate ID --owner ID --name NAME --scopes LIST --confirm WORKSPACE --out ./mgmt.json
|
|
22
|
+
actas management revoke ID
|
|
23
|
+
Management issuance uses ACTAS_HUMAN_CONNECTION_FILE (a private human bearer file).
|
|
24
|
+
An explicit --confirm workspace name reconfirms delegation. --days defaults to 90,
|
|
25
|
+
maximum 365. Secret connection output requires --out (exclusive mode 0600 file)
|
|
26
|
+
or explicit --stdout. Never place a token in command arguments.
|
|
27
|
+
`;
|
|
28
|
+
function flags(args,allowed){const values={};for(let i=0;i<args.length;i++){const k=args[i];if(!allowed.includes(k)||Object.hasOwn(values,k))throw Error('Unknown or repeated management option.');values[k]=k==='--stdout'?true:args[++i];if(values[k]===undefined)throw Error('Missing management option.');}return values;}
|
|
29
|
+
async function secretOutput(options,action){
|
|
30
|
+
if(Boolean(options['--out'])===Boolean(options['--stdout']))throw Error('Choose --out FILE or explicit --stdout for connection output.');
|
|
31
|
+
let handle;if(options['--out'])handle=await open(options['--out'],'wx',0o600);
|
|
32
|
+
try{const result=await action();if(handle){await handle.writeFile(JSON.stringify(result,null,2)+'\n');return {status:'issued',file:options['--out']};}process.stdout.write(JSON.stringify(result,null,2)+'\n');return null;}finally{await handle?.close();}
|
|
33
|
+
}
|
|
34
|
+
async function human(){
|
|
35
|
+
try{const c=JSON.parse(await readFile(process.env.ACTAS_HUMAN_CONNECTION_FILE,'utf8'));const u=new URL(c.apiUrl);if(u.username||u.password||u.search||u.hash||u.pathname!=='/'||(u.protocol!=='https:'&&!(u.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(u.hostname)))||typeof c.token!=='string'||!c.token||c.token.startsWith('actas_'))throw Error();return {...c,apiUrl:u.origin};}catch{throw Error('Use a private ACTAS_HUMAN_CONNECTION_FILE with a current human bearer.');}
|
|
36
|
+
}
|
|
37
|
+
export async function runManagementCommand(args){
|
|
38
|
+
const [command,action,...rest]=args;
|
|
39
|
+
if(!(['sending','domains','email','connection','management','activity'].includes(command)||command==='identity'&&['list','create','rename','revoke'].includes(action)))return false;
|
|
40
|
+
try{
|
|
41
|
+
let result;
|
|
42
|
+
if(command==='management'){
|
|
43
|
+
const c=await human();
|
|
44
|
+
if(action==='list'){const f=flags(rest,['--owner']);if(!f['--owner'])throw Error('Select --owner.');result=await identityRequest(c,'/management?ownerId='+encodeURIComponent(f['--owner']));}
|
|
45
|
+
else if(action==='revoke'&&rest.length===1)result=await identityRequest(c,'/management/'+encodeURIComponent(rest[0]),'DELETE');
|
|
46
|
+
else if(['issue','rotate'].includes(action)){
|
|
47
|
+
const id=action==='rotate'?rest.shift():null;const f=flags(rest,['--owner','--name','--scopes','--confirm','--days','--out','--stdout']);
|
|
48
|
+
const days=Number(f['--days']??90);if(!Number.isInteger(days)||days<1||days>365||!f['--owner']||!f['--name']||!f['--scopes']||!f['--confirm']||action==='rotate'&&!id)throw Error('Supply owner, name, scopes, confirmation and 1–365 days.');
|
|
49
|
+
result=await secretOutput(f,async()=> (await identityRequest(c,'/management'+(id?'/'+encodeURIComponent(id)+'/rotate':''),'POST',{ownerId:f['--owner'],name:f['--name'],scopes:f['--scopes'].split(','),confirmWorkspace:f['--confirm'],expiresAt:Date.now()+days*86400000})).connection);
|
|
50
|
+
}else throw Error('Invalid management command.');
|
|
51
|
+
}else{
|
|
52
|
+
const c=await readIdentityConnection();if(c.kind!=='management')throw Error('Connect a management connection first.');
|
|
53
|
+
if(command==='sending'&&action&&rest.length===1&&['on','off'].includes(rest[0]))result=await identityRequest(c,'/identities/'+encodeURIComponent(action)+'/sending','PATCH',{canSend:rest[0]==='on'});
|
|
54
|
+
else if(command==='domains'&&action==='list'&&!rest.length)result=await identityRequest(c,'/domains?ownerId='+encodeURIComponent(c.ownerId));
|
|
55
|
+
else if(command==='domains'&&action==='add'&&rest.length===1)result=await identityRequest(c,'/domains','POST',{ownerId:c.ownerId,domain:rest[0]});
|
|
56
|
+
else if(command==='domains'&&action==='verify'&&rest.length===1)result=await identityRequest(c,'/domains/'+encodeURIComponent(rest[0])+'/verify','POST',{});
|
|
57
|
+
else if(command==='domains'&&action==='remove'&&rest.length===1)result=await identityRequest(c,'/domains/'+encodeURIComponent(rest[0]),'DELETE');
|
|
58
|
+
else if(command==='identity'&&action==='list'&&!rest.length)result=await identityRequest(c,'/identities');
|
|
59
|
+
else if(command==='identity'&&action==='create'){const name=rest.shift(),f=flags(rest,['--product','--local-part','--domain']);if(!name)throw Error('Supply an agent name.');result=await identityRequest(c,'/identities','POST',{ownerId:c.ownerId,name,productId:f['--product']||null,...(f['--local-part']?{localPart:f['--local-part']}:{}),...(f['--domain']?{domain:f['--domain']}:{})});}
|
|
60
|
+
else if(command==='identity'&&action==='rename'&&rest.length===2)result=await identityRequest(c,'/identities/'+encodeURIComponent(rest[0]),'PATCH',{name:rest[1]});
|
|
61
|
+
else if(command==='identity'&&action==='revoke'&&rest.length===1)result=await identityRequest(c,'/identities/'+encodeURIComponent(rest[0])+'/revoke','POST',{});
|
|
62
|
+
else if(command==='email'&&action==='create'&&rest.length===1)result=await identityRequest(c,'/identities/'+encodeURIComponent(rest[0])+'/email','POST',{});
|
|
63
|
+
else if(command==='connection'&&action==='list'&&!rest.length)result=await identityRequest(c,'/clients');
|
|
64
|
+
else if(command==='connection'&&action==='revoke'&&rest.length===1)result=await identityRequest(c,'/clients/'+encodeURIComponent(rest[0]),'DELETE');
|
|
65
|
+
else if(command==='connection'&&action==='issue'){
|
|
66
|
+
const id=rest.shift(),f=flags(rest,['--scopes','--inbox','--resources','--name','--out','--stdout']);if(!id||!f['--scopes']||!f['--inbox']&&!f['--resources'])throw Error('Supply identity, scopes and --inbox or --resources.');
|
|
67
|
+
result=await secretOutput(f,async()=>{const identity=await identityRequest(c,'/identities/'+encodeURIComponent(id));const issued=await identityRequest(c,'/clients','POST',{ownerId:c.ownerId,productId:identity.productId,identityId:id,name:f['--name']||identity.name+' runtime',scopes:[...new Set(['resources:read',...f['--scopes'].split(',')])],resourceIds:f['--inbox']?[f['--inbox']] : f['--resources'].split(','),identityFields:[]});return {version:1,apiUrl:c.apiUrl,identityId:id,token:issued.token};});
|
|
68
|
+
}else if(command==='activity'&&action===undefined)result=await identityRequest(c,'/activity');
|
|
69
|
+
else throw Error('Invalid management command. Run actas help.');
|
|
70
|
+
}
|
|
71
|
+
if(result!==null)process.stdout.write(JSON.stringify(result,null,2)+'\n');
|
|
72
|
+
}catch{process.stderr.write(JSON.stringify({status:'failed',message:'Management command failed. Check arguments, access and private output file; reconcile uncertain issuance in Access before retrying.'})+'\n');process.exitCode=1;}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Load the MCP dependencies only when a stdio MCP client requests them.
|
|
3
|
+
if(process.argv[2]==='mcp'){
|
|
4
|
+
const {main}=await import('./accounts-mcp.mjs');
|
|
5
|
+
await main().catch(()=>{process.stderr.write('ActAs MCP could not start. Check the private connection file and permissions.\n');process.exitCode=1;});
|
|
6
|
+
}else{
|
|
7
|
+
await import('./public-cli.mjs');
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {runRegistryCommand,registryHelp} from './accounts-registry.mjs';
|
|
3
|
+
if(await runRegistryCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
4
|
+
import {runManagementCommand,managementHelp} from './management.mjs';
|
|
5
|
+
if(await runManagementCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
6
|
+
import {runVaultCommand,vaultHelp} from './vault.mjs';
|
|
7
|
+
if(await runVaultCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
8
|
+
import {runIdentityCommand,identityHelp} from './identity.mjs';
|
|
9
|
+
if(await runIdentityCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
10
|
+
import {configureAccount} from './configure.mjs';
|
|
11
|
+
import {existsSync} from 'node:fs';
|
|
12
|
+
import {homedir} from 'node:os';
|
|
13
|
+
import {join} from 'node:path';
|
|
14
|
+
import {accountFlow,targetUrl} from './accounts.mjs';
|
|
15
|
+
import {callerExecutor,loadMappings,selectMapping} from './caller.mjs';
|
|
16
|
+
import {externalExecutor} from './external-caller.mjs';
|
|
17
|
+
const [action,url,...args]=process.argv.slice(2);
|
|
18
|
+
if(action==='configure'){try{if(!url||args.length)throw new Error('Use actas configure <connection.json>.');console.log(JSON.stringify(await configureAccount(url)));}catch(e){console.error(JSON.stringify({status:'failed',message:e.message}));process.exitCode=1;}process.exit(process.exitCode||0);}
|
|
19
|
+
if(!action||['help','--help','-h'].includes(action)){
|
|
20
|
+
console.log(registryHelp+"\n"+managementHelp+"\n"+identityHelp+"\n"+vaultHelp);
|
|
21
|
+
}else try{
|
|
22
|
+
if(!['signup','login'].includes(action)||!url||args.length%2)throw new Error('Use actas signup <url> or actas login <url>.');
|
|
23
|
+
const flags={};for(let i=0;i<args.length;i+=2){if(!['--account','--continue'].includes(args[i])||flags[args[i]]||!args[i+1])throw new Error('Unknown or repeated option.');flags[args[i]]=args[i+1];}
|
|
24
|
+
const fallback=join(homedir(),'.config','actas','accounts.json');
|
|
25
|
+
const accountsPath=process.env.ACTAS_ACCOUNTS_FILE||(existsSync(fallback)?fallback:undefined);
|
|
26
|
+
const selected=selectMapping(await loadMappings(accountsPath),targetUrl(url).origin,flags['--account']);
|
|
27
|
+
if(selected&&!selected.actions.includes(action))throw new Error('Account action is not authorised.');
|
|
28
|
+
const options={accountsPath,reportPath:flags['--continue']};
|
|
29
|
+
const external=await externalExecutor(options),caller=await callerExecutor(options);
|
|
30
|
+
const {exitCode,...result}=await accountFlow(action,url,{accountRef:selected?.accountRef,execute:async state=>(await external(state))||caller(state)});
|
|
31
|
+
console.log(JSON.stringify(result,null,2));process.exitCode=exitCode;
|
|
32
|
+
}catch(error){console.error(JSON.stringify({status:'failed',message:error.message}));process.exitCode=1;}
|
package/client/vault.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {readFile,open,rename,unlink,lstat,chmod,readdir} from 'node:fs/promises';
|
|
2
|
+
import {join,dirname,resolve} from 'node:path';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
import {StringDecoder} from 'node:string_decoder';
|
|
7
|
+
import {parseEnv} from 'node:util';
|
|
8
|
+
import {identityRequest,readIdentityConnection} from './identity.mjs';
|
|
9
|
+
const fail=()=>{throw Error('Invalid vault command. Run actas help. Values belong only at private prompts.');};
|
|
10
|
+
export const vaultHelp=`Workspace vault (exact and folder grants; no values in command arguments)
|
|
11
|
+
actas totp add /path [--replace] Read seed or otpauth URI from stdin
|
|
12
|
+
actas totp code /path
|
|
13
|
+
actas totp status /path
|
|
14
|
+
actas secret set /path/KEY [--replace]
|
|
15
|
+
actas secret list [--path /folder]
|
|
16
|
+
actas secret versions /path/KEY
|
|
17
|
+
actas secret restore /path/KEY VERSION
|
|
18
|
+
actas run --keys A,B [--path /folder] -- cmd [args]
|
|
19
|
+
actas env sync <dir> --path /folder --keys A,B [--force] [--dry-run]
|
|
20
|
+
actas env inventory <dir> List local .env.local key names only
|
|
21
|
+
actas script add /path/script
|
|
22
|
+
actas script check /path/script
|
|
23
|
+
actas grant list
|
|
24
|
+
actas grant add <client|identity>:ID /path <read|write|reveal|run|totp:code> [--folder]
|
|
25
|
+
actas grant revoke ID
|
|
26
|
+
Connected agents and management automation use ACTAS_CONNECTION_FILE. Paths default to ACTAS_VAULT_PATH
|
|
27
|
+
or /shared for run and env sync. For owner grant management use a
|
|
28
|
+
private ACTAS_HUMAN_CONNECTION_FILE containing apiUrl and a current human bearer
|
|
29
|
+
token, plus ACTAS_WORKSPACE_ID and optional ACTAS_PRODUCT_ID. Human tokens expire;
|
|
30
|
+
reconnect through the normal workspace session. Membership suffices; no admin powers.
|
|
31
|
+
Children are trusted consumers, not a sandbox. They may intentionally print values.
|
|
32
|
+
`;
|
|
33
|
+
export function validPath(path){if(typeof path!=='string'||path.length>512||!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(path))fail();return path;}
|
|
34
|
+
export function keyNames(raw){const keys=raw?.split(',');if(!keys?.length||keys.some(x=>! /^[A-Za-z_][A-Za-z0-9_]*$/.test(x))||new Set(keys).size!==keys.length)fail();return keys;}
|
|
35
|
+
export function childEnvironment(parent=process.env){return Object.fromEntries(['PATH','HOME','TMPDIR','LANG','LC_ALL','SYSTEMROOT'].filter(k=>parent[k]!==undefined).map(k=>[k,parent[k]]));}
|
|
36
|
+
export function privatePrompt(labels,input=process.stdin,output=process.stdout){
|
|
37
|
+
if(!input.isTTY||!output.isTTY||typeof input.setRawMode!=='function')throw Error('Use a private Terminal with TTY input and output. Nothing saved.');
|
|
38
|
+
return new Promise((resolve,reject)=>{
|
|
39
|
+
let fields=[],current='',done=false,swallowLF=false;const decoder=new StringDecoder('utf8'),wasRaw=input.isRaw;
|
|
40
|
+
function finish(error){if(done)return;done=true;input.removeListener('data',data);input.removeListener('end',cancel);input.removeListener('error',cancel);for(const s of ['SIGINT','SIGTERM','SIGHUP'])process.removeListener(s,cancel);input.setRawMode(Boolean(wasRaw));input.pause();output.write('\n');const result=fields;fields=[];current='';error?reject(error):resolve(result);}
|
|
41
|
+
function cancel(){finish(Error('Private entry cancelled. Nothing saved.'));}
|
|
42
|
+
function data(chunk){for(const c of typeof chunk==='string'?chunk:decoder.write(chunk)){
|
|
43
|
+
if(done)return;if(c==='\x03'||c==='\x04')return cancel();if(c==='\x7f'||c==='\b'){current=[...current].slice(0,-1).join('');continue;}if(c==='\x15'){current='';continue;}
|
|
44
|
+
if(c==='\n'&&swallowLF){swallowLF=false;continue;}swallowLF=c==='\r';
|
|
45
|
+
if(c==='\n'||c==='\r'){fields.push(current);current='';output.write('\n');if(fields.length===labels.length)return finish();output.write(labels[fields.length]+': ');}
|
|
46
|
+
else if(c.charCodeAt(0)<32||c.charCodeAt(0)===127)return finish(Error('Unsupported control input. Nothing saved.'));
|
|
47
|
+
else{current+=c;if(current.length>65536)return finish(Error('Entry too long. Nothing saved.'));}
|
|
48
|
+
}}
|
|
49
|
+
input.setRawMode(true);input.on('data',data);input.once('end',cancel);input.once('error',cancel);for(const s of ['SIGINT','SIGTERM','SIGHUP'])process.once(s,cancel);output.write(labels[0]+': ');input.resume();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function context(){
|
|
53
|
+
if(process.env.ACTAS_HUMAN_CONNECTION_FILE){
|
|
54
|
+
let connection;try{connection=JSON.parse(await readFile(process.env.ACTAS_HUMAN_CONNECTION_FILE,'utf8'));const url=new URL(connection.apiUrl);if(url.username||url.password||url.search||url.hash||url.pathname!=='/'||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname)))||typeof connection.token!=='string'||!connection.token||connection.token.startsWith('actas_'))throw Error();connection.apiUrl=url.origin;}catch{throw Error('Could not read a valid human connection.');}
|
|
55
|
+
if(!process.env.ACTAS_WORKSPACE_ID)throw Error('Select ACTAS_WORKSPACE_ID for human vault commands.');
|
|
56
|
+
return {connection,scope:{ownerId:process.env.ACTAS_WORKSPACE_ID,productId:process.env.ACTAS_PRODUCT_ID||null}};
|
|
57
|
+
}
|
|
58
|
+
const connection=await readIdentityConnection();if(connection.kind==='management')return {connection,scope:{ownerId:connection.ownerId,productId:process.env.ACTAS_PRODUCT_ID||null}};const identity=await identityRequest(connection,'/identities/'+connection.identityId);return {connection,scope:{ownerId:identity.ownerId,productId:identity.productId}};
|
|
59
|
+
}
|
|
60
|
+
export function requestVault(ctx,route,body,method='POST'){return identityRequest(ctx.connection,'/vault'+route,method,body===undefined?undefined:{...body,...ctx.scope});}
|
|
61
|
+
function options(args,allowed){const out={};for(let i=0;i<args.length;i++){const name=args[i];if(!allowed.includes(name)||Object.hasOwn(out,name))fail();out[name]=['--replace','--force','--dry-run'].includes(name)?true:args[++i];if(out[name]===undefined)fail();}return out;}
|
|
62
|
+
async function selected(ctx,path,keys){validPath(path);const values={};for(const key of keys){const item=await requestVault(ctx,'/read',{path:path+'/'+key,action:'reveal'});if(typeof item?.value!=='string'||item.value.includes('\0'))throw Error('Invalid vault response. Command was not started.');values[key]=item.value;}return values;}
|
|
63
|
+
export function runChild(command,env,card,spawnChild=spawn){
|
|
64
|
+
if(!command.length)fail();return new Promise((resolve,reject)=>{
|
|
65
|
+
let child;try{child=spawnChild(command[0],command.slice(1),{env,shell:false,stdio:card?['pipe','inherit','inherit']:'inherit'});}catch{reject(Error('Unable to start requested command.'));return;}
|
|
66
|
+
const forward=signal=>child.kill(signal);const sigint=()=>forward('SIGINT'),sigterm=()=>forward('SIGTERM');process.on('SIGINT',sigint);process.on('SIGTERM',sigterm);
|
|
67
|
+
function cleanup(){process.removeListener('SIGINT',sigint);process.removeListener('SIGTERM',sigterm);}
|
|
68
|
+
child.once('error',()=>{cleanup();reject(Error('Unable to start requested command.'));});child.once('exit',(code,signal)=>{cleanup();resolve(code??(signal?1:0));});
|
|
69
|
+
if(card){child.stdin.on('error',()=>{});child.stdin.end(JSON.stringify(card)+'\n');}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// Node dotenv has no escaped-newline syntax. Quote literal multiline values and
|
|
73
|
+
// reject the rare value containing all three quote delimiters rather than corrupt it.
|
|
74
|
+
export function encodeEnv(value){for(const quote of ['"',"'",'`'])if(!value.includes(quote))return quote+value+quote;throw Error('Value cannot be represented losslessly in a dotenv file. Use actas run.');}
|
|
75
|
+
export async function syncEnv(directory,values,{force=false,dryRun=false}={}){
|
|
76
|
+
const file=join(resolve(directory),'.env.local');let raw='';try{const info=await lstat(file);if(!info.isFile()||info.isSymbolicLink())throw Error();raw=await readFile(file,'utf8');}catch(e){if(e.code!=='ENOENT')throw Error('Unable to read a regular target env file.');}
|
|
77
|
+
let existing;try{existing=parseEnv(raw);}catch{throw Error('Target env file could not be parsed.');}
|
|
78
|
+
const updates=new Map(),actions=[];for(const [key,value] of Object.entries(values)){keyNames(key);if(existing[key]&&!force){actions.push({key,action:'kept-existing'});continue;}updates.set(key,encodeEnv(value));actions.push({key,action:Object.hasOwn(existing,key)?'updated':'added'});}
|
|
79
|
+
if(!dryRun&&updates.size){
|
|
80
|
+
// Remove complete multiline assignments, preserve unrelated lines/comments.
|
|
81
|
+
const lines=raw.split(/\r?\n/),out=[];const consumed=new Set();
|
|
82
|
+
for(let i=0;i<lines.length;i++){const match=/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(lines[i]);if(!match||!updates.has(match[1])){out.push(lines[i]);continue;}
|
|
83
|
+
const v=match[2],q=v[0];if(['"',"'",'`'].includes(q)&&!v.slice(1).includes(q)){while(++i<lines.length&&!lines[i].includes(q)){} }
|
|
84
|
+
if(!consumed.has(match[1]))out.push(match[1]+'='+updates.get(match[1]));consumed.add(match[1]);
|
|
85
|
+
}
|
|
86
|
+
for(const [k,v] of updates)if(!consumed.has(k))out.push(k+'='+v);
|
|
87
|
+
const next=out.join('\n').replace(/\n*$/,'')+'\n';const parsed=parseEnv(next);for(const [key] of updates)if(parsed[key]!==values[key])throw Error('Env serialization could not preserve the value. Nothing written.');
|
|
88
|
+
const temp=join(dirname(file),'.actas-env-'+randomUUID());let handle;try{handle=await open(temp,'wx',0o600);await handle.writeFile(next);await handle.close();handle=null;await rename(temp,file);await chmod(file,0o600);}finally{await handle?.close();await unlink(temp).catch(()=>{});}
|
|
89
|
+
}
|
|
90
|
+
return {target:file,dryRun,actions};
|
|
91
|
+
}
|
|
92
|
+
export async function inventoryEnv(directory){
|
|
93
|
+
const raw=await readFile(join(resolve(directory),'.env.local'),'utf8');return {keys:[...new Set([...raw.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map(m=>m[1]))].sort()};
|
|
94
|
+
}
|
|
95
|
+
export async function runVaultCommand(args){
|
|
96
|
+
const [cmd,sub,...rest]=args;if(!['run','env','card','script','grant','totp'].includes(cmd)&&!(cmd==='secret'&&['set','list','versions','restore'].includes(sub)))return false;
|
|
97
|
+
try{
|
|
98
|
+
let result;
|
|
99
|
+
if(cmd==='env'&&sub==='inventory'){if(rest.length!==1)fail();result=await inventoryEnv(rest[0]);}
|
|
100
|
+
else{
|
|
101
|
+
const ctx=await context();
|
|
102
|
+
if(cmd==='run'||cmd==='card'&&sub==='run'){
|
|
103
|
+
const split=args.indexOf('--');if(split<0||split===args.length-1)fail();let values,card;
|
|
104
|
+
if(cmd==='run'){const flags=options(args.slice(1,split),['--path','--keys']);values=await selected(ctx,flags['--path']||process.env.ACTAS_VAULT_PATH||'/shared',keyNames(flags['--keys']));}
|
|
105
|
+
else{if(split!==3)fail();card=(await requestVault(ctx,'/read',{path:validPath(args[2]),action:'run'})).card;if(!card)throw Error('Invalid card response. Command was not started.');}
|
|
106
|
+
process.exitCode=await runChild(args.slice(split+1),{...childEnvironment(),...values},card);return true;
|
|
107
|
+
}
|
|
108
|
+
else if(cmd==='env'&&sub==='sync'){const [dir,...extra]=rest;if(!dir)fail();const flags=options(extra,['--path','--keys','--force','--dry-run']);const values=await selected(ctx,flags['--path']||process.env.ACTAS_VAULT_PATH||'/shared',keyNames(flags['--keys']));result=await syncEnv(dir,values,{force:flags['--force'],dryRun:flags['--dry-run']});}
|
|
109
|
+
else if(cmd==='secret'&&sub==='list'){const flags=options(rest,['--path']);const query=new URLSearchParams({ownerId:ctx.scope.ownerId,...(ctx.scope.productId?{productId:ctx.scope.productId}:{}),...(flags['--path']?{path:validPath(flags['--path'])}:{})});result=(await requestVault(ctx,'/entries?'+query,undefined,'GET')).filter(x=>x.kind==='secret'&&(!flags['--path']||x.path.startsWith(validPath(flags['--path'])+'/')));}
|
|
110
|
+
else if(cmd==='totp'&&sub==='add'){const path=validPath(rest[0]),flags=options(rest.slice(1),['--replace']);let value='';for await(const chunk of process.stdin){value+=chunk;if(value.length>8192)fail();}result=await requestVault(ctx,'/set',{path,kind:'totp',value:value.trim(),replace:flags['--replace']===true});}
|
|
111
|
+
else if(cmd==='totp'&&['code','status'].includes(sub)){if(rest.length!==1)fail();result=await requestVault(ctx,'/read',{path:validPath(rest[0]),action:sub==='code'?'totp:code':'read'});}
|
|
112
|
+
else if(cmd==='secret'&&sub==='set'||cmd==='card'&&sub==='add'){
|
|
113
|
+
const path=validPath(rest[0]),flags=options(rest.slice(1),['--replace']);let value;
|
|
114
|
+
if(cmd==='secret'){const entered=await privatePrompt(['Secret value','Repeat value']);if(!entered[0]||entered[0]!==entered[1])throw Error('Values are empty or do not match. Nothing saved.');value=entered[0];}
|
|
115
|
+
else{const [cardholder,number,expiryMonth,expiryYear,cvc,label]=await privatePrompt(['Cardholder','Number','Expiry month (MM)','Expiry year (YYYY)','CVC (internal workspace only; blank to omit)','Label']);value={cardholder,number,expiryMonth,expiryYear,...(cvc?{cvc}:{}),label};}
|
|
116
|
+
result=await requestVault(ctx,'/set',{path,kind:cmd==='secret'?'secret':'card',value,replace:flags['--replace']===true});
|
|
117
|
+
}
|
|
118
|
+
else if(cmd==='secret'&&sub==='versions'){if(rest.length!==1)fail();result=await requestVault(ctx,'/versions',{path:validPath(rest[0])});}
|
|
119
|
+
else if(cmd==='secret'&&sub==='restore'){if(rest.length!==2||!/^\d+$/.test(rest[1]))fail();result=await requestVault(ctx,'/restore',{path:validPath(rest[0]),version:Number(rest[1])});}
|
|
120
|
+
else if(cmd==='card'&&sub==='status'||cmd==='script'&&sub==='check'){if(rest.length!==1)fail();result=await requestVault(ctx,'/read',{path:validPath(rest[0]),action:cmd==='script'?'run':'read'});}
|
|
121
|
+
else if(cmd==='script'&&sub==='add'){if(rest.length!==1)fail();result=await requestVault(ctx,'/set',{path:validPath(rest[0]),kind:'script'});}
|
|
122
|
+
else if(cmd==='grant'&&sub==='list'){if(rest.length)fail();const query=new URLSearchParams({ownerId:ctx.scope.ownerId,...(ctx.scope.productId?{productId:ctx.scope.productId}:{})});result=await requestVault(ctx,'/grants?'+query,undefined,'GET');}
|
|
123
|
+
else if(cmd==='grant'&&sub==='add'){if(rest.length!==3&&!(rest.length===4&&rest[3]==='--folder'))fail();const m=/^(client|identity):(.+)$/.exec(rest[0]);if(!m||!['read','write','reveal','run','totp:code'].includes(rest[2]))fail();result=await requestVault(ctx,'/grants',{principalKind:m[1],principalId:m[2],path:validPath(rest[1]),action:rest[2],folder:rest[3]==='--folder'});}
|
|
124
|
+
else if(cmd==='grant'&&sub==='revoke'){if(rest.length!==1)fail();result=await requestVault(ctx,'/grants/'+encodeURIComponent(rest[0]),undefined,'DELETE');}
|
|
125
|
+
else fail();
|
|
126
|
+
}
|
|
127
|
+
process.stdout.write(JSON.stringify(result,null,2)+'\n');
|
|
128
|
+
}catch{process.stderr.write('ActAs vault operation failed. No sensitive diagnostics displayed; verify metadata before retrying a write.\n');process.exitCode=1;}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actas",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"mcpName": "dev.actas/accounts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Persistent identity, email, saved accounts and scoped credentials for AI agents",
|
|
7
|
+
"homepage": "https://actas.dev/?utm_source=npm&utm_medium=directory&utm_campaign=launch",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"mcp",
|
|
10
|
+
"mcp-server",
|
|
11
|
+
"actas",
|
|
12
|
+
"ai-agent",
|
|
13
|
+
"email",
|
|
14
|
+
"identity",
|
|
15
|
+
"credentials"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"actas": "client/package-cli.mjs",
|
|
19
|
+
"actas-accounts-mcp": "client/accounts-mcp.mjs"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
27
|
+
"zod": "4.5.4",
|
|
28
|
+
"playwright": "1.63.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Compatibility client for the retained controlled-service proof only. */
|
|
2
|
+
export class ActAsError extends Error {
|
|
3
|
+
constructor(status,code,message){super(message);this.name='ActAsError';this.status=status;this.code=code;}
|
|
4
|
+
}
|
|
5
|
+
export class ActAs {
|
|
6
|
+
constructor({url,token}) {
|
|
7
|
+
this.url=(url||'https://actas.dev').replace(/\/$/,'');
|
|
8
|
+
const parsed=new URL(this.url);
|
|
9
|
+
if(parsed.protocol!=='https:' && !(parsed.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(parsed.hostname))) throw new Error('ActAs requires HTTPS, except on loopback for local development.');
|
|
10
|
+
if(!token)throw new Error('An explicitly scoped ActAs API token is required.');
|
|
11
|
+
this.token=token;
|
|
12
|
+
}
|
|
13
|
+
async request(path,{method='GET',body,idempotencyKey}={}) {
|
|
14
|
+
const response=await fetch(this.url+'/api/v1'+path,{method,redirect:'error',headers:{Authorization:`Bearer ${this.token}`,...(body!==undefined?{'Content-Type':'application/json'}:{}),...(idempotencyKey?{'Idempotency-Key':idempotencyKey}:{})},...(body!==undefined?{body:JSON.stringify(body)}:{})});
|
|
15
|
+
const payload=await response.json().catch(()=>null);
|
|
16
|
+
if(!response.ok){const error=new ActAsError(response.status,payload?.error?.code||'request_failed',payload?.error?.message||'ActAs could not complete the request.');error.operation=method+' '+path;throw error;}
|
|
17
|
+
return payload?.data;
|
|
18
|
+
}
|
|
19
|
+
me(){return this.request('/me');}
|
|
20
|
+
list(type,filters={}){return this.request('/resources?'+new URLSearchParams({type,...Object.fromEntries(Object.entries(filters).filter(([,v])=>v!==null&&v!==undefined))}));}
|
|
21
|
+
get(id){return this.request('/resources/'+encodeURIComponent(id));}
|
|
22
|
+
create(resource){return this.request('/resources',{method:'POST',body:resource});}
|
|
23
|
+
update(id,patch){return this.request('/resources/'+encodeURIComponent(id),{method:'PATCH',body:patch});}
|
|
24
|
+
reveal(id){return this.request('/resources/'+encodeURIComponent(id)+'/reveal',{method:'POST'});}
|
|
25
|
+
provisionEmail(resource,idempotencyKey){if(!idempotencyKey)throw new Error('An idempotency key is required.');return this.request('/emails',{method:'POST',body:resource,idempotencyKey});}
|
|
26
|
+
messages(id){return this.request('/emails/'+encodeURIComponent(id)+'/messages');}
|
|
27
|
+
message(id,messageId){return this.request('/emails/'+encodeURIComponent(id)+'/messages/'+encodeURIComponent(messageId));}
|
|
28
|
+
activity(){return this.request('/activity');}
|
|
29
|
+
}
|