@volter/supercode-teams 0.2.179 → 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 +205 -10
- 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";
|
package/machine.mjs
CHANGED
|
@@ -29,8 +29,12 @@ import { EventEmitter } from 'node:events';
|
|
|
29
29
|
import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
|
|
30
30
|
import { join, resolve, dirname } from 'node:path';
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { boardGuardArguments } from './board-process-guard.mjs';
|
|
32
33
|
import { homedir, hostname } from 'node:os';
|
|
33
34
|
import { Workspaces, inspectCheckout } from './workspaces.mjs';
|
|
35
|
+
import { WorkspaceContexts, fromContext } from '@volter/teams/context';
|
|
36
|
+
import { TeamsClient } from '@volter/teams/http-client';
|
|
37
|
+
import { supercodeHome } from '@volter/teams/home';
|
|
34
38
|
import { readMailHistory } from './mail-history.mjs';
|
|
35
39
|
import { randomBytes, createHash } from 'node:crypto';
|
|
36
40
|
import { WebSocket } from 'ws';
|
|
@@ -39,7 +43,7 @@ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composer
|
|
|
39
43
|
// than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
|
|
40
44
|
import * as terminal from '@volter/supercode-terminal';
|
|
41
45
|
import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
|
|
42
|
-
import { lineSplitter } from '
|
|
46
|
+
import { lineSplitter } from '@volter/teams/lines';
|
|
43
47
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
44
48
|
import { holds, observesPanes } from '@volter/teams/admission';
|
|
45
49
|
import { MachineCore, MACHINE_VERSION } from '@volter/teams/machine-core';
|
|
@@ -53,6 +57,7 @@ import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from '
|
|
|
53
57
|
import { TerminalMailbox, retainedTerminalMail } from './terminal-mail.mjs';
|
|
54
58
|
import { confirmNativeInitialInput } from './native-initial-input.mjs';
|
|
55
59
|
import { canonicalCodexFolderTrust, codexProgram, codexStartupArguments, codexUpdatePrompt, codexVersion, settleCodexHookReview } from './codex-startup.mjs';
|
|
60
|
+
import { sessionNoticeTarget } from './session-notice-target.mjs';
|
|
56
61
|
|
|
57
62
|
/** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
|
|
58
63
|
const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
|
|
@@ -155,6 +160,29 @@ function paneExit(pane) {
|
|
|
155
160
|
export class TeamsMachine extends MachineCore {
|
|
156
161
|
/** Typed lines by key → the answer their first attempt gives (see #serveTurn). */
|
|
157
162
|
#turnKeys = new Map();
|
|
163
|
+
#sessionBindings = new Map();
|
|
164
|
+
|
|
165
|
+
/** Native process evidence, published by the connector; a missing transport never reports an end. */
|
|
166
|
+
async sessionBindings(machineId,incarnationId) {
|
|
167
|
+
const panes=await this.host.listSessions();
|
|
168
|
+
const live=panes.filter(pane=>!pane.dead&&pane.rootPid);
|
|
169
|
+
const reading=live.length?await this.#serveRequest('harness.v1.sessions.activity_under',{pids:live.map(pane=>pane.rootPid)}):{activities:[]};
|
|
170
|
+
const byPid=new Map((reading.activities??[]).map(item=>[item.pid,item.activity]));
|
|
171
|
+
const current=new Set(panes.filter(pane=>!pane.dead).map(pane=>pane.contextKey));
|
|
172
|
+
for(const pane of live){
|
|
173
|
+
const activity=byPid.get(pane.rootPid);if(!activity?.session_id)continue;
|
|
174
|
+
const key=`${pane.contextKey}:${activity.harness}:${activity.session_id}`;
|
|
175
|
+
this.#sessionBindings.set(key,{id:`pane_${createHash('sha256').update(`${machineId}:${incarnationId}:${key}`).digest('hex').slice(0,32)}`,
|
|
176
|
+
machine_id:machineId,incarnation_id:incarnationId,pane:pane.contextKey,harness:activity.harness,
|
|
177
|
+
native_session_id:activity.session_id,native_runtime_id:activity.session_id,state:activity.presence==='running'?'running':'unknown',
|
|
178
|
+
observed_at:Date.now(),native_capabilities:[],evidence:activity.evidence});
|
|
179
|
+
}
|
|
180
|
+
for(const binding of this.#sessionBindings.values()){
|
|
181
|
+
if(!current.has(binding.pane)){binding.state='stopped';binding.observed_at=Date.now();}
|
|
182
|
+
else if(!live.some(pane=>pane.contextKey===binding.pane&&byPid.get(pane.rootPid)?.session_id===binding.native_session_id)){binding.state='unknown';binding.observed_at=Date.now();}
|
|
183
|
+
}
|
|
184
|
+
return [...this.#sessionBindings.values()];
|
|
185
|
+
}
|
|
158
186
|
|
|
159
187
|
/**
|
|
160
188
|
* `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
|
|
@@ -475,22 +503,87 @@ export class TeamsMachine extends MachineCore {
|
|
|
475
503
|
|
|
476
504
|
// ---- mail ----------------------------------------------------------------
|
|
477
505
|
|
|
478
|
-
#
|
|
506
|
+
#nativeMailReply(request){
|
|
507
|
+
return new Promise((resolve,reject)=>{
|
|
508
|
+
const child=spawn(this.supercodeBin,['message','remote-deliver'],{stdio:['pipe','pipe','ignore'],env:this.env});
|
|
509
|
+
let output='',done=false;
|
|
510
|
+
const finish=(error,result)=>{if(done)return;done=true;clearTimeout(timer);error?reject(error):resolve(result);};
|
|
511
|
+
const timer=setTimeout(()=>{child.kill('SIGTERM');finish(new Error('native mail read timed out'));},10000);
|
|
512
|
+
child.stdout.on('data',chunk=>{output+=chunk;if(output.length>4*1024*1024){child.kill('SIGTERM');finish(new Error('native mail reply exceeded its bound'));}});
|
|
513
|
+
child.once('error',error=>finish(error));
|
|
514
|
+
child.once('close',code=>{
|
|
515
|
+
if(code!==0)return finish(new Error(`native mail read exited ${code}`));
|
|
516
|
+
try{finish(null,JSON.parse(output));}catch{finish(new Error('native mail read returned invalid JSON'));}
|
|
517
|
+
});
|
|
518
|
+
child.stdin.on('error',error=>finish(error));child.stdin.end(JSON.stringify(request));
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#noticeTarget(address){
|
|
523
|
+
return sessionNoticeTarget(address,{machine:hostname().split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-'),
|
|
524
|
+
mail:request=>this.#nativeMailReply(request),
|
|
525
|
+
discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
|
|
526
|
+
recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async #resumeNoticeTarget(address){
|
|
530
|
+
const pending=this.noticeResumes??=new Map();
|
|
531
|
+
if(pending.has(address))return pending.get(address);
|
|
532
|
+
const resume=(async()=>{
|
|
533
|
+
const status=await this.#noticeTarget(address);
|
|
534
|
+
if(status.state==='running')return;
|
|
535
|
+
if(!status.resumable)throw Object.assign(new Error(status.reason??'native session cannot resume'),{noticeCode:'session_resume_unavailable'});
|
|
536
|
+
const code=await new Promise((resolve,reject)=>{
|
|
537
|
+
// Explicit native approval and sandbox flags suppress open's unattended defaults.
|
|
538
|
+
const child=spawn(this.supercodeBin,['open',address,'--detach','--',...status.resume.args],
|
|
539
|
+
{cwd:status.resume.cwd,env:{...this.env,SUPERCODE_POPUPS:'0'},stdio:'ignore'});
|
|
540
|
+
const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},15000);
|
|
541
|
+
child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});
|
|
542
|
+
});
|
|
543
|
+
for(const deadline=Date.now()+10000;;await new Promise(resolve=>setTimeout(resolve,200))){
|
|
544
|
+
const live=await this.#nativeMailReply({op:'list'});
|
|
545
|
+
if(live.code===0&&live.sessions?.some(row=>row.address===address))return;
|
|
546
|
+
if(Date.now()>=deadline)throw Object.assign(new Error(code===0?'native resume started but has no live delivery door':`native resume was not confirmed (exit ${code??'timeout'})`),{noticeCode:'session_resume_pending'});
|
|
547
|
+
}
|
|
548
|
+
})();
|
|
549
|
+
pending.set(address,resume);
|
|
550
|
+
try{return await resume;}finally{pending.delete(address);}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#serveMail(entry, stream, accept, refuse,requestOverride=null) {
|
|
479
554
|
// One message into a session on this machine, delivered by `supercode message
|
|
480
555
|
// remote-deliver` through the same doors a local send uses. The receiver is the
|
|
481
556
|
// target: `mail:*` reaches every session here, `mail:<address>` one of them; a
|
|
482
557
|
// receiver named rather than addressed needs `mail:*`.
|
|
483
558
|
const { admitted, principal } = entry;
|
|
484
|
-
const request = stream.params?.request;
|
|
559
|
+
const request = requestOverride??stream.params?.request;
|
|
485
560
|
if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
|
|
486
561
|
if (request?.op === 'history') return this.#serveMailHistory(entry, request, stream, accept, refuse);
|
|
487
562
|
const to = typeof request?.to === 'string' ? request.to : '*';
|
|
488
563
|
const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
|
|
489
|
-
if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file, list or turn request');
|
|
564
|
+
if (!request || !['send', 'file', 'list','status'].includes(request.op)) return refuse('mail needs a send, file, list, status or turn request');
|
|
490
565
|
if (!holds(admitted, 'mail', target)) {
|
|
491
566
|
this.log.append({ principal, cap: 'mail', target, outcome: 'refused' });
|
|
492
567
|
return refuse(`no mail grant for ${target}`);
|
|
493
568
|
}
|
|
569
|
+
if(request.op==='status'){
|
|
570
|
+
if(!to.startsWith('sc:'))return refuse('status requires one exact native address');
|
|
571
|
+
accept();
|
|
572
|
+
void this.#noticeTarget(to).then(({resume,...native})=>{
|
|
573
|
+
if(!stream.closed){stream.write(JSON.stringify({code:0,native}));stream.close('status');}
|
|
574
|
+
}).catch(()=>{if(!stream.closed){stream.write(JSON.stringify({code:5,native:{state:'unknown',reason:'native_liveness_unavailable',resumable:false}}));stream.close('status');}});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if(request.resume_if_closed===true){
|
|
578
|
+
if(request.op!=='send'||!to.startsWith('sc:'))return refuse('thread resume requires an exact send target');
|
|
579
|
+
accept();
|
|
580
|
+
void this.#resumeNoticeTarget(to).then(()=>{
|
|
581
|
+
if(stream.closed)return;
|
|
582
|
+
if(!holds(entry.admitted,'mail',target)){stream.write(JSON.stringify({code:2,error:'access_revoked',text:'native mail grant expired before delivery'}));stream.close('refused');return;}
|
|
583
|
+
this.#serveMail(entry,stream,()=>{},refuseWith=>{if(!stream.closed){stream.write(JSON.stringify({code:2,text:refuseWith}));stream.close('refused');}},{...request,resume_if_closed:false});
|
|
584
|
+
}).catch(error=>{if(!stream.closed){stream.write(JSON.stringify({code:3,error:error.noticeCode??'session_resume_unavailable',text:error.message}));stream.close('refused');}});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
494
587
|
accept();
|
|
495
588
|
const child = spawn(this.supercodeBin, ['message', 'remote-deliver'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
|
|
496
589
|
let out = '';
|
|
@@ -629,6 +722,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
629
722
|
// A long-lived tmux server can predate a consolidated installation.
|
|
630
723
|
// New panes inherit this daemon's selected tools, not that stale PATH.
|
|
631
724
|
env.PATH ??= this.env.PATH;
|
|
725
|
+
// Its CLI reads this machine's contexts and sockets, not whatever home that server started with.
|
|
726
|
+
if (this.env.SUPERCODE_HOME) env.SUPERCODE_HOME ??= this.env.SUPERCODE_HOME;
|
|
632
727
|
const inherited = env.NODE_OPTIONS ?? this.env.NODE_OPTIONS ?? '';
|
|
633
728
|
// Quoted: NODE_OPTIONS splits on spaces, and a home path may hold one (Windows user folders often do).
|
|
634
729
|
env.NODE_OPTIONS = `${inherited} --require ${JSON.stringify(PORTHOLE_HOOK)}`.trim();
|
|
@@ -640,7 +735,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
640
735
|
// Native session hooks carry the launcher's identity across Codex's shared
|
|
641
736
|
// app-server process boundary. Process ancestry alone cannot recover it.
|
|
642
737
|
#nativeQuestionCommand(params, pane, by, nativeInput) {
|
|
643
|
-
|
|
738
|
+
if (params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1' && process.platform === 'win32') throw new Error('board process guard is unavailable on Windows; no unguarded session was launched');
|
|
739
|
+
const args = boardGuardArguments(params.kind,params.arguments ?? [],{cwd:params.cwd,enabled:params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1'});
|
|
644
740
|
const name = String(params.program ?? '').split(/[\\/]/).pop();
|
|
645
741
|
if (process.platform === 'win32' || !((params.kind === 'codex' && name === 'codex') ||
|
|
646
742
|
(params.kind === 'claude-code' && name === 'claude'))) return launchCommand(params.program, args);
|
|
@@ -1066,12 +1162,14 @@ export class TeamsMachine extends MachineCore {
|
|
|
1066
1162
|
v: MACHINE_VERSION,
|
|
1067
1163
|
id: this.id,
|
|
1068
1164
|
hostname: hostname(),
|
|
1165
|
+
name: this.machineEvents?.machine ?? hostname(),
|
|
1069
1166
|
socket: this.socketPath,
|
|
1070
1167
|
server: this.link?.server ?? null,
|
|
1071
1168
|
machineId: this.link?.machineId ?? null,
|
|
1072
1169
|
node: { pid: process.pid, version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor },
|
|
1073
1170
|
egress: this.env.SUPERCODE_TEAMS_EGRESS ?? 'unrestricted',
|
|
1074
1171
|
execution: executionCapabilities(this.env),
|
|
1172
|
+
boardProcessPolicy: process.platform === 'win32' ? null : { version: 1, harnesses: ['claude-code','codex'], boundary: 'native-PreToolUse' },
|
|
1075
1173
|
runSpec: this.env.SUPERCODE_TEAMS_RUN_SPEC_SHA256 ?? null,
|
|
1076
1174
|
at: Date.now(),
|
|
1077
1175
|
};
|
|
@@ -1430,6 +1528,99 @@ export class TeamsMachine extends MachineCore {
|
|
|
1430
1528
|
if (!this.machineEvents || params.team_id!==this.machineEvents.teamId) throw new Error('this connector is not serving that team');
|
|
1431
1529
|
return method==='machine.events.events'?this.machineEvents.snapshot():this.machineEvents.subscribe(params.address,method.endsWith('.unsubscribe'));
|
|
1432
1530
|
}
|
|
1531
|
+
case 'harness.v1.agents.bindSession': {
|
|
1532
|
+
if (!operator) throw Object.assign(new Error('binding an agent session requires operator authority'),{code:403});
|
|
1533
|
+
const {session,context}=params;
|
|
1534
|
+
if(typeof session!=='string'||!/^([a-z][a-z0-9-]*):(.+)$/.test(session)||typeof context!=='string')throw Object.assign(new Error('name a native session and its agent reader context'),{code:400});
|
|
1535
|
+
const selected=fromContext({supercodeHome:supercodeHome(this.env),name:context});
|
|
1536
|
+
const identity=await selected.client.team(selected.context.team_id).sessions.get(session).identity({signal:AbortSignal.timeout(15000)});
|
|
1537
|
+
if(!identity.data.agent||identity.data.agent.id!==selected.context.principal_id)throw Object.assign(new Error('session does not belong to the context agent'),{code:403});
|
|
1538
|
+
const file=join(this.home,'agent-session-contexts.json'),record=readJson(file,{version:1,sessions:{}});
|
|
1539
|
+
record.sessions[session]={context,agent:identity.data.agent.id,org:selected.context.team_id,assignments:params.assignments??[],intended:params.intended??null};
|
|
1540
|
+
const temporary=`${file}.${randomBytes(12).toString('hex')}.tmp`;
|
|
1541
|
+
writeFileSync(temporary,JSON.stringify(record)+'\n',{mode:0o600,flag:'wx'});renameSync(temporary,file);
|
|
1542
|
+
act('agents.bindSession',session);return record.sessions[session];
|
|
1543
|
+
}
|
|
1544
|
+
case 'harness.v1.agents.session': {
|
|
1545
|
+
if(!operator)throw Object.assign(new Error('local agent session lookup requires operator authority'),{code:403});
|
|
1546
|
+
if(typeof params.session!=='string')throw Object.assign(new Error('name a native session'),{code:400});
|
|
1547
|
+
return readJson(join(this.home,'agent-session-contexts.json'),{sessions:{}}).sessions[params.session]??null;
|
|
1548
|
+
}
|
|
1549
|
+
case 'harness.v1.agents.closureReceipt': {
|
|
1550
|
+
if(!operator)throw Object.assign(new Error('native closure receipts require operator authority'),{code:403});
|
|
1551
|
+
if(typeof params.closure!=='string'||!/^close_[0-9a-f]{32}$/.test(params.closure))throw Object.assign(new Error('name a retained closure obligation'),{code:400});
|
|
1552
|
+
const receipt=readJson(join(this.home,'agent-closure-receipts.json'),{closures:{}}).closures[params.closure];
|
|
1553
|
+
return {attempted:!!receipt,pending:!!this.agentClosuresRunning?.has(params.closure),result:receipt?.result??null};
|
|
1554
|
+
}
|
|
1555
|
+
case 'harness.v1.agents.closeSession': {
|
|
1556
|
+
if(!operator||!this.link)throw Object.assign(new Error('native agent closure requires the connected machine operator'),{code:403});
|
|
1557
|
+
if(typeof params.closure!=='string'||!/^close_[0-9a-f]{32}$/.test(params.closure))throw Object.assign(new Error('name a retained closure obligation'),{code:400});
|
|
1558
|
+
const {server,teamId,machineId,credential}=this.link;
|
|
1559
|
+
const response=await fetch(`${server}/api/v1/teams/${teamId}/machines/${machineId}/agent-closures/${params.closure}`,{headers:{Authorization:`Bearer ${credential}`},redirect:'manual',signal:AbortSignal.timeout(15000)});
|
|
1560
|
+
if(!response.ok)throw Object.assign(new Error(`agent closure authorization returned HTTP ${response.status}`),{code:403});
|
|
1561
|
+
const closure=(await response.json()).data;
|
|
1562
|
+
if(closure.state!=='claimed'||closure.machine_id!==machineId||closure.team_id!==teamId)throw Object.assign(new Error('the machine has no claimed closure on that session'),{code:403});
|
|
1563
|
+
const session=`${closure.harness}:${closure.native_id}`,binding=readJson(join(this.home,'agent-session-contexts.json'),{sessions:{}}).sessions[session];
|
|
1564
|
+
if(binding&&binding.agent!==closure.agent_id)throw Object.assign(new Error('native session identity changed'),{code:409});
|
|
1565
|
+
const file=join(this.home,'agent-closure-receipts.json'),receipts=readJson(file,{version:1,closures:{}});
|
|
1566
|
+
if(receipts.closures[closure.id])return receipts.closures[closure.id].result??{state:'unknown',reason:'native closure attempt has no retained receipt'};
|
|
1567
|
+
receipts.closures[closure.id]={attempted_at:Date.now()};writeJson(file,receipts);
|
|
1568
|
+
(this.agentClosuresRunning??=new Set()).add(closure.id);
|
|
1569
|
+
let result;
|
|
1570
|
+
try{
|
|
1571
|
+
const found=await this.#serveRequest('harness.v1.sessions.discover',{query:closure.native_id,harnesses:[closure.harness],limit:200});
|
|
1572
|
+
const descriptor=found.sessions?.find(row=>row.locator?.session_id===closure.native_id&&row.locator?.harness===closure.harness);
|
|
1573
|
+
if(descriptor?.live_endpoint?.startsWith('supercode-live://')){
|
|
1574
|
+
const runtime=await this.#serveRequest('harness.v1.runtimes.attach_existing',{harness:closure.harness,runtime_id:closure.native_id,cwd:descriptor.cwd,base_url:descriptor.live_endpoint});
|
|
1575
|
+
const ended=await this.#serveRequest('harness.v1.runtimes.close',{connection:runtime.connection});
|
|
1576
|
+
result=ended?.closed?{state:'closed',reason:'native runtime close receipt'}:{state:'unknown',reason:'runtime returned no close receipt'};
|
|
1577
|
+
}else{
|
|
1578
|
+
const machine=hostname().split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-'),address=`sc:${machine}:${closure.harness}:${closure.native_id}`;
|
|
1579
|
+
const code=await new Promise((resolve,reject)=>{
|
|
1580
|
+
const child=spawn(this.supercodeBin,['close',address],{env:{...this.env,SUPERCODE_CALLER:address},stdio:'ignore'});
|
|
1581
|
+
const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},30000);
|
|
1582
|
+
child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',status=>{clearTimeout(timer);resolve(status);});
|
|
1583
|
+
});
|
|
1584
|
+
result=code===0?{state:'closed',reason:'native session close door completed'}:code===null?{state:'unknown',reason:'native close did not complete within 30 seconds'}:{state:'failed',reason:`native close refused (exit ${code})`};
|
|
1585
|
+
}
|
|
1586
|
+
}catch(error){result={state:'failed',reason:String(error.message).slice(0,1024)};}
|
|
1587
|
+
// Other sessions may have finished while this native close was in flight.
|
|
1588
|
+
try {
|
|
1589
|
+
const retained=readJson(file,{version:1,closures:{}});
|
|
1590
|
+
retained.closures[closure.id]={...retained.closures[closure.id],result};writeJson(file,retained);
|
|
1591
|
+
act('agents.closeSession',session,result.state,result.reason);return result;
|
|
1592
|
+
}finally{this.agentClosuresRunning.delete(closure.id);}
|
|
1593
|
+
}
|
|
1594
|
+
case 'harness.v1.agents.context': {
|
|
1595
|
+
if (!operator) throw Object.assign(new Error('installing an agent reader context requires operator authority'), { code: 403 });
|
|
1596
|
+
const {origin,team,agent,credential}=params;
|
|
1597
|
+
if(typeof origin!=='string'||typeof team!=='string'||typeof agent!=='string'||typeof credential?.token!=='string')throw Object.assign(new Error('agent context requires server, Org, identity and delegated credential'),{code:400});
|
|
1598
|
+
const client=new TeamsClient({server:origin,credential:credential.token});
|
|
1599
|
+
const me=await client.me({signal:AbortSignal.timeout(15000)});
|
|
1600
|
+
if(me.principal.id!==agent||me.principal.kind!=='agent'||!me.teams.some(item=>item.team.id===team))throw Object.assign(new Error('delegated credential does not belong to this agent and Org'),{code:403});
|
|
1601
|
+
const key=createHash('sha256').update(`${client.server}/${team}/${agent}`).digest('hex');
|
|
1602
|
+
const name=`agent-${key.slice(0,32)}`,ref=`cred_${key}`,contexts=new WorkspaceContexts(supercodeHome(this.env));
|
|
1603
|
+
contexts.writeCredential(ref,{token:credential.token,kind:'service',expires_at:credential.expires_at,server_id:me.server_id,origin:client.server});
|
|
1604
|
+
contexts.upsert(name,{origin:client.server,server_id:me.server_id,team_id:team,principal_id:agent,credential_ref:ref,label:agent});
|
|
1605
|
+
act('agents.context',agent);
|
|
1606
|
+
return {context:name,agent,org:team,expires_at:credential.expires_at};
|
|
1607
|
+
}
|
|
1608
|
+
case 'harness.v1.agents.mint': {
|
|
1609
|
+
if (!operator) throw Object.assign(new Error('minting local agent ids requires operator authority'), { code: 403 });
|
|
1610
|
+
if (typeof params.key !== 'string' || !params.key || params.key.length > 1024) throw Object.assign(new Error('agent mint requires a stable key'), { code: 400 });
|
|
1611
|
+
const file = join(this.home, 'agent-identities.json');
|
|
1612
|
+
const record = readJson(file, { version: 1, ids: {} });
|
|
1613
|
+
const key = createHash('sha256').update(params.key).digest('hex');
|
|
1614
|
+
if (!record.ids[key]) {
|
|
1615
|
+
record.ids[key] = { id: `agent_${randomBytes(16).toString('hex')}`, minted_at: Date.now() };
|
|
1616
|
+
const temporary = `${file}.${randomBytes(12).toString('hex')}.tmp`;
|
|
1617
|
+
writeFileSync(temporary, JSON.stringify(record)+'\n', { mode: 0o600, flag: 'wx' });
|
|
1618
|
+
renameSync(temporary,file);
|
|
1619
|
+
}
|
|
1620
|
+
act('agents.mint', key);
|
|
1621
|
+
return { ...record.ids[key], synced: false };
|
|
1622
|
+
}
|
|
1623
|
+
case 'harness.v1.machine.describe':
|
|
1433
1624
|
case 'describe':
|
|
1434
1625
|
return this.describe();
|
|
1435
1626
|
case 'panes.present': {
|
|
@@ -1444,6 +1635,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1444
1635
|
return { pane: session.contextKey, idleComposer: composer != null && !/\besc to (?:interrupt|cancel)\b/iu.test(screen) };
|
|
1445
1636
|
}));
|
|
1446
1637
|
}
|
|
1638
|
+
case 'harness.v1.panes.list':
|
|
1447
1639
|
case 'panes.list': {
|
|
1448
1640
|
// A participant lists the panes its grants let it observe; view on the whole
|
|
1449
1641
|
// machine (or operator) lists them all.
|
|
@@ -1560,12 +1752,14 @@ export class TeamsMachine extends MachineCore {
|
|
|
1560
1752
|
if (params.notify) this.#noticeWhenEnded({ key, pane: contextKey, notify: params.notify, label: params.name ?? params.kind ?? params.program });
|
|
1561
1753
|
return { key, pane: contextKey, reused: false, status: 'ready', window };
|
|
1562
1754
|
}
|
|
1755
|
+
case 'harness.v1.launches.get':
|
|
1563
1756
|
case 'launches.get': {
|
|
1564
1757
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1565
1758
|
const found = this.launches.find(params.key);
|
|
1566
1759
|
if (!found) return null;
|
|
1567
1760
|
return this.#launchReceipt(found);
|
|
1568
1761
|
}
|
|
1762
|
+
case 'harness.v1.launches.cancel':
|
|
1569
1763
|
case 'launches.cancel': {
|
|
1570
1764
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1571
1765
|
if (typeof params.key !== 'string' || !params.key.trim()) throw Object.assign(new Error('launch cancellation requires a nonempty key'), { code: 400 });
|
|
@@ -1591,6 +1785,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1591
1785
|
}
|
|
1592
1786
|
return rows;
|
|
1593
1787
|
}
|
|
1788
|
+
case 'harness.v1.panes.close':
|
|
1594
1789
|
case 'panes.close': {
|
|
1595
1790
|
need('control', `pane:${params.pane}`);
|
|
1596
1791
|
for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
|
|
@@ -1814,13 +2009,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
1814
2009
|
act('title', params.target, 'ok', renamed);
|
|
1815
2010
|
return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
|
|
1816
2011
|
}
|
|
1817
|
-
case '
|
|
2012
|
+
case 'harness.v1.workspaces.inspect': case 'harness.v1.workspaces.register': case 'harness.v1.workspaces.unregister': case 'harness.v1.workspaces.list': case 'harness.v1.workspaces.resolve': {
|
|
1818
2013
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1819
2014
|
const registry = new Workspaces(this.home);
|
|
1820
|
-
const result = method === '
|
|
1821
|
-
: method === '
|
|
1822
|
-
: method === '
|
|
1823
|
-
: method === '
|
|
2015
|
+
const result = method === 'harness.v1.workspaces.inspect' ? inspectCheckout(params.path)
|
|
2016
|
+
: method === 'harness.v1.workspaces.register' ? registry.register(params.path, params.repository)
|
|
2017
|
+
: method === 'harness.v1.workspaces.unregister' ? registry.unregister(params.path)
|
|
2018
|
+
: method === 'harness.v1.workspaces.list' ? registry.list()
|
|
1824
2019
|
: registry.resolve(params.repository, params.branch, { solo: params.solo, card: params.card, create: params.create });
|
|
1825
2020
|
act('workspace', '*', 'ok', method);
|
|
1826
2021
|
return result;
|
package/native-codex-pane.mjs
CHANGED
|
@@ -20,7 +20,8 @@ const continuing = seed.arguments.includes('resume') || seed.arguments.includes(
|
|
|
20
20
|
const writableRoots = [];
|
|
21
21
|
for (let index = 0; index < seed.arguments.length; index += 1) {
|
|
22
22
|
const arg = seed.arguments[index];
|
|
23
|
-
if (
|
|
23
|
+
if (arg === '--dangerously-bypass-hook-trust') { config.push(arg); }
|
|
24
|
+
else if (continuing && ['--dangerously-bypass-approvals-and-sandbox', '--yolo'].includes(arg)) {
|
|
24
25
|
config.push('-c', 'approval_policy="never"', '-c', 'sandbox_mode="danger-full-access"');
|
|
25
26
|
} else if (continuing && arg === '--full-auto') {
|
|
26
27
|
config.push('-c', 'approval_policy="on-request"', '-c', 'sandbox_mode="workspace-write"');
|
|
@@ -41,7 +42,7 @@ for (let index = 0; index < seed.arguments.length; index += 1) {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
if(writableRoots.length)config.push('-c',`sandbox_workspace_write.writable_roots=${JSON.stringify(writableRoots)}`);
|
|
44
|
-
const server = spawn(seed.program, ['app-server', '--listen', endpoint
|
|
45
|
+
const server = spawn(seed.program, [...config, 'app-server', '--listen', endpoint], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
45
46
|
let tui;
|
|
46
47
|
let ended = false;
|
|
47
48
|
let serverError;
|