@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/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
|
};
|
|
@@ -1080,6 +1178,16 @@ export class TeamsMachine extends MachineCore {
|
|
|
1080
1178
|
|
|
1081
1179
|
listPorts() { return listAnnouncedPorts(this.portsDir); }
|
|
1082
1180
|
|
|
1181
|
+
/** The attach clients on each pane's tmux session, by its label; null where panes are not tmux's (a ConPTY host). */
|
|
1182
|
+
#viewerCounts() {
|
|
1183
|
+
if (process.platform === 'win32') return null;
|
|
1184
|
+
const listed = spawnSync('tmux', [...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), 'list-clients', '-F', '#{session_name}'], { timeout: 3_000, encoding: 'utf8' });
|
|
1185
|
+
const counts = new Map();
|
|
1186
|
+
if (listed.status !== 0) return counts; // no server, or no clients: nothing shows any pane
|
|
1187
|
+
for (const name of listed.stdout.split('\n').filter(Boolean)) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
1188
|
+
return counts;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1083
1191
|
/**
|
|
1084
1192
|
* A terminal window on this machine's screen showing the pane, so the people at the machine see what runs on it:
|
|
1085
1193
|
* in whichever capable terminal is installed (popup-terminals.mjs), running an attach client. The pane ending ends
|
|
@@ -1087,7 +1195,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1087
1195
|
*/
|
|
1088
1196
|
#popupWindow(contextKey, session) {
|
|
1089
1197
|
const terminal = popupTerminal(this.env);
|
|
1090
|
-
if (!terminal) return;
|
|
1198
|
+
if (!terminal) return false;
|
|
1091
1199
|
const attach = process.platform === 'win32'
|
|
1092
1200
|
? [process.execPath, fileURLToPath(new URL('./bin/fleet.mjs', import.meta.url)), 'attach', contextKey, '--mode', 'control']
|
|
1093
1201
|
// Presented as `supercode open`'s own client is (crates/cli/src/open.rs `attach`): the terminal sets its own
|
|
@@ -1095,7 +1203,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1095
1203
|
// screen override, so the window's own scrollback, wheel and selection work instead of the wheel arriving
|
|
1096
1204
|
// in the pane as up/down arrows.
|
|
1097
1205
|
: ['/usr/bin/env', 'TERM=screen-256color', spawnSync('which', ['tmux'], { encoding: 'utf8' }).stdout.trim() || 'tmux', ...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), '-T', 'RGB', 'attach-session', '-t', session.label ?? contextKey];
|
|
1098
|
-
openPopup(terminal, attach, { ...this.env, TMUX: '' });
|
|
1206
|
+
return openPopup(terminal, attach, { ...this.env, TMUX: '' });
|
|
1099
1207
|
}
|
|
1100
1208
|
|
|
1101
1209
|
/**
|
|
@@ -1420,6 +1528,99 @@ export class TeamsMachine extends MachineCore {
|
|
|
1420
1528
|
if (!this.machineEvents || params.team_id!==this.machineEvents.teamId) throw new Error('this connector is not serving that team');
|
|
1421
1529
|
return method==='machine.events.events'?this.machineEvents.snapshot():this.machineEvents.subscribe(params.address,method.endsWith('.unsubscribe'));
|
|
1422
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':
|
|
1423
1624
|
case 'describe':
|
|
1424
1625
|
return this.describe();
|
|
1425
1626
|
case 'panes.present': {
|
|
@@ -1434,6 +1635,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1434
1635
|
return { pane: session.contextKey, idleComposer: composer != null && !/\besc to (?:interrupt|cancel)\b/iu.test(screen) };
|
|
1435
1636
|
}));
|
|
1436
1637
|
}
|
|
1638
|
+
case 'harness.v1.panes.list':
|
|
1437
1639
|
case 'panes.list': {
|
|
1438
1640
|
// A participant lists the panes its grants let it observe; view on the whole
|
|
1439
1641
|
// machine (or operator) lists them all.
|
|
@@ -1445,9 +1647,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
1445
1647
|
// hand here: a list that made a caller ask a second door "and is anything
|
|
1446
1648
|
// wrong with these?" would be a list that knows and will not say.
|
|
1447
1649
|
const rows = await this.attentionEvidence();
|
|
1650
|
+
// how many clients each pane has now (a popped window, a `supercode open`, a relay streaming it elsewhere): what a caller must
|
|
1651
|
+
// know before it opens another view of a pane on the same screen
|
|
1652
|
+
const viewers = this.#viewerCounts();
|
|
1448
1653
|
// `title` is what a viewer should show: the override if one is set, else the tmux label.
|
|
1449
1654
|
return rows.filter((row) => row.session.id && shown(row.session.contextKey)).map(({ session: s, attention, rank, notify, evidence }) => ({
|
|
1450
|
-
pane: s.contextKey, dead: Boolean(s.dead), label: s.label, title: titles[`pane:${s.contextKey}`] ?? s.label, cwd: s.cwd, owned: s.owned, activeCommand: s.activeCommand, pid: s.rootPid ?? null, size: s.size, suspended: this.suspended.has(s.contextKey),
|
|
1655
|
+
pane: s.contextKey, dead: Boolean(s.dead), label: s.label, title: titles[`pane:${s.contextKey}`] ?? s.label, cwd: s.cwd, owned: s.owned, activeCommand: s.activeCommand, pid: s.rootPid ?? null, size: s.size, suspended: this.suspended.has(s.contextKey), viewers: viewers ? (viewers.get(s.label) ?? 0) : null,
|
|
1451
1656
|
attention, rank, notify, evidence, input_cleanup: this.inputRepairs[s.contextKey] ? {pending:true,at:this.inputRepairs[s.contextKey].at,reason:this.inputRepairs[s.contextKey].reason} : null,
|
|
1452
1657
|
}));
|
|
1453
1658
|
}
|
|
@@ -1541,16 +1746,20 @@ export class TeamsMachine extends MachineCore {
|
|
|
1541
1746
|
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, ...executable, status, error: exit?.failed ? exit.how : undefined });
|
|
1542
1747
|
return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
|
|
1543
1748
|
}
|
|
1544
|
-
|
|
1749
|
+
// the window this machine's screen shows the pane in, when one opened: the caller is told, so it does not open a
|
|
1750
|
+
// second view of a pane that is already in front of the person at the machine
|
|
1751
|
+
const window = params.popup !== false && this.#popupWindow(contextKey, session);
|
|
1545
1752
|
if (params.notify) this.#noticeWhenEnded({ key, pane: contextKey, notify: params.notify, label: params.name ?? params.kind ?? params.program });
|
|
1546
|
-
return { key, pane: contextKey, reused: false, status: 'ready' };
|
|
1753
|
+
return { key, pane: contextKey, reused: false, status: 'ready', window };
|
|
1547
1754
|
}
|
|
1755
|
+
case 'harness.v1.launches.get':
|
|
1548
1756
|
case 'launches.get': {
|
|
1549
1757
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1550
1758
|
const found = this.launches.find(params.key);
|
|
1551
1759
|
if (!found) return null;
|
|
1552
1760
|
return this.#launchReceipt(found);
|
|
1553
1761
|
}
|
|
1762
|
+
case 'harness.v1.launches.cancel':
|
|
1554
1763
|
case 'launches.cancel': {
|
|
1555
1764
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1556
1765
|
if (typeof params.key !== 'string' || !params.key.trim()) throw Object.assign(new Error('launch cancellation requires a nonempty key'), { code: 400 });
|
|
@@ -1576,6 +1785,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1576
1785
|
}
|
|
1577
1786
|
return rows;
|
|
1578
1787
|
}
|
|
1788
|
+
case 'harness.v1.panes.close':
|
|
1579
1789
|
case 'panes.close': {
|
|
1580
1790
|
need('control', `pane:${params.pane}`);
|
|
1581
1791
|
for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
|
|
@@ -1799,13 +2009,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
1799
2009
|
act('title', params.target, 'ok', renamed);
|
|
1800
2010
|
return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
|
|
1801
2011
|
}
|
|
1802
|
-
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': {
|
|
1803
2013
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1804
2014
|
const registry = new Workspaces(this.home);
|
|
1805
|
-
const result = method === '
|
|
1806
|
-
: method === '
|
|
1807
|
-
: method === '
|
|
1808
|
-
: 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()
|
|
1809
2019
|
: registry.resolve(params.repository, params.branch, { solo: params.solo, card: params.card, create: params.create });
|
|
1810
2020
|
act('workspace', '*', 'ok', method);
|
|
1811
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./machine": "./machine.mjs",
|
|
11
11
|
"./browser-attach": "./browser-attach.mjs",
|
|
12
12
|
"./attention": "./attention.mjs",
|
|
13
|
+
"./event-client": "./event-client.mjs",
|
|
13
14
|
"./bin": "./bin/teams.mjs",
|
|
14
15
|
"./fleet": "./fleet.mjs",
|
|
15
16
|
"./bin/fleet": "./bin/fleet.mjs",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"@volter/supercode-harness-sdk": "^0.3.43",
|
|
63
64
|
"@volter/supercode-terminal": "^0.2.54",
|
|
64
65
|
"@volter/identity": "^0.4.1",
|
|
65
|
-
"@volter/teams": "
|
|
66
|
+
"@volter/teams": "0.2.0",
|
|
66
67
|
"@xterm/addon-serialize": "^0.14.0",
|
|
67
68
|
"@xterm/headless": "^6.0.0",
|
|
68
69
|
"protobufjs": "8.8.0",
|
|
@@ -125,7 +125,7 @@ export const harnessRoutes = Object.freeze({ preAuth, authed, beforeIdempotency,
|
|
|
125
125
|
|
|
126
126
|
async function canonicalSession(api,team,row){const seen=new Set();while(row?.canonical_id){if(seen.has(row.id))fail(409,'conflict','correlation cycle');seen.add(row.id);row=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=?',[team,row.canonical_id]);}return row;}
|
|
127
127
|
async function constituents(api,team,row){const root=await canonicalSession(api,team,row);return api.store.query('WITH RECURSIVE family(id) AS (SELECT ? UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=?) SELECT s.* FROM sessions s JOIN family f ON f.id=s.id WHERE s.team_id=?',[root.id,team,team]);}
|
|
128
|
-
async function canReadMerged(api,auth,row){for(const member of await constituents(api,auth.team_id,row))if(!canRead(auth,member,await grants(api,auth.team_id,member.id)))return false;return true;}
|
|
128
|
+
async function canReadMerged(api,auth,row){if(auth.principal_kind==='agent'){try{await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:row.id,now:api.clock.nowMs()});return true;}catch(error){if([401,403,404,410].includes(error.status))return false;throw error;}}for(const member of await constituents(api,auth.team_id,row))if(!canRead(auth,member,await grants(api,auth.team_id,member.id)))return false;return true;}
|
|
129
129
|
const managerGuard=(auth,sessionId,now,{governance=false}={})=>({
|
|
130
130
|
sql:`SELECT 1 ok FROM sessions s JOIN memberships m ON m.team_id=s.team_id AND m.principal_id=? WHERE s.team_id=? AND s.id=? AND s.deleted_at IS NULL AND m.active=1 AND m.role!='viewer' AND (?=1 AND m.role IN ('owner','admin') OR s.custodian_id=? OR EXISTS (SELECT 1 FROM session_grants g WHERE g.team_id=s.team_id AND g.session_id=s.id AND g.role='manager' AND (g.expires_at IS NULL OR g.expires_at>?) AND (g.subject_kind='principal' AND g.subject_id=? OR g.subject_kind='group' AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.team_id=s.team_id AND gm.group_id=g.subject_id AND gm.principal_id=?))))`,
|
|
131
131
|
params:[auth.principal_id,auth.team_id,sessionId,governance?1:0,auth.principal_id,now,auth.principal_id,auth.principal_id],
|
|
@@ -171,6 +171,7 @@ async function listSessions(api,res,auth,url,requestId,search){
|
|
|
171
171
|
const rows=[...currentRows,...olderRows],currentById=new Map(currentRows.map(row=>[row.id,row]));
|
|
172
172
|
const boundaryNow=api.clock.nowMs();
|
|
173
173
|
if(!membership?.active||credential?.revoked_at!=null||(credential?.expires_at!=null&&credential.expires_at<=boundaryNow))fail(401,'authentication_required','authentication required');
|
|
174
|
+
if(cursorKey&&!cursor)fail(409,'resync_required','cursor expired, mismatched, or authorization changed');
|
|
174
175
|
let tuples;
|
|
175
176
|
if(cursor){
|
|
176
177
|
if(cursor.kind!==(search?'search':'list')||cursor.expires_at<=boundaryNow||cursor.auth_epoch!==team.auth_epoch||canonicalJson(JSON.parse(cursor.query_json))!==canonicalJson(normalizedQuery))fail(409,'resync_required','cursor expired, mismatched, or authorization changed');
|
|
@@ -441,13 +442,17 @@ async function viewBindingRoute(api,req,res,auth,ref,requestId){
|
|
|
441
442
|
const bindings=await list();
|
|
442
443
|
// Native transport destinations are custody information, not an extra transcript-reader capability.
|
|
443
444
|
if(session.custodian_id===auth.principal_id&&!auth.installation_id)for(const binding of bindings){
|
|
444
|
-
const resolution=await sessionDeliveryResolution(api,auth.team_id,{sessionRef:session.id,...binding},true);
|
|
445
|
+
const resolution=await sessionDeliveryResolution(api,auth.team_id,{sessionRef:session.id,...binding},true,auth);
|
|
445
446
|
binding.delivery=resolution.target&&resolution.target.harness!==session.harness
|
|
446
447
|
? {...resolution,state:'unavailable',reason:'harness_mismatch'} : resolution;
|
|
447
448
|
}
|
|
448
449
|
send(res,200,result(api,auth,{session_id:session.id,harness:session.harness,bindings}),requestId);return;
|
|
449
450
|
}
|
|
450
|
-
|
|
451
|
+
// The owner binds, in person or through an app they installed and consented to `sessions.access` (the app acts for its
|
|
452
|
+
// installer); an app installed by anyone else binds nothing of theirs.
|
|
453
|
+
const installer=auth.installation_id&&auth.app_grants==null&&auth.app_actions?.includes('sessions.access')
|
|
454
|
+
?(await api.store.one("SELECT installer_id FROM app_installations WHERE id=? AND team_id=? AND state='active'",[auth.installation_id,auth.team_id]))?.installer_id:null;
|
|
455
|
+
if(session.custodian_id!==auth.principal_id&&session.custodian_id!==installer)fail(403,'forbidden',"only the session's owner binds it to a view");
|
|
451
456
|
const body=await parseJson(req);exact(body,['issuer','room','conversation']);
|
|
452
457
|
if(typeof body.room!=='string'||!body.room||typeof body.conversation!=='string'||!body.conversation)fail(400,'invalid_request','room and conversation are required');
|
|
453
458
|
if(!await installedIssuer(api,body.issuer,'delegation.sessions',auth.team_id))fail(400,'invalid_request','this issuer is not an installed session integration');
|
package/server/index.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The Node host: one process on a data directory (SQLite in a worker thread, the
|
|
2
2
|
// bootstrap credential in a protected file beside it), serving HTTP and the
|
|
3
3
|
// machine doors on one listener. The server itself is core.mjs's.
|
|
4
|
-
import { identityOrganizationsFromEnv } from '@volter/teams/server/identity-orgs';
|
|
5
4
|
import { createServer as createHttpServer } from 'node:http';
|
|
6
5
|
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, renameSync, chmodSync, unlinkSync, existsSync } from 'node:fs';
|
|
7
6
|
import { join } from 'node:path';
|
|
@@ -21,7 +20,7 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
|
|
|
21
20
|
const loopback=['127.0.0.1','::1','localhost'].includes(listen.host??'127.0.0.1');
|
|
22
21
|
publicOrigin=normalizePublicOrigin(publicOrigin);
|
|
23
22
|
if(!loopback&&!publicOrigin?.startsWith('https:'))throw new TypeError('non-loopback Teams servers require an HTTPS publicOrigin');
|
|
24
|
-
const api=createTeamsApi({clock,ids,publicOrigin,
|
|
23
|
+
const api=createTeamsApi({clock,ids,publicOrigin,uploadLimitBytes});
|
|
25
24
|
// The harness's own routes on this server (harness-routes.mjs); the handler owns the neutral ones.
|
|
26
25
|
api.attach({harnessRoutes});
|
|
27
26
|
const events=api.events;
|
package/server/store-core.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { ingestOtlpBatch, correlateSessions } from './arc2-transactions.mjs';
|
|
|
7
7
|
import { claimEffect, getEffect, listEffects, reportEffect } from './effects-transactions.mjs';
|
|
8
8
|
import { applyCaptureMessages } from './capture-transactions.mjs';
|
|
9
9
|
import { MIGRATIONS, MESSAGE_AT } from '@volter/teams/server/migrations';
|
|
10
|
+
import { AGENT_ACTIVE, AGENT_DESCENDANTS } from '@volter/teams/server/agent-authority';
|
|
10
11
|
import { APP_LINEAGE_REVOKED, appGrantAllows } from '@volter/teams/server/auth';
|
|
11
12
|
|
|
12
13
|
/** Bring a database to the newest schema. `version()`/`setVersion(n)` name where the host records it. */
|
|
@@ -27,7 +28,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
27
28
|
const {teamId,principalId,credentialId,ref,now,bound}=args;
|
|
28
29
|
// `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
|
|
29
30
|
// session its owner bound to that Room and Conversation; it holds no credential and is no member.
|
|
30
|
-
let membership=null,appFence=null,grantSource=null;
|
|
31
|
+
let membership=null,appFence=null,grantSource=null,agentGrantSource=null,agentReader=false;
|
|
31
32
|
if((args.delegationDepth??0)>16)domainError(401,'authentication_required','delegation lineage is too deep');
|
|
32
33
|
if(bound){
|
|
33
34
|
if(!get(`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE i.team_id=? AND a.issuer=? AND i.id=? AND i.created_at=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value='delegation.sessions')`,[teamId,bound.issuer,bound.installationId,bound.installedAt]))domainError(401,'authentication_required','integration was uninstalled');
|
|
@@ -45,6 +46,12 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
45
46
|
if(appFence!==null)grantSource=get('SELECT id,principal_id FROM credentials WHERE id=?',[app.subject_credential_id??app.parent_id]);
|
|
46
47
|
}
|
|
47
48
|
if(!credential||!membership)domainError(401,'authentication_required','authentication required');
|
|
49
|
+
const principal=get('SELECT kind FROM principals WHERE id=?',[principalId]);
|
|
50
|
+
if(principal?.kind==='agent'){
|
|
51
|
+
agentGrantSource=get('SELECT c.id,c.principal_id FROM agent_credentials d JOIN credentials c ON c.id=d.parent_credential_id WHERE d.credential_id=?',[credentialId]);
|
|
52
|
+
if(!get(AGENT_ACTIVE,[teamId,principalId])||get(APP_LINEAGE_REVOKED,[credentialId,now]))domainError(401,'authentication_required','agent delegation revoked');
|
|
53
|
+
agentReader=true;
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
if(typeof ref!=='string'||ref.length<8)domainError(404,'not_found','not found');
|
|
50
57
|
const groups=all('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,principalId]).map(row=>row.group_id);
|
|
@@ -69,7 +76,13 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
69
76
|
if(!grantSource)return false;
|
|
70
77
|
try{authorizedSession({teamId,principalId:grantSource.principal_id,credentialId:grantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}
|
|
71
78
|
}
|
|
72
|
-
if(
|
|
79
|
+
if(agentGrantSource){
|
|
80
|
+
try{authorizedSession({teamId,principalId:agentGrantSource.principal_id,credentialId:agentGrantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}
|
|
81
|
+
}
|
|
82
|
+
if(agentReader&&get(`WITH RECURSIVE owners(id) AS (${AGENT_DESCENDANTS}), family(id) AS (SELECT s.session_id FROM agent_sessions s JOIN owners o ON s.agent_id=o.id WHERE s.team_id=? UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=?) SELECT 1 FROM family WHERE id=?`,[teamId,principalId,teamId,teamId,session.id]))return true;
|
|
83
|
+
// An agent's standing delegation covers its own family and descendants. Team visibility
|
|
84
|
+
// alone does not open a parent's or sibling's transcript; those require an explicit grant.
|
|
85
|
+
if(!agentReader&&(session.visibility==='team'||session.custodian_id===principalId))return true;
|
|
73
86
|
const grants=all('SELECT subject_kind,subject_id,expires_at FROM session_grants WHERE team_id=? AND session_id=?',[teamId,session.id]);
|
|
74
87
|
return grants.some(grant=>(grant.subject_kind==='principal'&&grant.subject_id===principalId||grant.subject_kind==='group'&&groups.includes(grant.subject_id))&&(grant.expires_at==null||grant.expires_at>now));
|
|
75
88
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A Room reply may resume its existing thread. Configuration is read through the harness's own door.
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function codexNoticeResumeArguments(config){
|
|
5
|
+
if(config?.harness!=='codex'||typeof config.cwd!=='string'||!isAbsolute(config.cwd))return null;
|
|
6
|
+
if(!['untrusted','on-failure','on-request','never'].includes(config.approval_policy))return null;
|
|
7
|
+
const policy=config.sandbox_policy;
|
|
8
|
+
if(!policy||typeof policy!=='object'||Array.isArray(policy)||!['read-only','workspace-write','danger-full-access'].includes(policy.type))return null;
|
|
9
|
+
const keys=['type',...(policy.type==='workspace-write'?['writable_roots','network_access','exclude_tmpdir_env_var','exclude_slash_tmp']:[])];
|
|
10
|
+
if(Object.keys(policy).some(key=>!keys.includes(key)))return null;
|
|
11
|
+
if(policy.writable_roots!==undefined&&(!Array.isArray(policy.writable_roots)||!policy.writable_roots.every(root=>typeof root==='string'&&isAbsolute(root))))return null;
|
|
12
|
+
for(const key of ['network_access','exclude_tmpdir_env_var','exclude_slash_tmp'])if(policy[key]!==undefined&&typeof policy[key]!=='boolean')return null;
|
|
13
|
+
const args=['--ask-for-approval',config.approval_policy,'--sandbox',policy.type];
|
|
14
|
+
if(policy.type==='workspace-write'){
|
|
15
|
+
args.push('-c',`sandbox_workspace_write.writable_roots=${JSON.stringify(policy.writable_roots??[])}`);
|
|
16
|
+
for(const key of ['network_access','exclude_tmpdir_env_var','exclude_slash_tmp'])args.push('-c',`sandbox_workspace_write.${key}=${policy[key]??(key!=='network_access')}`);
|
|
17
|
+
}
|
|
18
|
+
if(typeof config.model==='string'&&config.model)args.push('--model',config.model);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function sessionNoticeTarget(address,{machine,mail,discover,recordedConfig}){
|
|
23
|
+
const match=/^sc:([^:]+):([^:]+):([^:\s]+)$/.exec(address??'');
|
|
24
|
+
if(!match||match[1]!==machine)return {state:'unavailable',reason:'native_target_not_local',resumable:false};
|
|
25
|
+
const [, ,harness,id]=match;
|
|
26
|
+
const live=await mail({op:'list'});
|
|
27
|
+
if(live.code!==0||!Array.isArray(live.sessions))throw new Error('native liveness door returned no session catalogue');
|
|
28
|
+
if(live.sessions.some(row=>row.address===address))return {state:'running',reason:null,resumable:false};
|
|
29
|
+
const page=await discover({query:id,harnesses:[harness],limit:200});
|
|
30
|
+
const row=page.sessions?.find(value=>value.locator?.session_id===id&&value.locator?.harness===harness);
|
|
31
|
+
if(!row)return {state:'unavailable',reason:'native_session_not_found',resumable:false};
|
|
32
|
+
if(harness!=='codex')return {state:'stopped',reason:'native_resume_not_supported',resumable:false};
|
|
33
|
+
let config;
|
|
34
|
+
try{config=await recordedConfig(row.locator);}catch{return {state:'stopped',reason:'native_resume_profile_unavailable',resumable:false};}
|
|
35
|
+
const args=codexNoticeResumeArguments(config);
|
|
36
|
+
if(config.session_id!==id)return {state:'stopped',reason:'native_resume_identity_unrecorded',resumable:false};
|
|
37
|
+
return {state:'stopped',reason:args?null:'native_resume_profile_unrecorded',resumable:!!args,
|
|
38
|
+
...(args?{resume:{cwd:config.cwd,args}}:{})};
|
|
39
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -6,9 +6,6 @@
|
|
|
6
6
|
// PUBLIC_ORIGIN the https origin the Worker is served at
|
|
7
7
|
// INIT_TEAM the first team's name (used once, at bootstrap)
|
|
8
8
|
// Identity and delegation issuers are registered apps installed by team admins through the public doors.
|
|
9
|
-
// VOLTER_ORG_CLIENT_ID, VOLTER_ORG_CLIENT_SECRET, VOLTER_TEAM_ORGS
|
|
10
|
-
// optional: this server's product credential at the organization's identity issuer, and the teams whose
|
|
11
|
-
// organization there admits its members with no invitation (`team=slug,…`)
|
|
12
9
|
// SUPERCODE_TEAMS_BOOTSTRAP_TOKEN secret: the owner's first credential, minted with
|
|
13
10
|
// `supercode teams server token`
|
|
14
11
|
import { DurableObject } from 'cloudflare:workers';
|
|
@@ -18,7 +15,6 @@ import { createHttpHandler, authenticate } from '@volter/teams/server/http';
|
|
|
18
15
|
import { harnessRoutes } from '../server/harness-routes.mjs';
|
|
19
16
|
import { authorizeMachineUpgrade, acceptMachineSocket, LINK_PROTOCOL, CHANNEL_PROTOCOL } from '@volter/teams/server/machine-link';
|
|
20
17
|
import { setOtlpSchemaSources } from '../server/otlp/transport.mjs';
|
|
21
|
-
import { identityOrganizationsFromEnv } from '@volter/teams/server/identity-orgs';
|
|
22
18
|
import { setViewerSources } from '@volter/teams/server/viewer';
|
|
23
19
|
import { serveFetch } from './fetch-adapter.mjs';
|
|
24
20
|
import { webSocketDuplex } from './websocket-duplex.mjs';
|
|
@@ -80,7 +76,7 @@ export class TeamsServer extends DurableObject {
|
|
|
80
76
|
console.log(`storage: database_bytes=${ctx.storage.sql.databaseSize}`);
|
|
81
77
|
const db = durableSql(ctx.storage);
|
|
82
78
|
migrate({ exec: db.exec, tx: db.tx, version: () => ctx.storage.kv.get('schema_version') ?? 0, setVersion: (n) => ctx.storage.kv.put('schema_version', n) });
|
|
83
|
-
this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), appRootSecret: env.SUPERCODE_TEAMS_BOOTSTRAP_TOKEN || null
|
|
79
|
+
this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), appRootSecret: env.SUPERCODE_TEAMS_BOOTSTRAP_TOKEN || null });
|
|
84
80
|
this.api.storageUsage = () => {
|
|
85
81
|
const sql = ctx.storage.sql;
|
|
86
82
|
let freeBytes = null;
|