@volter/teams 0.1.90 → 0.2.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/README.md CHANGED
@@ -19,3 +19,8 @@ reached by a team, with no harness in it.
19
19
  `api.harnessRoutes`.
20
20
 
21
21
  This package is private: nothing is published while nothing is open-sourced.
22
+
23
+ Public Node clients are `@volter/teams/http-client` (`TeamsClient`),
24
+ `@volter/teams/event-client` (selected-context event client), and
25
+ `@volter/teams/machine-door` (an admitted local/remote machine channel).
26
+ Context credentials remain inside the custody adapter; consumers use its public client.
@@ -0,0 +1,15 @@
1
+ const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
2
+ /** Compare declared intent with native observations. An unreported field is unknown, never a match or drift. */
3
+ export function compareAgentProfile(intended,recorded){
4
+ const differences=[],unknown=[];
5
+ if(!intended)return {observed:recorded!=null,differences,unknown:['intent']};
6
+ const visit=(want,got,path)=>{
7
+ if(want===undefined||want===null)return;
8
+ if(got===undefined||got===null){unknown.push(path);return;}
9
+ if(object(want)&&object(got))for(const key of Object.keys(want))visit(want[key],got[key],path?`${path}.${key}`:key);
10
+ else if(JSON.stringify(want)!==JSON.stringify(got))differences.push({field:path,intended:want,actual:got});
11
+ };
12
+ visit(intended?.worker,recorded?.worker,'worker');
13
+ visit(intended?.permissions,recorded?.permissions,'permissions');
14
+ return {observed:recorded!=null,differences,unknown};
15
+ }
package/client.mjs CHANGED
@@ -54,6 +54,35 @@ export async function openRemoteMachine({ origin, teamId, machineId, token }, {
54
54
 
55
55
  /** Request/reply over a stream that carries JSON lines with `id`. */
56
56
  export { StreamRpc } from './stream-rpc.mjs';
57
+ import { lineSplitter } from './lines.mjs';
58
+
59
+ /**
60
+ * A `HarnessTransport` (harness SDK `ClientOptions.transport`) over the
61
+ * channel's `harness` stream. Only an operator's channel is admitted to it.
62
+ */
63
+ export function harnessTransport(channel) {
64
+ let stream = null;
65
+ return {
66
+ get running() { return stream !== null && !stream.closed; },
67
+ async start(handlers) {
68
+ stream = await channel.open('harness');
69
+ const lines = lineSplitter();
70
+ stream.on('data', (chunk) => {
71
+ for (const line of lines(chunk)) if (line) handlers.onLine(line);
72
+ });
73
+ stream.on('close', (reason) => handlers.onExit(null, reason ?? null));
74
+ },
75
+ write(line) {
76
+ if (!stream || stream.closed) throw new Error('harness stream is not open');
77
+ stream.write(line.endsWith('\n') ? line : line + '\n');
78
+ },
79
+ async close() {
80
+ stream?.close();
81
+ stream = null;
82
+ },
83
+ };
84
+ }
85
+
57
86
 
58
87
  /**
59
88
  * A harness's own execution server on the channel's machine, as the local address its client takes
package/context.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { join, dirname } from 'node:path';
4
+ import { TeamsClient } from './http-client.mjs';
5
+ const NAME=/^[a-z][a-z0-9_-]{0,63}$/;
6
+ const ID=/^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
7
+ function normalizeOrigin(value){const url=new URL(value);if(url.username||url.password||url.pathname!=='/'||url.search||url.hash)throw new Error('server must be a bare HTTP(S) origin');if(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','::1','[::1]','localhost'].includes(url.hostname)))throw new Error('non-loopback servers require HTTPS');return url.origin;}
8
+ export function workspacePaths(supercodeHome){const root=join(supercodeHome,'teams','workspaces');return {root,contexts:join(root,'contexts.json'),credentials:join(root,'credentials'),connectors:join(root,'connectors')};}
9
+ function locked(path,fn){mkdirSync(dirname(path),{recursive:true,mode:0o700});chmodSync(dirname(path),0o700);const lock=path+'.lock';let fd;try{fd=openSync(lock,'wx',0o600);}catch(e){throw Object.assign(new Error(`${path} is locked by another writer`),{code:'EBUSY',cause:e});}try{return fn();}finally{closeSync(fd);unlinkSync(lock);}}
10
+ function read(path,fallback){return existsSync(path)?JSON.parse(readFileSync(path,'utf8')):fallback;}
11
+ function atomic(path,value){const tmp=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,fd=openSync(tmp,'wx',0o600);try{writeFileSync(fd,JSON.stringify(value,null,2)+'\n');}finally{closeSync(fd);}try{renameSync(tmp,path);}catch(error){try{unlinkSync(tmp);}catch{}throw error;}chmodSync(path,0o600);}
12
+ export class WorkspaceContexts { constructor(supercodeHome){this.paths=workspacePaths(supercodeHome);} read(){return read(this.paths.contexts,{version:1,current:null,contexts:{}});} create(name,value){if(!NAME.test(name)||name==='local')throw new Error('context name must match [a-z][a-z0-9_-]{0,63} and cannot be local');return locked(this.paths.contexts,()=>{const doc=this.read();if(doc.contexts[name])throw new Error(`context ${name} already exists`);doc.contexts[name]=value;atomic(this.paths.contexts,doc);return value;});} use(name){return locked(this.paths.contexts,()=>{const doc=this.read();if(name!==null&&name!=='local'&&!doc.contexts[name])throw new Error(`unknown context ${name}`);doc.current=name;atomic(this.paths.contexts,doc);});} remove(name){return locked(this.paths.contexts,()=>{const doc=this.read();delete doc.contexts[name];if(doc.current===name)doc.current=null;atomic(this.paths.contexts,doc);});} writeCredential(id,value){mkdirSync(this.paths.credentials,{recursive:true,mode:0o700});locked(join(this.paths.credentials,`${id}.json`),()=>atomic(join(this.paths.credentials,`${id}.json`),value));} credential(id){return read(join(this.paths.credentials,`${id}.json`),null);} }
13
+ WorkspaceContexts.prototype.upsert=function(name,value){if(!NAME.test(name)||name==='local')throw new Error('context name must match [a-z][a-z0-9_-]{0,63} and cannot be local');return locked(this.paths.contexts,()=>{const doc=this.read();doc.contexts[name]=value;atomic(this.paths.contexts,doc);return value;});};
14
+ WorkspaceContexts.prototype.createAuthenticated=async function(name,{origin,teamId,credentialRef,fetch}){
15
+ if(!ID.test(teamId)||!/^cred_[A-Za-z0-9_-]+$/.test(credentialRef))throw new Error('team and credential reference must be immutable IDs');
16
+ const normalized=normalizeOrigin(origin),credential=this.credential(credentialRef);
17
+ if(!credential?.token)throw Object.assign(new Error(`credential ${credentialRef} is missing`),{code:'authentication_required'});
18
+ if(credential.origin!==normalized)throw Object.assign(new Error('credential origin does not match requested server'),{code:'authentication_required'});
19
+ const client=new TeamsClient({server:normalized,credential:credential.token,fetch});
20
+ const me=await client.me();
21
+ if(credential.server_id&&credential.server_id!==me.server_id)throw Object.assign(new Error('credential server audience does not match'),{code:'authentication_required'});
22
+ const membership=me.teams.find(item=>item.team.id===teamId);
23
+ if(!membership)throw Object.assign(new Error('credential is not a member of the requested team'),{code:'forbidden'});
24
+ return this.create(name,{origin:normalized,server_id:me.server_id,team_id:teamId,principal_id:me.principal.id,credential_ref:credentialRef});
25
+ };
26
+ WorkspaceContexts.prototype.connector=function(serverId,teamId){
27
+ if(!ID.test(serverId)||!ID.test(teamId))throw new Error('server and team must be immutable IDs');
28
+ const directory=join(this.paths.connectors,serverId,teamId),path=join(directory,'enrollment.json');
29
+ return {directory,read:()=>read(path,null),write:value=>{mkdirSync(directory,{recursive:true,mode:0o700});locked(path,()=>atomic(path,value));},update:fn=>{mkdirSync(directory,{recursive:true,mode:0o700});return locked(path,()=>{const value=fn(read(path,null));if(value===null){if(existsSync(path))unlinkSync(path);}else atomic(path,value);return value;});},remove:()=>{if(existsSync(path))locked(path,()=>unlinkSync(path));}};
30
+ };
31
+ export function fromContext({supercodeHome,name,fetch}){const contexts=new WorkspaceContexts(supercodeHome),doc=contexts.read(),selected=name??doc.current;if(!selected||selected==='local')throw new Error('a remote workspace context is required');const context=doc.contexts[selected];if(!context)throw new Error(`unknown context ${selected}`);const credential=contexts.credential(context.credential_ref);if(!credential?.token)throw Object.assign(new Error(`credential ${context.credential_ref} is missing`),{code:'authentication_required'});if(credential.server_id!==context.server_id||credential.origin!==context.origin)throw Object.assign(new Error('credential audience does not match context'),{code:'authentication_required'});return {client:new TeamsClient({server:context.origin,credential:credential.token,fetch}),context};}
32
+ /** A context name from a team's name: `Volter` → `volter`. */
33
+ export function contextNameOf(team){const name=String(team).toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^[^a-z]+/,'').slice(0,64);return name||'team';}
34
+ export async function loginContext({supercodeHome,name,origin,teamId,token,invite,identityToken=null,as,fetch}){if(token!=null&&(invite!=null||identityToken!=null))throw new Error('a token signs in alone');if(token==null&&invite==null&&identityToken==null)throw new Error('a token, an invitation or an identity is required');origin=normalizeOrigin(origin);let credential,serverId,principalId,resolvedTeam=teamId;if(invite){const response=await fetch(new URL('/api/v1/invitations/accept',origin),{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify({enrollment_code:invite,...(identityToken?{access_token:identityToken}:{})})});if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw new Error('cross-origin redirects are refused');if(!response.ok)throw new Error(`invitation failed: HTTP ${response.status}`);const value=await response.json();credential=value.credential;principalId=value.principal.id;resolvedTeam=value.team.id;token=credential.token;}else if(identityToken){const response=await fetch(new URL('/api/v1/login/volter',origin),{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token:identityToken,...(teamId?{team_id:teamId}:{})})});if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw new Error('cross-origin redirects are refused');if(!response.ok){const detail=await response.json().catch(()=>null);throw new Error(`sign-in failed: ${detail?.error?.message??`HTTP ${response.status}`}`);}const value=await response.json();credential=value.credential;principalId=value.principal.id;resolvedTeam=value.team.id;token=credential.token;}const client=new TeamsClient({server:origin,credential:token,fetch});const me=await client.me();serverId=me.server_id;const membership=me.teams.find(x=>x.team.id===resolvedTeam||x.team.name===resolvedTeam);if(!membership)throw new Error('credential is not a member of the requested team');name??=contextNameOf(membership.team.name);principalId??=me.principal.id;const store=new WorkspaceContexts(supercodeHome);const credentialId=`cred_${crypto.randomUUID().replaceAll('-','')}`;store.writeCredential(credentialId,{token,kind:credential?.kind??'user',expires_at:credential?.expires_at??null,server_id:serverId,origin});const saved=store.upsert(name,{origin,server_id:serverId,team_id:membership.team.id,team_name:membership.team.name,principal_id:principalId,credential_ref:credentialId,label:as??null});if(!store.read().current)store.use(name);return saved;}
@@ -0,0 +1,8 @@
1
+ // Public custody door: consumers follow HTTP events without resolving the Teams binary or reading its files.
2
+ import { dirname } from 'node:path';
3
+ import { teamsHome } from './home.mjs';
4
+ import { fromContext } from './context.mjs';
5
+ export function eventClient({supercodeHome=dirname(teamsHome()),context: name,fetch}={}) {
6
+ const {client,context}=fromContext({supercodeHome,name,fetch});
7
+ return {team:client.team(context.team_id),context:{name,org:context.team_id,principal:context.principal_id,server:context.origin}};
8
+ }
@@ -0,0 +1,149 @@
1
+ /** Neutral HTTP transport. Product extensions keep their payloads in their own contracts. */
2
+ export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number }
3
+ export interface StreamOptions extends RequestOptions { after?: string }
4
+ export type Query = Record<string, unknown>;
5
+ export interface Result<T = unknown> { version: number; data: T; [key: string]: unknown }
6
+ export interface Page<T = unknown> { version: number; items: T[]; [key: string]: unknown }
7
+ export interface OntologyEvent {
8
+ id: string | null; org: string; at: number | null; kind: string;
9
+ actor: { principal: string | null; session: string | null; lineage: string[] } | null;
10
+ resource: { kind: string; id: string }; data: Record<string, unknown>;
11
+ }
12
+ export interface PublishedEventFrame {
13
+ type: 'snapshot_begin' | 'snapshot_item' | 'snapshot_end' | 'event' | 'checkpoint' | 'access_revoked' | 'resync_required';
14
+ cursor?: string; sequence?: number; data?: OntologyEvent; terminal?: boolean; reason?: string;
15
+ }
16
+ export interface ReadCollection {
17
+ list(query?: Query, options?: RequestOptions): Promise<Page>;
18
+ get(id: string, options?: RequestOptions): Promise<Result>;
19
+ }
20
+ export interface MutableCollection extends ReadCollection {
21
+ create(body: unknown, options?: RequestOptions): Promise<Result>;
22
+ update(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
23
+ remove(id: string, options?: RequestOptions): Promise<null>;
24
+ }
25
+ export interface AgentRecord {
26
+ id: string; kind: 'agent'; org: string; name: string; path: string; parent: string | null;
27
+ lineage: Record<string, unknown>; sponsor: string | null; explicit_sponsor: string | null;
28
+ profile: string; state: 'active' | 'suspended' | 'ended'; suspension_reason: string | null;
29
+ main_session: string | null; created_at: number; ended_at: number | null; end_reason: string | null; revision: number;
30
+ }
31
+ export interface SessionIdentity {
32
+ session: string; agent: AgentRecord | null; starter: Record<string, unknown> | null;
33
+ resolved: Record<string, unknown> | null; resolved_at?: number | null; main: boolean;
34
+ ended_at?: number | null; end_reason?: string | null; revision?: number; state?: 'unrecorded';
35
+ }
36
+ export interface SessionClient {
37
+ identity(options?: RequestOptions): Promise<Result<SessionIdentity>>;
38
+ recordLineage(body: {starter_session?: string}, options?: RequestOptions): Promise<Result<SessionIdentity>>;
39
+ mail(body: {text: string}, options?: RequestOptions): Promise<Result<{delivered: true}>>;
40
+ descriptor(options?: RequestOptions): Promise<Result>;
41
+ read(query?: Query, options?: RequestOptions): Promise<unknown>;
42
+ follow(query?: Query, options?: StreamOptions): AsyncIterable<unknown>;
43
+ access(options?: RequestOptions): Promise<Result>;
44
+ grant(body: unknown, options?: RequestOptions): Promise<Result>;
45
+ revoke(id: string, options?: RequestOptions): Promise<Result>;
46
+ export(body: unknown, options?: RequestOptions): Promise<Result>;
47
+ prepare(body: unknown, options?: RequestOptions): Promise<Result>;
48
+ attach(body: unknown, options?: RequestOptions): Promise<Result>;
49
+ artifact(id: string, options?: {signal?: AbortSignal; range?: string}): Promise<Response>;
50
+ }
51
+ export interface TeamClient {
52
+ identity: import('./identity-record.js').IdentityRecordClient;
53
+ /** One role-and-binding shape (C5): roles bundle actions; a binding grants one on a resource; one evaluator decides. */
54
+ permissions: {
55
+ roles(options?: RequestOptions): Promise<Result>;
56
+ defineRole(body: {name: string; actions: string[]}, options?: RequestOptions): Promise<Result>;
57
+ bindings(query: {resource_kind: string; resource_id: string}, options?: RequestOptions): Promise<Result>;
58
+ bind(body: {holder: {kind: 'principal' | 'group'; id: string}; role?: string; actions?: string[]; resource: {kind: string; id: string}; expires_at?: number; conditions?: Record<string, unknown>}, options?: RequestOptions): Promise<Result>;
59
+ revoke(id: string, options?: RequestOptions): Promise<Result>;
60
+ decide(body: {principal?: string; action: string; resource: {kind: string; id: string}}, options?: RequestOptions): Promise<Result>;
61
+ };
62
+ /** An automation's sponsor: a person an Org admin names; agents the automation starts inherit it. */
63
+ principals: {
64
+ sponsor(id: string, options?: RequestOptions): Promise<Result>;
65
+ setSponsor(id: string, sponsor: string, options?: RequestOptions): Promise<Result>;
66
+ clearSponsor(id: string, options?: RequestOptions): Promise<Result>;
67
+ };
68
+ agents: {
69
+ root(options?: RequestOptions): Promise<Result<AgentRecord>>;
70
+ list(query?: Query, options?: RequestOptions): Promise<Page<AgentRecord>>;
71
+ get(id: string, options?: RequestOptions): Promise<Result<AgentRecord>>;
72
+ management(id: string, options?: RequestOptions): Promise<Result<{id: string; revision: number; profile: string; manageable: true}>>;
73
+ declare(body: {id?: string; name: string; parent?: string | null; sponsor?: string | null; profile: string; root?: boolean; starter_session?: string}, options?: RequestOptions): Promise<Result<AgentRecord>>;
74
+ update(id: string, body: {name?: string; parent?: string | null; sponsor?: string | null; profile?: string; state?: 'active' | 'suspended'}, options?: RequestOptions): Promise<Result<AgentRecord>>;
75
+ delegateReader(id: string, body?: {expires_in_ms?: number}, options?: RequestOptions): Promise<Result<{id: string; token: string; principal_id: string; kind: 'service'; expires_at: number; actions: string[]}>>;
76
+ end(id: string, body: {reason: string}, options?: RequestOptions): Promise<Result<{id: string; state: 'ended'; ended: string[]}>>;
77
+ closures(id: string, options?: RequestOptions): Promise<Result<{items: Array<{id: string; session_id: string; machine_id: string; harness: string; native_id: string; state: 'pending'|'claimed'|'closed'|'failed'|'unknown'; reason: string; result: {state: string; reason: string}|null}>}>>;
78
+ sessions(id: string, query?: Query, options?: RequestOptions): Promise<Page<SessionIdentity>>;
79
+ ownSession(id: string, body: {session: string; main?: boolean; starter_session?: string}, options?: RequestOptions): Promise<Result<SessionIdentity>>;
80
+ };
81
+ id: string;
82
+ events: {
83
+ catalogue(options?: RequestOptions): Promise<Result<{kinds: string[]}>>;
84
+ createSource(body: {name: string}, options?: RequestOptions): Promise<Result<{id: string; name: string}>>;
85
+ publish(source: string, body: unknown, options?: RequestOptions): Promise<Result<OntologyEvent>>;
86
+ follow(query?: Query, options?: StreamOptions): AsyncIterable<PublishedEventFrame>;
87
+ };
88
+ sessions: {
89
+ list(query?: Query, options?: RequestOptions): Promise<Page>;
90
+ search(query: Query, options?: RequestOptions): Promise<unknown>;
91
+ watch(query?: Query, options?: StreamOptions): AsyncIterable<unknown>;
92
+ get(id: string): SessionClient;
93
+ };
94
+ members: Pick<MutableCollection, 'list' | 'update' | 'remove'>;
95
+ groups: Pick<MutableCollection, 'list' | 'create' | 'remove'> & {
96
+ addMember(id: string, member: string, options?: RequestOptions): Promise<null>;
97
+ removeMember(id: string, member: string, options?: RequestOptions): Promise<null>;
98
+ };
99
+ machines: Pick<MutableCollection, 'list' | 'get' | 'create' | 'remove'> & {
100
+ reportSessionProfile(id: string, body: {session: string; profile: Record<string, unknown>}, options?: RequestOptions): Promise<Result>;
101
+ roots(id: string, query?: Query, options?: RequestOptions): Promise<Page>;
102
+ liveGrants(id: string): {
103
+ list(options?: RequestOptions): Promise<Page>; get(id: string, options?: RequestOptions): Promise<Result>;
104
+ create(body: unknown, options?: RequestOptions): Promise<Result>; remove(id: string, options?: RequestOptions): Promise<null>;
105
+ };
106
+ };
107
+ apps: {
108
+ catalogue(options?: RequestOptions): Promise<Result>;
109
+ list(options?: RequestOptions): Promise<Page>; get(id: string, options?: RequestOptions): Promise<Result>;
110
+ register(body: unknown, options?: RequestOptions): Promise<Result>;
111
+ deregister(id: string, options?: RequestOptions): Promise<null>;
112
+ rotateSecret(id: string, options?: RequestOptions): Promise<Result>;
113
+ install(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
114
+ };
115
+ appInstallations: {
116
+ list(options?: RequestOptions): Promise<Page>; get(id: string, options?: RequestOptions): Promise<Result>;
117
+ uninstall(id: string, options?: RequestOptions): Promise<null>;
118
+ delegate(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
119
+ };
120
+ sources: Pick<MutableCollection, 'list' | 'get' | 'create' | 'update'> & {
121
+ credential(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
122
+ configuration(id: string, format?: string, options?: RequestOptions): Promise<Result>;
123
+ };
124
+ syncRules: MutableCollection;
125
+ credentials: Pick<MutableCollection, 'create' | 'remove'>;
126
+ invitations: Pick<MutableCollection, 'create'>;
127
+ operations: Pick<ReadCollection, 'get'>;
128
+ continuations: Pick<ReadCollection, 'get'> & {
129
+ authorizeDestination(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
130
+ launch(id: string, options?: RequestOptions): Promise<Result>;
131
+ completeLocal(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
132
+ };
133
+ attachments: {
134
+ actions(id: string, body: unknown, options?: RequestOptions): Promise<Result>;
135
+ detach(id: string, options?: RequestOptions): Promise<null>;
136
+ stream(attachment: unknown, options?: RequestOptions): AsyncIterable<unknown>;
137
+ };
138
+ }
139
+ export declare class TeamsClient {
140
+ constructor(options: {server: string; credential: string; fetch?: typeof fetch});
141
+ server: string;
142
+ request<T = unknown>(method: string, path: string, options?: RequestOptions & {body?: unknown}): Promise<T>;
143
+ me(options?: RequestOptions): Promise<unknown>;
144
+ capabilities(options?: RequestOptions): Promise<{version: 1; server_id: string; protocol: 'teams.v1'; operations: string[]; auth_modes: string[]}>;
145
+ teams(query?: Query, options?: RequestOptions): Promise<Page>;
146
+ createTeam(body: {name: string}, options?: RequestOptions): Promise<Result>;
147
+ redeemAppCode(body: unknown, options?: RequestOptions): Promise<unknown>;
148
+ team(id: string): TeamClient;
149
+ }
@@ -0,0 +1,232 @@
1
+ const trimOrigin=value=>{const u=new URL(value),loopback=u.hostname==='localhost'||u.hostname==='::1'||u.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(u.hostname);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.search||u.hash||(u.pathname!==''&&u.pathname!=='/')||(u.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return u.origin;};
2
+ const randomKey=()=>globalThis.crypto.randomUUID().replaceAll('-','')+globalThis.crypto.randomUUID().replaceAll('-','');
3
+ export class TeamsClient {
4
+ constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args)}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
5
+ async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
6
+ const key=idempotencyKey??(method==='GET'?null:randomKey());let delay=250,last;
7
+ for(let attempt=0;attempt<5;attempt++) { try {
8
+ const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(body)});
9
+ if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
10
+ const value=response.status===204?null:await response.json();if(!response.ok)throw Object.assign(new Error(value?.error?.message??`HTTP ${response.status}`),value?.error,{status:response.status});return value;
11
+ } catch(error) { last=error;if(method!=='GET'||signal?.aborted||!(!error.status||error.retryable)||attempt===4)throw error;await retryWait(delay,signal);delay=Math.min(delay*2,5000); } }
12
+ throw last;
13
+ }
14
+ me(options){return this.request('GET','/api/v1/me',options);} capabilities(options){return this.request('GET','/api/v1/capabilities',options);} team(id){return new TeamClient(this,id);}
15
+ redeemAppCode(body,options){return this.request('POST','/api/v1/apps/token',{...options,body});}
16
+ teams(queryValue={},options){return this.request('GET','/api/v1/teams'+query(queryValue),options);}
17
+ createTeam(body,options){return this.request('POST','/api/v1/teams',{...options,body});}
18
+ }
19
+ const segment = value => {
20
+ if (typeof value !== 'string' || !value || value.length > 256) throw new TypeError('a nonempty resource ID is required');
21
+ return encodeURIComponent(value);
22
+ };
23
+ class TeamClient {
24
+ constructor(root, id) {
25
+ this.root = root; this.id = id;
26
+ const base = `/api/v1/teams/${segment(id)}`;
27
+ const get = (path, options) => root.request('GET', base + path, options);
28
+ const post = (path, body, options) => root.request('POST', base + path, { ...options, body });
29
+ const patch = (path, body, options) => root.request('PATCH', base + path, { ...options, body });
30
+ const remove = (path, options) => root.request('DELETE', base + path, options);
31
+ const resource = (path, id) => `${path}/${segment(id)}`;
32
+ this.agents = {
33
+ root: o => get('/agents/root',o),
34
+ list: (q={},o) => get('/agents'+query(q),o),
35
+ get: (id,o) => get(resource('/agents',id),o),
36
+ management: (id,o) => get(resource('/agents',id)+'/management',o),
37
+ declare: (body,o) => post('/agents',body,o),
38
+ update: (id,body,o) => patch(resource('/agents',id),body,o),
39
+ sessions: (id,q={},o) => get(resource('/agents',id)+'/sessions'+query(q),o),
40
+ ownSession: (id,body,o) => post(resource('/agents',id)+'/sessions',body,o),
41
+ delegateReader: (id,body={},o) => post(resource('/agents',id)+'/credentials',body,o),
42
+ end: (id,body,o) => post(resource('/agents',id)+'/end',body,o),
43
+ closures: (id,o) => get(resource('/agents',id)+'/closures',o),
44
+ };
45
+ this.permissions = {
46
+ roles: o => get('/permissions/roles',o),
47
+ defineRole: (body,o) => post('/permissions/roles',body,o),
48
+ bindings: (q,o) => get('/permissions/bindings'+query(q),o),
49
+ bind: (body,o) => post('/permissions/bindings',body,o),
50
+ revoke: (id,o) => remove(resource('/permissions/bindings',id),o),
51
+ decide: (body,o) => post('/permissions/decide',body,o),
52
+ };
53
+ this.principals = {
54
+ sponsor: (id,o) => get(resource('/principals',id)+'/sponsor',o),
55
+ setSponsor: (id,sponsor,o) => root.request('PUT', base + resource('/principals',id)+'/sponsor', { ...o, body:{sponsor} }),
56
+ clearSponsor: (id,o) => remove(resource('/principals',id)+'/sponsor',o),
57
+ };
58
+ this.identity = {
59
+ observations: (q = {},o) => get('/identity/observations'+query(q),o),
60
+ sightings: (q,o) => get('/identity/sightings'+query(q),o),
61
+ observe: (body,o) => post('/identity/observations',body,o),
62
+ resolve: (observation,o) => get('/identity/resolve'+query({observation}),o),
63
+ declarePerson: (body,o) => post('/identity/principals',body,o),
64
+ infer: (body,o) => post('/identity/inferences',{...body,classification:'inferred'},o),
65
+ revokeInference: (id,o) => remove(resource('/identity/inferences',id),o),
66
+ link: (body,o) => post('/identity/links',body,o),
67
+ revoke: (id,o) => remove(resource('/identity/links',id),o),
68
+ };
69
+ this.events = {
70
+ catalogue: o => get('/events/catalogue',o),
71
+ follow: (q = {},o = {}) => events(root,base+'/events'+query(q),o),
72
+ createSource: (body,o) => post('/event-sources',body,o),
73
+ publish: (source,body,o) => post(resource('/event-sources',source)+'/events',body,o),
74
+ };
75
+ this.apps = {
76
+ catalogue: o => get('/apps/catalogue', o),
77
+ list: o => get('/apps', o), get: (id, o) => get(resource('/apps', id), o),
78
+ register: (body, o) => post('/apps', body, o),
79
+ deregister: (id, o) => remove(resource('/apps', id), o),
80
+ rotateSecret: (id, o) => post(resource('/apps', id) + '/secret', {}, o),
81
+ install: (id, body, o) => post(resource('/apps', id) + '/installations', body, o),
82
+ };
83
+ this.appInstallations = {
84
+ list: o => get('/app-installations', o), get: (id, o) => get(resource('/app-installations', id), o),
85
+ uninstall: (id, o) => remove(resource('/app-installations', id), o),
86
+ delegate: (id, body, o) => post(resource('/app-installations', id) + '/delegations', body, o),
87
+ };
88
+ this.sessions = {
89
+ list: (q = {}, o) => get('/sessions' + query(q), o),
90
+ search: (q, o) => get('/sessions/search' + query(q), o),
91
+ watch: (q = {}, o = {}) => events(root, base + '/sessions/events' + query(q), o),
92
+ get: id => new SessionClient(root, base, id),
93
+ };
94
+ this.members = {
95
+ list: (q = {}, o) => get('/members' + query(q), o),
96
+ update: (id, body, o) => patch(resource('/members', id), body, o),
97
+ remove: (id, o) => remove(resource('/members', id), o),
98
+ };
99
+ this.groups = {
100
+ list: (q = {}, o) => get('/groups' + query(q), o),
101
+ create: (body, o) => post('/groups', body, o),
102
+ remove: (id, o) => remove(resource('/groups', id), o),
103
+ addMember: (id, member, o) => root.request('PUT', base + resource('/groups', id) + resource('/members', member), o),
104
+ removeMember: (id, member, o) => remove(resource('/groups', id) + resource('/members', member), o),
105
+ };
106
+ this.machines = {
107
+ reportSessionProfile: (id,body,o) => post(resource('/machines',id)+'/session-profiles',body,o),
108
+ list: (q = {}, o) => get('/machines' + query(q), o),
109
+ get: (id, o) => get(resource('/machines', id), o),
110
+ create: (body, o) => post('/machines', body, o),
111
+ roots: (id, q = {}, o) => get(resource('/machines', id) + '/roots' + query(q), o),
112
+ remove: (id, o) => remove(resource('/machines', id), o),
113
+ liveGrants: machineId => {
114
+ const path = resource('/machines', machineId) + '/grants';
115
+ return {
116
+ list: (o) => get(path, o), get: (id, o) => get(resource(path, id), o),
117
+ create: (body, o) => post(path, body, o), remove: (id, o) => remove(resource(path, id), o),
118
+ };
119
+ },
120
+ };
121
+ this.sources = {
122
+ list: (q = {}, o) => get('/sources' + query(q), o),
123
+ get: (id, o) => get(resource('/sources', id), o),
124
+ create: (body, o) => post('/sources', body, o),
125
+ update: (id, body, o) => patch(resource('/sources', id), body, o),
126
+ credential: (id, body, o) => post(resource('/sources', id) + '/credentials', body, o),
127
+ configuration: (id, format = 'otel-collector', o) => get(resource('/sources', id) + '/config' + query({ format }), o),
128
+ };
129
+ this.syncRules = {
130
+ list: (q = {}, o) => get('/sync-rules' + query(q), o), get: (id, o) => get(resource('/sync-rules', id), o),
131
+ create: (body, o) => post('/sync-rules', body, o), update: (id, body, o) => patch(resource('/sync-rules', id), body, o),
132
+ remove: (id, o) => remove(resource('/sync-rules', id), o),
133
+ };
134
+ this.credentials = {
135
+ create: (body, o) => post('/credentials', body, o), remove: (id, o) => remove(resource('/credentials', id), o),
136
+ };
137
+ this.invitations = { create: (body, o) => post('/invitations', body, o) };
138
+ this.operations = { get: (id, o) => get(resource('/operations', id), o) };
139
+ this.continuations = {
140
+ get: (id, o) => get(resource('/continuations', id), o),
141
+ authorizeDestination: (id, body, o) => post(resource('/continuations', id) + '/authorize-destination', body, o),
142
+ launch: (id, o) => post(resource('/continuations', id) + '/launch', undefined, o),
143
+ completeLocal: (id, body, o) => post(resource('/continuations', id) + '/complete-local', body, o),
144
+ };
145
+ this.attachments = {
146
+ actions: (id, body, o) => post(resource('/attachments', id) + '/actions', body, o),
147
+ detach: (id, o) => remove(resource('/attachments', id), o),
148
+ stream: (attachment, o = {}) => attachmentEvents(root, base, attachment, o),
149
+ };
150
+ }
151
+ }
152
+ class SessionClient {
153
+ constructor(root, base, id) { this.root = root; this.path = `${base}/sessions/${segment(id)}`; }
154
+ identity = o => this.root.request('GET',this.path+'/agent',o);
155
+ recordLineage = (body,o) => this.root.request('POST',this.path+'/agent',{...o,body});
156
+ mail = (body,o) => this.root.request('POST',this.path+'/mail',{...o,body});
157
+ descriptor = o => this.root.request('GET', this.path, o);
158
+ read = (q = {}, o) => this.root.request('GET', this.path + '/messages' + query(q), o);
159
+ follow = (q = {}, o = {}) => events(this.root, this.path + '/events' + query(q), o);
160
+ access = o => this.root.request('GET', this.path + '/access', o);
161
+ grant = (body, o) => this.root.request('POST', this.path + '/access', { ...o, body });
162
+ revoke = (id, o) => this.root.request('DELETE', this.path + '/access/' + segment(id), o);
163
+ export = (body, o) => this.root.request('POST', this.path + '/exports', { ...o, body });
164
+ prepare = (body, o) => this.root.request('POST', this.path + '/continuations', { ...o, body });
165
+ attach = (body, o) => this.root.request('POST', this.path + '/attachments', { ...o, body });
166
+ artifact = async (id, { signal, range } = {}) => {
167
+ if (range !== undefined && !/^bytes=(?:[0-9]+-[0-9]*|-[0-9]+)$/.test(range)) throw new TypeError('range must be one byte range');
168
+ const response = await this.root.fetch(this.root.server + this.path + '/artifacts/' + segment(id), {
169
+ signal, redirect: 'manual', headers: { Authorization: `Bearer ${this.root.credential}`, ...(range ? { Range: range } : {}) },
170
+ });
171
+ rejectRedirect(response);
172
+ if (!response.ok) throw await sseError(response);
173
+ return response;
174
+ };
175
+ }
176
+ function rejectRedirect(response) {
177
+ if (response.type === 'opaqueredirect' || response.status >= 300 && response.status < 400) throw Object.assign(new Error('cross-origin redirects are refused'), { code: 'invalid_request', status: 400, retryable: false });
178
+ }
179
+ async function* attachmentEvents(root, base, attachment, { signal } = {}) {
180
+ const expected = root.server + base + '/attachments/' + segment(attachment.id) + '/stream';
181
+ if (attachment.stream_format !== 'observed_runtime_event' || attachment.stream_url !== expected || typeof attachment.credential !== 'string' || !attachment.credential) throw new TypeError('attachment stream target or credential is invalid');
182
+ let response;
183
+ try {
184
+ response = await root.fetch(expected, { signal, redirect: 'manual', headers: { Accept: 'text/event-stream', Authorization: `Bearer ${attachment.credential}` } });
185
+ rejectRedirect(response);
186
+ if (!response.ok) throw await sseError(response);
187
+ let buffer = '';
188
+ for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
189
+ buffer += chunk;
190
+ let match;
191
+ while ((match = /\r?\n\r?\n/.exec(buffer))) {
192
+ const block = buffer.slice(0, match.index); buffer = buffer.slice(match.index + match[0].length);
193
+ if (new TextEncoder().encode(block).length > 1024 * 1024 + 4096) throw Object.assign(new Error('attachment frame exceeds 1 MiB'), { code: 'resync_required', retryable: false });
194
+ const lines = block.split(/\r?\n/), kind = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
195
+ const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).replace(/^ /, '')).join('\n');
196
+ if (!data) continue;
197
+ if (kind === 'access_revoked' || kind === 'resync_required') throw Object.assign(new Error(`attachment stream ${kind}`), { code: kind, retryable: false });
198
+ if (kind !== 'runtime') throw Object.assign(new Error('unexpected attachment stream event'), { code: 'invalid_request', retryable: false });
199
+ const frame = JSON.parse(data);
200
+ yield frame;
201
+ if (frame.type === 'closed' || frame.type === 'error' && frame.terminal) return;
202
+ }
203
+ if (new TextEncoder().encode(buffer).length > 1024 * 1024 + 4096) throw Object.assign(new Error('attachment frame exceeds 1 MiB'), { code: 'resync_required', retryable: false });
204
+ }
205
+ if (!signal?.aborted) throw Object.assign(new Error('attachment stream ended; create a new attachment to reconnect'), { code: 'resync_required', retryable: false });
206
+ } catch (error) { if (!signal?.aborted) throw error; }
207
+ }
208
+ const query=q=>{const p=new URLSearchParams();for(const [k,v]of Object.entries(q))if(v!==undefined)p.set(k,String(v));return p.size?'?'+p:'';};
209
+ const retryWait=(delay,signal)=>new Promise((resolve,reject)=>{const done=()=>{signal?.removeEventListener('abort',abort);resolve();},timer=setTimeout(done,delay),abort=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);reject(signal.reason);};signal?.addEventListener('abort',abort,{once:true});});
210
+ async function sseError(response){let value=null;try{value=await response.json();}catch{}return Object.assign(new Error(value?.error?.message??`SSE HTTP ${response.status}`),value?.error,{status:response.status,retryable:value?.error?.retryable??(response.status===429||response.status>=500)});}
211
+ async function* events(root,path,{signal,after}={}){
212
+ let cursor=after??null,lastYielded=null,lastError=null;
213
+ for(let attempt=0;attempt<5&&!signal?.aborted;attempt++){
214
+ const suffix=cursor?path+(path.includes('?')?'&':'?')+'after='+encodeURIComponent(cursor):path;
215
+ try{
216
+ const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...(cursor?{'Last-Event-ID':cursor}:{})}});
217
+ if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
218
+ if(!response.ok)throw await sseError(response);
219
+ let buffer='';
220
+ for await(const chunk of response.body.pipeThrough(new TextDecoderStream())){
221
+ buffer+=chunk;let at;
222
+ while((at=buffer.indexOf('\n\n'))>=0){
223
+ const block=buffer.slice(0,at);buffer=buffer.slice(at+2);const id=block.split('\n').find(line=>line.startsWith('id: '))?.slice(4),data=block.split('\n').filter(line=>line.startsWith('data: ')).map(line=>line.slice(6)).join('\n');if(!data)continue;
224
+ const event=JSON.parse(data),terminal=event?.terminal||event?.type==='access_revoked'||event?.type==='resync_required',nextCursor=id??cursor;cursor=nextCursor;if(!terminal&&nextCursor&&nextCursor===lastYielded)continue;if(nextCursor)lastYielded=nextCursor;yield event;if(terminal)return;
225
+ }
226
+ }
227
+ lastError=Object.assign(new Error('SSE stream ended before a terminal event'),{retryable:true});
228
+ }catch(error){if(signal?.aborted)return;if(error.retryable===false||error.status&&error.status!==429&&error.status<500)throw error;lastError=error;}
229
+ if(attempt<4)await retryWait(Math.min(250*2**attempt,5000),signal).catch(()=>{});
230
+ }
231
+ if(!signal?.aborted&&lastError)throw lastError;
232
+ }
@@ -0,0 +1,21 @@
1
+ import type {RequestOptions, Result, Page} from './http-client.js';
2
+ export interface IdentityLink {
3
+ id:string; observation:string; principal_id:string; classification:'proven'|'inferred'; label:'proven'|'probably';
4
+ proof:{kind:'pairing'|'integration'|'custodian'|'identity_subject'|'inference'; evidence:Record<string,unknown>};
5
+ created_by:string; created_at:number; revision:number; revoked_at:number|null; revoked_by:string|null;
6
+ }
7
+ export interface IdentityResolution {
8
+ observation:string; first_seen:number|null; last_seen:number|null;
9
+ principal:{id:string;name:string;kind:string}|null; classification:'proven'|'inferred'|'unresolved'; label:string; links:IdentityLink[];
10
+ }
11
+ export interface IdentityRecordClient {
12
+ observations(query?:{limit?:number;after?:string},options?:RequestOptions):Promise<Page>;
13
+ sightings(query:{observation:string;limit?:number;after?:string},options?:RequestOptions):Promise<Page>;
14
+ observe(body:{observation:string;location:string;at:number;name?:string|null;evidence:Record<string,unknown>},options?:RequestOptions):Promise<Result<{id:string;observation:string}>>;
15
+ resolve(observation:string,options?:RequestOptions):Promise<Result<IdentityResolution>>;
16
+ declarePerson(body:{name:string},options?:RequestOptions):Promise<Result<{id:string;kind:'user';display_name:string}>>;
17
+ link(body:{observation:string;principal_id:string;classification:'proven'|'inferred';proof:IdentityLink['proof']},options?:RequestOptions):Promise<Result<IdentityLink>>;
18
+ infer(body:{observation:string;principal_id:string;proof:{kind:'inference';evidence:Record<string,unknown>}},options?:RequestOptions):Promise<Result<IdentityLink>>;
19
+ revoke(id:string,options:RequestOptions & {ifMatch:number}):Promise<null>;
20
+ revokeInference(id:string,options:RequestOptions & {ifMatch:number}):Promise<null>;
21
+ }
package/lines.mjs ADDED
@@ -0,0 +1,19 @@
1
+ /** Splits a stream's chunks into its complete lines, in time linear in its length: each chunk is scanned once and a
2
+ * line joined once, when its newline arrives. Appending to one buffer and searching it from the start again on
3
+ * every chunk is quadratic in a long line, and one answer (a large session's `sessions.load`, a whole machine's
4
+ * `sessions.discover`) is a line of tens of megabytes. */
5
+ export function lineSplitter() {
6
+ let pending = [];
7
+ return (chunk) => {
8
+ const text = String(chunk), lines = [];
9
+ let from = 0, at;
10
+ while ((at = text.indexOf('\n', from)) >= 0) {
11
+ pending.push(text.slice(from, at));
12
+ lines.push(pending.join(''));
13
+ pending = [];
14
+ from = at + 1;
15
+ }
16
+ if (from < text.length) pending.push(text.slice(from));
17
+ return lines;
18
+ };
19
+ }
@@ -0,0 +1,36 @@
1
+ // Which door a machine verb goes through: without a machine name, this OS
2
+ // user's own daemon over its local socket; with one, the Teams server of the
3
+ // selected context, which forwards the channel to that enrolled machine.
4
+ import { resolve } from 'node:path';
5
+ import { openLocalMachine, openRemoteMachine } from './client.mjs';
6
+ import { WorkspaceContexts, fromContext } from './context.mjs';
7
+ import { teamsHome, paths } from './home.mjs';
8
+
9
+ export async function openMachine({ machine = null, context = null, env = process.env, home = teamsHome(env) } = {}) {
10
+ if (!machine) return openLocalMachine(paths(home).socket);
11
+ const supercodeHome = env.SUPERCODE_HOME ?? resolve(home, '..');
12
+ const contexts = new WorkspaceContexts(supercodeHome);
13
+ const name = context ?? env.SUPERCODE_CONTEXT ?? contexts.read().current;
14
+ if (!name || name === 'local') throw Object.assign(new Error('a remote machine is reached through a Teams context: `supercode teams login …`, then `supercode context use NAME` or --context NAME'), { code: 'invalid_request' });
15
+ const { client, context: selected } = fromContext({ supercodeHome, name, fetch: globalThis.fetch });
16
+ const credential = contexts.credential(selected.credential_ref);
17
+ let machineId = machine;
18
+ if (!machine.startsWith('mach_')) {
19
+ const listed = (await client.team(selected.team_id).machines.list()).items;
20
+ let matches = listed.filter((item) => item.name === machine);
21
+ // A mail address names a machine by its short, lowercased host name
22
+ // (`yuerans-macbook-pro`), while enrollment defaults to the full one
23
+ // (`Yuerans-MacBook-Pro.local`): fall back to that normal form.
24
+ if (!matches.length) matches = listed.filter((item) => normalMachineName(item.name) === normalMachineName(machine));
25
+ if (matches.length !== 1) throw Object.assign(new Error(matches.length ? `machine name ${machine} is ambiguous; use its id` : `machine ${machine} is not accessible`), { code: matches.length ? 'ambiguous_reference' : 'not_found' });
26
+ machineId = matches[0].id;
27
+ }
28
+ const channel = await openRemoteMachine({ origin: selected.origin, teamId: selected.team_id, machineId, token: credential.token });
29
+ channel.machineName = machine;
30
+ return channel;
31
+ }
32
+
33
+ /** A machine name in the form mail addresses use: the short host name, lowercased, with anything outside [a-z0-9_-] as `-`. */
34
+ export function normalMachineName(name) {
35
+ return String(name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
36
+ }