@volter/supercode-teams 0.2.178 → 0.3.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 +2 -1
- package/bin/fleet.mjs +4 -1
- package/bin/teams.mjs +5 -5
- package/bin/workspace/agent-session.mjs +20 -0
- package/bin/workspace/args.mjs +3 -3
- package/bin/workspace.mjs +170 -4
- package/board-process-guard.mjs +52 -0
- package/client.mjs +3 -29
- package/connector/agent-closures.mjs +28 -0
- package/connector/index.d.ts +1 -0
- package/connector/index.mjs +76 -5
- package/context.mjs +1 -34
- package/event-client.mjs +1 -0
- package/machine-door.mjs +1 -36
- package/machine.mjs +225 -15
- package/native-codex-pane.mjs +3 -2
- package/package.json +3 -2
- package/server/harness-routes.mjs +8 -3
- package/server/index.mjs +1 -2
- package/server/store-core.mjs +15 -2
- package/session-notice-target.mjs +39 -0
- package/worker/index.mjs +1 -5
- package/workspace-client.d.ts +4 -0
- package/workspace-client.mjs +2 -185
- package/lines.mjs +0 -19
package/connector/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { randomBytes, createHash } from 'node:crypto';
|
|
|
4
4
|
import { canonicalJson, carryDigest } from '../contracts/index.mjs';
|
|
5
5
|
import { EffectConsumer } from './effects.mjs';
|
|
6
6
|
import { NativeEffects } from './native-effects.mjs';
|
|
7
|
+
import { consumeAgentClosures } from './agent-closures.mjs';
|
|
7
8
|
|
|
8
9
|
const SYNC_PROBLEM_LIMIT=20;
|
|
9
10
|
// Live follows are bounded. The harness SDK lets at most 64 subscription opens await replies at once (sdk/typescript
|
|
@@ -172,7 +173,7 @@ export class TeamsConnector {
|
|
|
172
173
|
this.state.sequence += 1;
|
|
173
174
|
const reply = await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
|
|
174
175
|
incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
|
|
175
|
-
bindings: [...this.nativeEffects.published(), ...await bindings()],
|
|
176
|
+
bindings: [...(this.activityBindings?.values()??[]), ...this.nativeEffects.published(), ...await bindings()],
|
|
176
177
|
roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
|
|
177
178
|
...(this.hostKeyId ? { host_key_id: this.hostKeyId } : {}),
|
|
178
179
|
// What each synced session's harness is doing (its turn: working, idle, …), from the last discovery.
|
|
@@ -189,9 +190,19 @@ export class TeamsConnector {
|
|
|
189
190
|
beating = next.catch(() => {});
|
|
190
191
|
return next;
|
|
191
192
|
};
|
|
193
|
+
this.publishActivityHeartbeat=()=>void heartbeat().catch(error=>{this.lastError=error;});
|
|
194
|
+
this.activityBindings=new Map();
|
|
195
|
+
this.activityEvent=event=>{
|
|
196
|
+
if(event.subscription!==this.activitySubscription)return;
|
|
197
|
+
for(const activity of event.activities??[])this.recordActivityBinding(activity);
|
|
198
|
+
this.publishActivityHeartbeat();
|
|
199
|
+
};
|
|
200
|
+
this.core.on?.('sessionActivityEvent',this.activityEvent);
|
|
192
201
|
const pass = async () => {
|
|
193
202
|
const configuration = await this.configuration();
|
|
194
203
|
await this.syncConfiguredSessions(configuration, this.localRoots);
|
|
204
|
+
await this.publishSessionProfiles();
|
|
205
|
+
await consumeAgentClosures(this);
|
|
195
206
|
await heartbeat();
|
|
196
207
|
await this.pollEffects();
|
|
197
208
|
};
|
|
@@ -252,7 +263,7 @@ export class TeamsConnector {
|
|
|
252
263
|
// A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
|
|
253
264
|
// reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
|
|
254
265
|
// machine; rules over the same root and harnesses share the scan.
|
|
255
|
-
const discoveries=new Map(),followCandidates=[],discovered=new Set(),activity=[];
|
|
266
|
+
const discoveries=new Map(),followCandidates=[],discovered=new Set(),activity=[],activityLocators=new Map();
|
|
256
267
|
this.liveFiles??=new Map();
|
|
257
268
|
this.turnStates??=new Map();
|
|
258
269
|
for(const rule of activeRules.values()){
|
|
@@ -275,6 +286,8 @@ export class TeamsConnector {
|
|
|
275
286
|
const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
|
|
276
287
|
if(!locator||!nativeSessionId||!harness)continue;
|
|
277
288
|
discovered.add(`${harness}:${nativeSessionId}`);
|
|
289
|
+
activityLocators.set(`${harness}:${nativeSessionId}`,locator);
|
|
290
|
+
if(descriptor.activity)this.recordActivityBinding({...descriptor.activity,harness,session_id:nativeSessionId});
|
|
278
291
|
// A session's turn state as its harness reports it, and since when this connector has seen it so.
|
|
279
292
|
const turn=descriptor.activity?.turn;
|
|
280
293
|
if(typeof turn==='string'&&turn&&activity.length<4096){const key=`${harness}:${nativeSessionId}`,held=this.turnStates.get(key),since=held?.turn===turn?held.since:this.clock.nowMs();this.turnStates.set(key,{turn,since});activity.push({harness,native_id:nativeSessionId,turn,since});}
|
|
@@ -303,11 +316,31 @@ export class TeamsConnector {
|
|
|
303
316
|
}
|
|
304
317
|
for(const [key,options] of this.liveFiles)if(!discovered.has(key)||!activeRules.has(options.ruleId))this.liveFiles.delete(key);
|
|
305
318
|
this.nativeEffects.endDiscovery();
|
|
319
|
+
await this.followSessionActivity([...activityLocators.values()]);
|
|
306
320
|
for(const key of this.turnStates.keys())if(!discovered.has(key))this.turnStates.delete(key);
|
|
307
321
|
this.reportedActivity=activity;
|
|
308
322
|
this.pruneNativeCache(discovered);
|
|
309
323
|
this.rotateFollowers(followCandidates);
|
|
310
324
|
}
|
|
325
|
+
recordActivityBinding(activity){
|
|
326
|
+
if(!activity?.harness||!activity.session_id)return;
|
|
327
|
+
const key=`${activity.harness}:${activity.session_id}`,id=`activity_${hash(`${this.machineId}:${this.state.incarnation_id}:${key}`).slice(0,32)}`;
|
|
328
|
+
const previous=this.activityBindings?.get(id),presence=activity.presence;
|
|
329
|
+
const state=presence==='running'?'running':presence==='persisted'&&['running','stopped'].includes(previous?.state)?'stopped':'unknown';
|
|
330
|
+
this.activityBindings?.set(id,{id,machine_id:this.machineId,incarnation_id:this.state.incarnation_id,harness:activity.harness,native_session_id:activity.session_id,native_runtime_id:activity.session_id,state,observed_at:this.clock.nowMs(),native_capabilities:[],evidence:activity.evidence??null,turn:activity.turn??'unknown'});
|
|
331
|
+
}
|
|
332
|
+
async followSessionActivity(locators){
|
|
333
|
+
if(typeof this.core.subscribeSessionActivity!=='function'||typeof this.core.unsubscribeSessionActivity!=='function')return;
|
|
334
|
+
if(!locators.length)return;
|
|
335
|
+
if(locators.length>2048)locators=locators.filter(locator=>this.activityBindings?.get(`activity_${hash(`${this.machineId}:${this.state.incarnation_id}:${locator.harness}:${locator.session_id}`).slice(0,32)}`)?.state==='running').slice(0,2048);
|
|
336
|
+
if(!locators.length)return;
|
|
337
|
+
const fingerprint=locators.map(item=>`${item.harness}:${item.session_id}`).sort().join('\n');
|
|
338
|
+
if(fingerprint===this.activityFingerprint)return;
|
|
339
|
+
const opened=await this.core.subscribeSessionActivity(locators),previous=this.activitySubscription;
|
|
340
|
+
this.activitySubscription=opened.subscription;this.activityFingerprint=fingerprint;
|
|
341
|
+
for(const activity of opened.initial??[])this.recordActivityBinding(activity);
|
|
342
|
+
if(previous)await this.core.unsubscribeSessionActivity(previous);
|
|
343
|
+
}
|
|
311
344
|
captureViewedFiles(){
|
|
312
345
|
if(this.abort.signal.aborted)return;
|
|
313
346
|
this.liveFileCaptures??=new Set();this.liveFileStamps??=new Map();
|
|
@@ -619,6 +652,29 @@ export class TeamsConnector {
|
|
|
619
652
|
const large=(artifactBefore?.size??0)>=LARGE_CAPTURE_BYTES;
|
|
620
653
|
const loaded=preloaded?preloaded.loaded:await this.core.session(locator).load(captureRead(harness,large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:{})),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
|
|
621
654
|
const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
|
|
655
|
+
if(Array.isArray(loaded.resolved_profile_history)&&loaded.resolved_profile_history.length) {
|
|
656
|
+
const seen=this.state.profile_history_seen??={},known=new Set(seen[key]??[]);
|
|
657
|
+
const cutoff=backfillPolicy.mode==='none'?rule.capture_after:backfillPolicy.mode==='since'?backfillPolicy.since:null;
|
|
658
|
+
for(const observation of loaded.resolved_profile_history) {
|
|
659
|
+
if(backfillPolicy.mode==='none'&&!Number.isSafeInteger(cutoff))continue;
|
|
660
|
+
const at=Date.parse(observation.at??'');
|
|
661
|
+
if(Number.isSafeInteger(cutoff)&&(!Number.isSafeInteger(at)||at<cutoff))continue;
|
|
662
|
+
const id=`profile_${hash(canonicalJson([this.sourceId,key,observation.native_key,observation.at,observation.profile])).slice(0,32)}`;
|
|
663
|
+
if(known.has(id))continue;
|
|
664
|
+
known.add(id);
|
|
665
|
+
(this.state.profile_outbox??=[]).push({session:key,profile:observation.profile,observed_at:Number.isSafeInteger(at)?at:this.clock.nowMs(),observation_id:id});
|
|
666
|
+
}
|
|
667
|
+
seen[key]=[...known];this.save();
|
|
668
|
+
}
|
|
669
|
+
if(loaded.resolved_profile){
|
|
670
|
+
const observed=this.state.session_profiles??={},profile=loaded.resolved_profile,digest=hash(canonicalJson(profile));
|
|
671
|
+
if(observed[key]?.digest!==digest){
|
|
672
|
+
observed[key]={digest};
|
|
673
|
+
const latest=(this.state.profile_outbox??[]).filter(item=>item.session===key).at(-1);
|
|
674
|
+
if(!latest||canonicalJson(latest.profile)!==canonicalJson(profile))(this.state.profile_outbox??=[]).push({session:key,profile,observed_at:this.clock.nowMs(),observation_id:`profile_${randomBytes(16).toString('hex')}`});
|
|
675
|
+
this.save();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
622
678
|
let messages=(loaded.messages??loaded.turns??[]).map((entry,index)=>{const value=entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key)));return{native_key:entry.id??entry.native_key??String(index),recorded_at:recordedAt(entry),value};});
|
|
623
679
|
this.labelTurns(key,loaded.messages??loaded.turns??[],messages);
|
|
624
680
|
if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
|
|
@@ -626,7 +682,7 @@ export class TeamsConnector {
|
|
|
626
682
|
const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
|
|
627
683
|
const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
|
|
628
684
|
const unchanged=previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index]);
|
|
629
|
-
if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
685
|
+
if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
|
|
630
686
|
let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
|
|
631
687
|
// A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
|
|
632
688
|
// whatever a session holds when first seen: a session begun after the rule is published from its first message.
|
|
@@ -717,7 +773,22 @@ export class TeamsConnector {
|
|
|
717
773
|
}
|
|
718
774
|
const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',title:descriptor?.title??null,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
|
|
719
775
|
const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
|
|
720
|
-
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();
|
|
776
|
+
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
|
|
777
|
+
}
|
|
778
|
+
async publishSessionProfile(session){
|
|
779
|
+
const observation=this.state.profile_outbox?.find(item=>item.session===session);if(!observation)return;
|
|
780
|
+
try{
|
|
781
|
+
await this.request('POST',`/machines/${this.machineId}/session-profiles`,{session,profile:observation.profile,observed_at:observation.observed_at,observation_id:observation.observation_id});
|
|
782
|
+
this.state.profile_outbox=this.state.profile_outbox.filter(item=>item!==observation);this.save();
|
|
783
|
+
}catch(error){
|
|
784
|
+
// A launch can be captured before its identity declaration reaches Teams. Retain the observation for retry.
|
|
785
|
+
observation.error=errorSummary(error,this.clock.nowMs());observation.attempted_at=this.clock.nowMs();this.save();
|
|
786
|
+
if(error.status!==409&&error.status!==404)throw error;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async publishSessionProfiles(){
|
|
790
|
+
const first=new Map();for(const item of this.state.profile_outbox??[])if(!first.has(item.session))first.set(item.session,item);
|
|
791
|
+
for(const item of [...first.values()].sort((a,b)=>(a.attempted_at??0)-(b.attempted_at??0)).slice(0,100))await this.publishSessionProfile(item.session);
|
|
721
792
|
}
|
|
722
793
|
flush() {
|
|
723
794
|
const work=this.work.then(()=>this.flushSerial());
|
|
@@ -826,5 +897,5 @@ export class TeamsConnector {
|
|
|
826
897
|
}
|
|
827
898
|
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);this.refitParts(item);try{receipt=await this.sendCapture(item);}catch(error){if(error.code==='storage_pressure'&&this.deferNativeUnderPressure(item))continue;if(error.status===409&&error.code==='carry_mismatch'){const key=`${item.begin.harness}:${item.begin.native_session_id}`;this.lastError=error;process.stderr.write(`supercode teams: the server does not hold what a capture of ${item.begin.harness} session ${item.begin.native_session_id} carried; it goes whole next time\n`);this.state.outbox.shift();if(this.state.origins[key])this.state.origins[key]={...this.state.origins[key],observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}if(error.status===400&&error.code==='invalid_request'||error.status===413){this.lastError=error;this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id??null},error);(this.droppedCaptures??=new Set()).add(`${item.begin.harness}:${item.begin.native_session_id}`);process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused${error.status===413?' (one message is larger than a request may be)':' as malformed'}: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(!item.begin.carry)delete next.carry_refused;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();// A queued capture is often flushed by another session's capture: its own session's problem ends with it.
|
|
828
899
|
this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
829
|
-
stop(){try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.liveCaptureTimer)clearInterval(this.liveCaptureTimer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
900
|
+
stop(){this.core.off?.('sessionActivityEvent',this.activityEvent);if(this.activitySubscription)void this.core.unsubscribeSessionActivity(this.activitySubscription).catch(()=>{});try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.liveCaptureTimer)clearInterval(this.liveCaptureTimer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
830
901
|
}
|
package/context.mjs
CHANGED
|
@@ -1,34 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
4
|
-
import { TeamsClient } from './workspace-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;}
|
|
1
|
+
export * from "@volter/teams/context";
|
package/event-client.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { eventClient } from "@volter/teams/event-client";
|
package/machine-door.mjs
CHANGED
|
@@ -1,36 +1 @@
|
|
|
1
|
-
|
|
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 '@volter/teams/home';
|
|
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
|
-
}
|
|
1
|
+
export { openMachine, normalMachineName } from "@volter/teams/machine-door";
|