@volter/supercode-orchestrator 0.5.206 → 0.5.208
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 +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +160 -83
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +358 -85
- package/board/engine.mjs +84 -46
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +20 -4
- package/board/listing.mjs +15 -12
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +5 -0
- package/board/published.mjs +119 -32
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +124 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +62 -18
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +203 -27
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +95 -26
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +7 -6
- package/worker-route.mjs +31 -3
package/connection.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The kit's connection coordinator. Module manifests and programs belong to their application publisher.
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -7,7 +7,15 @@ import { spawn } from 'node:child_process';
|
|
|
7
7
|
import { fromContext } from '@volter/teams/context';
|
|
8
8
|
import { supercodeHome } from '@volter/teams/home';
|
|
9
9
|
|
|
10
|
+
// RH2's modules, each its own program under --modules-root.
|
|
10
11
|
export const MODULE_APPS = ['arcs-rooms','orchestration-rh2'];
|
|
12
|
+
// supercode's Workplace service's registrations (supercode ADR 0041 decision 3; runhuman-2 ADR 0055 decision 3): one program,
|
|
13
|
+
// sdk/teams/workplace/main.mjs, hosts them all, each with its own credentials on both sides. Their manifests ship here, so an
|
|
14
|
+
// organization's file selects them by these names and `rh2 apply-org` reads them with --modules-root SERVICE_REGISTRATIONS.
|
|
15
|
+
export const SERVICE_APPS = ['agents','cards','machines'];
|
|
16
|
+
export const SERVICE_REGISTRATIONS = fileURLToPath(new URL('../teams/workplace/registrations/', import.meta.url));
|
|
17
|
+
export const SERVICE_PROGRAM = fileURLToPath(new URL('../teams/workplace/main.mjs', import.meta.url));
|
|
18
|
+
const SERVICE_TOKENS = { agents: ['AGENTS_WORKPLACE_TOKEN','AGENTS_TEAMS_TOKEN'], cards: ['CARDS_WORKPLACE_TOKEN','CARDS_TEAMS_TOKEN'], machines: ['MACHINES_WORKPLACE_TOKEN','MACHINES_TEAMS_TOKEN'] };
|
|
11
19
|
export function privateJson(path, value) {
|
|
12
20
|
mkdirSync(dirname(path),{recursive:true,mode:0o700});
|
|
13
21
|
const pending=`${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
@@ -16,37 +24,30 @@ export function privateJson(path, value) {
|
|
|
16
24
|
export async function connectionCall(record, side, method, path, body, token) {
|
|
17
25
|
const origin=side==='teams'?record.teams.server:record.origin;
|
|
18
26
|
// Registration, consent, exchange and renewal have receiver-staged random values in their bodies:
|
|
19
|
-
// their digest is one operation's key, retained on a retry.
|
|
20
|
-
// with the same keys/results. Keep its key in custody until an answer is received.
|
|
21
|
-
const lease=side==='teams'&&method==='POST'&&path.endsWith('/issuer-keyring');
|
|
22
|
-
let operationKey;
|
|
23
|
-
if(lease){
|
|
24
|
-
const digest=createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
|
25
|
-
if(record.issuerKeyOperation?.digest!==digest)record.issuerKeyOperation={digest,key:`connect-${randomBytes(24).toString('hex')}`};
|
|
26
|
-
operationKey=record.issuerKeyOperation.key;
|
|
27
|
-
privateJson(connectionPath(record.root,record.id),record);
|
|
28
|
-
}
|
|
27
|
+
// their digest is one operation's key, retained on a retry.
|
|
29
28
|
const began=Date.now();
|
|
30
|
-
const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(
|
|
29
|
+
const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(9000),
|
|
31
30
|
headers:{accept:'application/json',...(token?{authorization:`Bearer ${token}`} : {}),
|
|
32
31
|
...(side==='rh2'?{origin:record.origin,'x-rh2-organization':record.organization}:{}),
|
|
33
32
|
...(body===undefined?{}:{'content-type':'application/json'}),
|
|
34
|
-
...(['POST','PATCH','DELETE'].includes(method)?{'idempotency-key'
|
|
33
|
+
...(['POST','PATCH','DELETE'].includes(method)?{'idempotency-key':`connect-${createHash('sha256').update(`${method}\n${path}\n${JSON.stringify(body)}`).digest('hex')}`}:{})},
|
|
35
34
|
...(body===undefined?{}:{body:JSON.stringify(body)})});
|
|
36
35
|
const text=await response.text();
|
|
37
36
|
record.calls??=[];record.calls.push({side,method,path,status:response.status,ms:Date.now()-began,bytes:Buffer.byteLength(text)});record.calls=record.calls.slice(-200);
|
|
38
|
-
let payload;try{payload=JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
|
|
37
|
+
let payload;try{payload=text===''&&response.status===204?{}:JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
|
|
39
38
|
if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
|
|
40
|
-
if(lease){delete record.issuerKeyOperation;privateJson(connectionPath(record.root,record.id),record);}
|
|
41
39
|
return payload;
|
|
42
40
|
}
|
|
43
41
|
export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
|
|
44
42
|
export function generateModuleConfig(root, record, grant) {
|
|
43
|
+
if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
|
|
45
44
|
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
|
|
46
45
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
47
46
|
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
48
47
|
writeFileSync(custody,`RH2_TOKEN=${grant.rh2.accessToken}\n${grant.teams?`TEAMS_TOKEN=${grant.teams.credential.token}\n`:''}`,{mode:0o600});chmodSync(custody,0o600);
|
|
49
|
-
|
|
48
|
+
// The arcs module seats card agents by resolving them in the registration that provides them: the connection's agents.
|
|
49
|
+
const agents=record.grants.find(g=>g.name==='agents'&&g.clientId), cards=record.grants.find(g=>g.name==='cards'&&g.rh2);
|
|
50
|
+
const config={rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,principalId:grant.rh2.principalId,tokenEnv:'RH2_TOKEN',...(agents?{agentsRegistration:agents.clientId}:{}),...(cards?{cardsPrincipalId:cards.rh2.principalId}:{})},
|
|
50
51
|
teams:{server:record.teams.server,team:record.teams.team,source:record.source,tokenEnv:'TEAMS_TOKEN'},
|
|
51
52
|
envFile:custody,stateFile:join(dir,'state.json'),defaultFloor:record.floor,
|
|
52
53
|
source:{key:`connection-${record.scope}`,namespace:`cx${record.scope}`,name:`${record.organization} / ${record.source}`,home:root,...(record.teamsContext?{teamsContext:record.teamsContext}:{}),...(process.env.SUPERCODE_BIN?{program:process.env.SUPERCODE_BIN}:{}),harness:record.harness??'hermes'},
|
|
@@ -56,6 +57,30 @@ export function generateModuleConfig(root, record, grant) {
|
|
|
56
57
|
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
57
58
|
return configPath;
|
|
58
59
|
}
|
|
60
|
+
/** The Workplace service's config and custody, written from the connection alone (no hand-written config): every installed
|
|
61
|
+
* service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all. */
|
|
62
|
+
export function generateServiceConfig(root, record, grant) {
|
|
63
|
+
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,'service');
|
|
64
|
+
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
65
|
+
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
66
|
+
const held=Object.fromEntries(record.grants.filter(g=>SERVICE_APPS.includes(g.name)&&g.rh2&&g.teams).map(g=>[g.name,g]));
|
|
67
|
+
writeFileSync(custody,Object.entries(held).map(([name,g])=>`${SERVICE_TOKENS[name][0]}=${g.rh2.accessToken}\n${SERVICE_TOKENS[name][1]}=${g.teams.credential.token}\n`).join(''),{mode:0o600});chmodSync(custody,0o600);
|
|
68
|
+
const agents=held.agents;
|
|
69
|
+
if(agents){
|
|
70
|
+
const section=name=>({registration:held[name].clientId,principal:held[name].rh2.principalId,tokenEnv:SERVICE_TOKENS[name][0],teamsTokenEnv:SERVICE_TOKENS[name][1],stateFile:join(dir,`${name}.state.json`)});
|
|
71
|
+
const config={
|
|
72
|
+
teams:{server:record.teams.server,team:record.teams.team,installation:agents.teams.installation.id,source:record.source,tokenEnv:SERVICE_TOKENS.agents[1]},
|
|
73
|
+
rh2:{baseUrl:record.origin,organization:record.organization,registration:agents.clientId,...(record.issuer!==record.origin?{issuer:record.issuer}:{}),tokenEnv:SERVICE_TOKENS.agents[0],principal:agents.rh2.principalId},
|
|
74
|
+
source:{namespace:`cx${record.scope}`},envFile:custody,stateFile:join(dir,'agents.state.json'),
|
|
75
|
+
...(record.webhook?{webhook:record.webhook}:{}),
|
|
76
|
+
...(held.cards?{cards:section('cards')}:{}),...(held.machines?{machines:section('machines')}:{}),
|
|
77
|
+
};
|
|
78
|
+
privateJson(configPath,config);
|
|
79
|
+
}
|
|
80
|
+
privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:SERVICE_PROGRAM,
|
|
81
|
+
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
82
|
+
return configPath;
|
|
83
|
+
}
|
|
59
84
|
export function startConnectionRunner(root,id) {
|
|
60
85
|
const recordPath=connectionPath(root,id);
|
|
61
86
|
if(!existsSync(recordPath)||!connectionCanRun(JSON.parse(readFileSync(recordPath,'utf8'))))return null;
|
|
@@ -63,12 +88,18 @@ export function startConnectionRunner(root,id) {
|
|
|
63
88
|
const child=spawn(process.execPath,[fileURLToPath(new URL('./bin/connection-runner.mjs',import.meta.url)),'--root',root,'--connection',id],{detached:true,stdio:['ignore',fd,fd],env:process.env});
|
|
64
89
|
closeSync(fd);child.unref();return child.pid;
|
|
65
90
|
}
|
|
66
|
-
export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string';}
|
|
91
|
+
export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string'&&!record.disconnectedAt;}
|
|
67
92
|
export async function connectWorkplace(flags) {
|
|
68
|
-
if(!flags.root||!flags['ticket-file']||!flags
|
|
93
|
+
if(!flags.root||!flags['ticket-file']||!flags.source)throw new Error('connect needs --root, --ticket-file and --source (one board event source)');
|
|
69
94
|
const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
|
|
70
95
|
if(!/^[0-9a-f]{24}$/.test(ticket.scope??''))throw new Error('connection ticket needs a source namespace; apply to a new private output path');
|
|
71
|
-
if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error(
|
|
96
|
+
if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name)&&!SERVICE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error(`unsupported or duplicate connection apps (connect takes ${[...MODULE_APPS,...SERVICE_APPS].join(', ')})`);
|
|
97
|
+
const names=ticket.grants.map(g=>g.name);
|
|
98
|
+
// The service is the agents registration's host: its other registrations run only inside it.
|
|
99
|
+
if(names.some(name=>name!=='agents'&&SERVICE_APPS.includes(name))&&!names.includes('agents'))throw new Error('cards and machines run in the Workplace service, which the agents registration hosts: select agents too');
|
|
100
|
+
if(names.some(name=>MODULE_APPS.includes(name))&&!flags['modules-root'])throw new Error(`--modules-root names where ${names.filter(name=>MODULE_APPS.includes(name)).join(' and ')} are`);
|
|
101
|
+
if(Boolean(flags['webhook-url'])!==Boolean(flags['webhook-listen']))throw new Error('--webhook-url and --webhook-listen go together: where the Workplace sends the agents registration\'s events, and the loopback address the service listens on');
|
|
102
|
+
const moduleRoot=flags['modules-root']?resolve(flags['modules-root']):null;
|
|
72
103
|
// An explicit local credential input is useful for an owned stack; a normal connect uses its existing Teams context.
|
|
73
104
|
let teams,owner;
|
|
74
105
|
if(flags['teams-token-file']){
|
|
@@ -86,9 +117,13 @@ export async function connectWorkplace(flags) {
|
|
|
86
117
|
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
87
118
|
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
|
|
88
119
|
const record=prior??{version:1,id,scope:ticket.scope,teamsContext:flags.context??process.env.SUPERCODE_CONTEXT,origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,source:flags.source,
|
|
89
|
-
selection:ticket.grants.map(g=>g.name),moduleRoot
|
|
120
|
+
selection:ticket.grants.map(g=>g.name),moduleRoot,...(flags['webhook-url']?{webhook:{url:flags['webhook-url'],listen:flags['webhook-listen']}}:{}),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
|
|
90
121
|
record.root=root;
|
|
91
|
-
if(prior
|
|
122
|
+
if(prior?.disconnectedAt)throw new Error('this connection was disconnected; apply the organization file again for a new ticket');
|
|
123
|
+
// The service's event address is part of the connection: a different one is a new connection, never silently dropped.
|
|
124
|
+
const webhook=flags['webhook-url']?{url:flags['webhook-url'],listen:flags['webhook-listen']}:undefined;
|
|
125
|
+
if(prior&&webhook&&JSON.stringify(prior.webhook??null)!==JSON.stringify(webhook))throw new Error('the existing connection has a different --webhook-url/--webhook-listen; use a new connection ticket before replacing it');
|
|
126
|
+
if(prior&&(record.source!==flags.source||(record.moduleRoot??null)!==moduleRoot||record.selection.join(',')!==ticket.grants.map(g=>g.name).join(',')))throw new Error('the existing connection has different module/source settings; use a new connection ticket before replacing it');
|
|
92
127
|
const lock=join(dirname(path),'runner.lock');
|
|
93
128
|
if(prior&&existsSync(lock)) {
|
|
94
129
|
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
@@ -106,7 +141,8 @@ export async function connectWorkplace(flags) {
|
|
|
106
141
|
for(const selected of ticket.grants){
|
|
107
142
|
let grant=record.grants.find(g=>g.name===selected.name);
|
|
108
143
|
if(grant && JSON.stringify([grant.manifest,grant.teamsManifest,grant.rooms])!==JSON.stringify([selected.manifest,selected.teamsManifest,selected.rooms]))throw new Error('existing connection consent differs from the ticket');
|
|
109
|
-
if(!grant){grant={name:selected.name,rooms:selected.rooms,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
|
|
144
|
+
if(!grant){grant={name:selected.name,clientId:selected.clientId,rooms:selected.rooms,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
|
|
145
|
+
if(SERVICE_APPS.includes(grant.name)&&!selected.teamsManifest)throw new Error(`${grant.name}: its Teams manifest is missing from the ticket (apply with --modules-root naming ${SERVICE_REGISTRATIONS})`);
|
|
110
146
|
if(!grant.rh2){
|
|
111
147
|
const recovering=typeof grant.redemption==='string';
|
|
112
148
|
grant.redemption??=randomBytes(32).toString('base64url');privateJson(path,record);
|
|
@@ -136,13 +172,13 @@ export async function connectWorkplace(flags) {
|
|
|
136
172
|
grant.teams=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:grant.registration.id,app_secret:grant.registration.secret,code:grant.consent.code,redirect_url:selected.teamsManifest.redirect_urls[0],credential_token:grant.stagedCredential});privateJson(path,record);
|
|
137
173
|
delete grant.consent;delete grant.registration.secret;privateJson(path,record);
|
|
138
174
|
}
|
|
139
|
-
generateModuleConfig(root,record,grant);
|
|
175
|
+
if(!SERVICE_APPS.includes(grant.name))generateModuleConfig(root,record,grant);
|
|
140
176
|
}
|
|
141
|
-
|
|
177
|
+
// The service's config names every one of its registrations, so it is written once all of them hold credentials.
|
|
178
|
+
for(const grant of record.grants.filter(g=>SERVICE_APPS.includes(g.name)))generateServiceConfig(root,record,grant);
|
|
179
|
+
if(!record.issuerRegistration){record.issuerStagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerRegistration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{name:`Workplace issuer (${id.slice(0,8)})`,description:'The Workplace as a delegated issuer, verified against its JWKS',actions:['delegation.sessions','delegation.surfaces','delegation.notices','delegation.machines'],event_kinds:[],redirect_urls:['http://127.0.0.1/connected'],issuer:record.issuer,secret:record.issuerStagedSecret},owner)).data;privateJson(path,record);}
|
|
142
180
|
if(!record.issuerConsent&&!record.issuerCredential){record.issuerStagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerConsent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${record.issuerRegistration.id}/installations`,{redirect_url:'http://127.0.0.1/connected',state:id,code:record.issuerStagedCode},owner)).data;privateJson(path,record);}
|
|
143
181
|
if(!record.issuerCredential){record.issuerStagedCredential??=newInstallationToken();privateJson(path,record);record.issuerCredential=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:record.issuerRegistration.id,app_secret:record.issuerStagedSecret,code:record.issuerStagedCode,redirect_url:'http://127.0.0.1/connected',credential_token:record.issuerStagedCredential});privateJson(path,record);delete record.issuerConsent;delete record.issuerRegistration.secret;privateJson(path,record);}
|
|
144
|
-
const keys=(await connectionCall(record,'rh2','GET','/.well-known/jwks.json')).keys;
|
|
145
|
-
await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${record.issuerCredential.installation.id}/issuer-keyring`,{keys,results:[]},record.issuerCredential.credential.token);
|
|
146
182
|
privateJson(join(dirname(path),'standing.json'),[]);
|
|
147
183
|
record.connectedAt??=new Date().toISOString();
|
|
148
184
|
record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
|
|
@@ -150,4 +186,37 @@ export async function connectWorkplace(flags) {
|
|
|
150
186
|
return {connection:id,root,modules:record.grants.map(g=>g.name),runner:pid,calls:record.calls.map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
|
|
151
187
|
}
|
|
152
188
|
|
|
189
|
+
/** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
|
|
190
|
+
* installation it holds leaves both sides by itself (each Workplace installation through its own uninstall door, each
|
|
191
|
+
* Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
|
|
192
|
+
* consented apps with no installation; an admin deregisters them. The custody record stays, marked disconnected, for
|
|
193
|
+
* audit; it can never run again. */
|
|
194
|
+
export async function disconnectWorkplace(flags) {
|
|
195
|
+
if(!flags.root||!flags.connection)throw new Error('disconnect needs --root and --connection (the id connect printed)');
|
|
196
|
+
const root=resolve(flags.root), path=connectionPath(root,flags.connection);
|
|
197
|
+
if(!existsSync(path))throw new Error(`no connection ${flags.connection} in ${root}`);
|
|
198
|
+
const record=JSON.parse(readFileSync(path,'utf8'));record.root=root;
|
|
199
|
+
record.paused=true;privateJson(path,record);
|
|
200
|
+
const lock=join(dirname(path),'runner.lock');
|
|
201
|
+
if(existsSync(lock)){
|
|
202
|
+
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
203
|
+
try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}
|
|
204
|
+
for(let waited=0;waited<20000;waited+=250){try{process.kill(running.pid,0);}catch{break;}await new Promise(done=>setTimeout(done,250));}
|
|
205
|
+
}
|
|
206
|
+
const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
|
|
207
|
+
const leave=async(name,side,method,door,token)=>{
|
|
208
|
+
try{await connectionCall(record,side,method,door,side==='rh2'?{}:undefined,token);left.push({name,side,left:true});}
|
|
209
|
+
catch(error){if(error.status===404||error.code==='installation-unknown'){left.push({name,side,left:true,already:true});}else left.push({name,side,left:false,code:error.code??error.status??error.message});}
|
|
210
|
+
privateJson(path,record);
|
|
211
|
+
};
|
|
212
|
+
for(const grant of record.grants){
|
|
213
|
+
if(grant.teams?.installation?.id)await leave(grant.name,'teams','DELETE',`${team}/app-installations/${encodeURIComponent(grant.teams.installation.id)}`,grant.teams.credential.token);
|
|
214
|
+
if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/uninstall`,grant.rh2.accessToken);
|
|
215
|
+
const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if(existsSync(entry))unlinkSync(entry);
|
|
216
|
+
}
|
|
217
|
+
if(record.issuerCredential?.installation?.id)await leave('issuer','teams','DELETE',`${team}/app-installations/${encodeURIComponent(record.issuerCredential.installation.id)}`,record.issuerCredential.token);
|
|
218
|
+
record.disconnectedAt=new Date().toISOString();record.left=left;privateJson(path,record);
|
|
219
|
+
return {connection:record.id,root,left,registrations:[...record.grants.map(g=>g.registration?.id).filter(Boolean),record.issuerRegistration?.id].filter(Boolean)};
|
|
220
|
+
}
|
|
221
|
+
|
|
153
222
|
export function newInstallationToken(){return `sct_cred_${randomBytes(16).toString('hex')}.${randomBytes(32).toString('base64url')}`;}
|
package/index.d.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function agentScope(context?: string): {server: string; org: string; prin
|
|
|
131
131
|
/** The identity a run starts as without asking Teams; `identity.synced` is false until Teams has its declaration, made later under the same id. */
|
|
132
132
|
/** A local agent: its daemon-minted id, retained intent and assignments. */
|
|
133
133
|
export type LocalAgent = {identity: AgentIdentity; intended: ReturnType<typeof resolveAgentProfile>; assignment: string; assignments: Array<Record<string, unknown>>; org: string};
|
|
134
|
-
export function localCardAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; card: string; role: string;
|
|
134
|
+
export function localCardAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; card: string; role: string; handle?: string | null; readableId?: string | null; machine?: string | null; starterSession?: string | null}): LocalAgent & {synced: false};
|
|
135
135
|
export function localProfileAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; machine?: string | null; agent?: string | null; declarations?: Record<string, unknown>}): LocalAgent & {synced: false};
|
|
136
136
|
export function localForkAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; parent: string; session: string; name: string; key: string}): LocalAgent;
|
|
137
137
|
/** Hand this home's agent declarations and ends to this machine's daemon, which declares and ends them in Teams. Answers what could not be handed on. */
|
|
@@ -349,6 +349,10 @@ export class AgentMailboxBridge {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
export class Orchestrator {
|
|
352
|
+
/** Private local preparation only: no hosted runtime or provider process. */
|
|
353
|
+
prepareMainPane(options: {directory: string; name: string; resume?: import('@volter/supercode-harness-sdk').SessionLocator | null}): Promise<{
|
|
354
|
+
ran: string; program: 'codex'; profile: string; cwd: string; arguments: string[]; env: Record<string,string>;
|
|
355
|
+
}>;
|
|
352
356
|
constructor(o: {
|
|
353
357
|
state: OrchestratorState;
|
|
354
358
|
vault?: Vault;
|
|
@@ -485,7 +489,7 @@ export interface OrchestrationImported {
|
|
|
485
489
|
|
|
486
490
|
/** Our folder, strict, through `harness.v1.orchestration.load`; the vault is read out of the home's own `.env`. */
|
|
487
491
|
export function load(root: Path): Promise<{ state: OrchestratorState; vault: Vault }>;
|
|
488
|
-
/**
|
|
492
|
+
/** Runtime persistence through `harness.v1.orchestration.save`. Leaves workflow.yaml untouched; sends no secret. */
|
|
489
493
|
export function save(state: OrchestratorState, root?: Path): Promise<void>;
|
|
490
494
|
/** Another harness's home as a state value; `vault_keys` names the `.env` keys it would need. */
|
|
491
495
|
export function compile(from: 'hermes' | 'openclaw', home: Path): Promise<{ state: OrchestratorState; vault_keys: string[] }>;
|
package/loop.mjs
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
// This is the only module that performs I/O for the loop. It holds the single
|
|
5
5
|
// state value, the vault, the SDK client, the adapters, and an injected clock.
|
|
6
6
|
|
|
7
|
-
import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
8
8
|
import { basename, join, dirname } from 'node:path';
|
|
9
9
|
import { step, leaseId } from './reducer.mjs';
|
|
10
10
|
import * as events from './events.mjs';
|
|
11
|
-
import { read, save, retainWorld, releaseWorld } from './orchestration.mjs';
|
|
11
|
+
import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
|
|
12
12
|
import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, workerCwd, declareWorkerFolder, declareWorkerHome, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
13
|
-
import { workerRoute } from './worker-route.mjs';
|
|
13
|
+
import { workerRoute, codexPaneRoute } from './worker-route.mjs';
|
|
14
|
+
import { projectCodexPaneResume } from './codex-pane-resume.mjs';
|
|
14
15
|
import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
|
|
15
16
|
import { hermesSessionKey, homeChannel } from './ir.mjs';
|
|
16
17
|
import { surfaceKeyString } from './ir.mjs';
|
|
@@ -536,6 +537,50 @@ export class Orchestrator {
|
|
|
536
537
|
return runtime.handle;
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
/** A private local preparation door for an explicit headful mailbox main.
|
|
541
|
+
* It returns only launch metadata and the configured forward's stand-in key;
|
|
542
|
+
* it creates no hosted conversation, controller lease, process or first turn. */
|
|
543
|
+
async prepareMainPane({ directory, name, resume = null }) {
|
|
544
|
+
if (typeof directory !== 'string' || typeof name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(name))
|
|
545
|
+
throw new Error('a headful main needs its exact profile directory and agent name');
|
|
546
|
+
const folder = realpathSync(directory);
|
|
547
|
+
const profiles = Object.values(this.state.profiles).filter(profile => {
|
|
548
|
+
try { return realpathSync(profile.dir) === folder; } catch { return false; }
|
|
549
|
+
});
|
|
550
|
+
if (!profiles.length) throw Object.assign(new Error('this home has no profile at that directory'), { code: 'not_this_home' });
|
|
551
|
+
if (profiles.length !== 1) throw new Error('the directory names more than one profile; no main was prepared');
|
|
552
|
+
const profile = profiles[0], worker = profile.worker;
|
|
553
|
+
if (worker?.harness !== 'codex') throw new Error('the selected main profile does not run Codex');
|
|
554
|
+
refreshVault(this.state, this.vault);
|
|
555
|
+
const route = codexPaneRoute(profile, worker, this.vault);
|
|
556
|
+
if (resume !== null) {
|
|
557
|
+
if (resume.harness !== 'codex' || typeof resume.session_id !== 'string' || resume.storage?.kind !== 'file')
|
|
558
|
+
throw new Error('a main resume needs its exact Codex transcript locator');
|
|
559
|
+
projectCodexPaneResume(route.env.CODEX_HOME, resume.storage.path, resume.session_id);
|
|
560
|
+
}
|
|
561
|
+
const context = process.env.SUPERCODE_CONTEXT, scope = agentScope(context);
|
|
562
|
+
const agent = await localProfileAgent({ root: this.state.root, org: scope.org, server: scope.server,
|
|
563
|
+
profiles: this.state.profiles, profile: profile.name, agent: name, declarations: this.state.agents });
|
|
564
|
+
const env = { ...route.env, ...agentEnvironment(agent, { root: this.state.root,
|
|
565
|
+
context: localReaderContext(scope.org, agent.identity.id) ?? undefined, main: true }) };
|
|
566
|
+
// The headful main uses the same profile-owned MCP door and identity
|
|
567
|
+
// preamble. The returned plan carries no configured server credentials.
|
|
568
|
+
mountOrchestratorMcp({ root: this.state.root, profile, harness: 'codex',
|
|
569
|
+
socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
|
|
570
|
+
agentId: agent.identity.id });
|
|
571
|
+
const instructions = agentPreamble(agent, { main: true,
|
|
572
|
+
previous: localAgentSessions(this.state.root, scope.org, agent.identity.id) });
|
|
573
|
+
const arguments_ = [...route.args, ...INSTRUCTION_ARGUMENTS.codex(instructions)];
|
|
574
|
+
if (resume === null) {
|
|
575
|
+
arguments_.push(...modelArguments({ ...worker, model: route.model }), '--ask-for-approval', approvalPolicy(profile, 'codex'));
|
|
576
|
+
arguments_.push(...sandboxArguments(profile, 'codex'));
|
|
577
|
+
if (worker.reasoning_effort) arguments_.push('-c', `model_reasoning_effort=${JSON.stringify(worker.reasoning_effort)}`);
|
|
578
|
+
}
|
|
579
|
+
declareWorkerHome({ harness: 'codex', env, cwd: folder, log: (level, text) => this.log(level, text) });
|
|
580
|
+
return { ran: `prepared a headful main for profile ${profile.name}`, program: 'codex',
|
|
581
|
+
profile: profile.name, cwd: folder, arguments: arguments_, env };
|
|
582
|
+
}
|
|
583
|
+
|
|
539
584
|
/**
|
|
540
585
|
* A new concurrent session of this profile's agent, asked for on its behalf (operator `sessions.launch`): the agent is
|
|
541
586
|
* the one this home holds for the profile, and must be the one asked for. The session is composed by startRuntime
|
|
@@ -570,6 +615,8 @@ export class Orchestrator {
|
|
|
570
615
|
const native=prior.session_ref.slice(prior.session_ref.indexOf(':')+1);
|
|
571
616
|
const page=await this.client.discover({harnesses:[locator.harness],query:native,limit:200});
|
|
572
617
|
target=page.sessions.find(item=>item.locator.harness===locator.harness&&item.locator.session_id===native)?.locator;
|
|
618
|
+
// a source still being read is no proof the retained session is gone: no replacement is made either way
|
|
619
|
+
if(!target&&page.reconciling?.length)throw new Error(`retained fork session cannot be confirmed yet: the session index is still reading ${page.reconciling.join(', ')}; no replacement was created`);
|
|
573
620
|
if(!target)throw new Error('retained fork session is not available; no replacement was created');
|
|
574
621
|
}else {
|
|
575
622
|
const loaded=await this.client.loadWindow(locator,{message_limit:1});
|
|
@@ -814,6 +861,7 @@ export class Orchestrator {
|
|
|
814
861
|
if (locator.storage) return locator;
|
|
815
862
|
const found = await this.client.request('harness.v1.sessions.discover', { query: locator.session_id, harnesses: [locator.harness], limit: 200 });
|
|
816
863
|
const whole = found.sessions?.find((item) => item.locator?.harness === locator.harness && item.locator?.session_id === locator.session_id)?.locator;
|
|
864
|
+
if (!whole && found.reconciling?.length) throw new Error(`${locator.harness}:${locator.session_id} cannot be found yet: the session index is still reading ${found.reconciling.join(', ')}`);
|
|
817
865
|
if (!whole) throw new Error(`${locator.harness}:${locator.session_id} is not in its harness catalogue`);
|
|
818
866
|
return whole;
|
|
819
867
|
}
|
package/mcp/bridge.mjs
CHANGED
|
@@ -123,7 +123,10 @@ async function dispatch(orch, call, log) {
|
|
|
123
123
|
async function dispatchOperator(orch, { op, args, profile }, log) {
|
|
124
124
|
const name = typeof profile === 'string' && profile ? profile : 'default';
|
|
125
125
|
let answer;
|
|
126
|
-
if (op === 'agents.
|
|
126
|
+
if (op === 'agents.main_plan') {
|
|
127
|
+
try { answer = { ok: true, result: await orch.prepareMainPane(args ?? {}) }; }
|
|
128
|
+
catch (error) { answer = { ok: false, error: error.message, ...(error.code ? { code: error.code } : {}) }; }
|
|
129
|
+
} else if (op === 'agents.fork') {
|
|
127
130
|
// Operation `fork`: a declared child agent, its profile a copy of the parent's, its session a fork of the parent's.
|
|
128
131
|
try {
|
|
129
132
|
const a = args ?? {};
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
},
|
|
47
47
|
"include_archived": {
|
|
48
48
|
"type": "boolean",
|
|
49
|
-
"description": "Include archived tasks. Defaults to false."
|
|
49
|
+
"description": "Include cancelled and archived cards and their tasks. Defaults to false."
|
|
50
|
+
},
|
|
51
|
+
"include_subtasks": {
|
|
52
|
+
"type": "boolean",
|
|
53
|
+
"description": "Include subtasks as separate rows. Defaults to false; use kanban_show to read a card's tasks inside it."
|
|
50
54
|
},
|
|
51
55
|
"limit": {
|
|
52
56
|
"type": "integer",
|
package/ontology.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED from `supercode ontology schema` — do not edit; run node sdk/orchestrator/scripts/generate-types.mjs
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
3
|
+
// The committed `sdk/interchange/ontology-schemas.json` contracts own these types.
|
|
4
|
+
// Native serde records remain only with the capabilities still using them.
|
|
5
5
|
|
|
6
6
|
/** The access record, as Hermes's stores hold it. */
|
|
7
7
|
export interface Access {
|
|
@@ -45,8 +45,6 @@ export interface AgentDecl {
|
|
|
45
45
|
label?: string | null;
|
|
46
46
|
/** The agent it is declared under, if not the home's root. */
|
|
47
47
|
parent?: string | null;
|
|
48
|
-
/** A person set on purpose as its sponsor; absent, the sponsor is inherited. */
|
|
49
|
-
sponsor?: string | null;
|
|
50
48
|
/** What a source format said about it that the IR has no field for, verbatim. */
|
|
51
49
|
residue?: Record<string, unknown>;
|
|
52
50
|
}
|
|
@@ -115,7 +113,7 @@ export interface Binding {
|
|
|
115
113
|
|
|
116
114
|
/**
|
|
117
115
|
* A per-action gate on a profile's agents: a start or resume over it is refused, with the reason,
|
|
118
|
-
* and the refusal is told to the manager
|
|
116
|
+
* and the refusal is told to the manager. It never pauses or ends anything itself.
|
|
119
117
|
*/
|
|
120
118
|
export interface Budget {
|
|
121
119
|
/** The window. */
|
|
@@ -390,19 +388,7 @@ export type FireStatus =
|
|
|
390
388
|
| 'unknown';
|
|
391
389
|
|
|
392
390
|
/** Observations a role can subscribe to, separate from a card's transition events. */
|
|
393
|
-
export type FleetEventKind =
|
|
394
|
-
/** A native session process first observed running. */
|
|
395
|
-
| 'session_started'
|
|
396
|
-
/** A known native session observed running in a new process incarnation. */
|
|
397
|
-
| 'session_resumed'
|
|
398
|
-
/** A native source reports a session process ended; unavailable sources are not ends. */
|
|
399
|
-
| 'session_ended'
|
|
400
|
-
/** More than one observed live process incarnation names the same native session. */
|
|
401
|
-
| 'duplicate_session_process'
|
|
402
|
-
/** A card's native session reports that it needs its user's input. */
|
|
403
|
-
| 'card_waiting_on_user'
|
|
404
|
-
/** A machine's health source reports a critical alarm. */
|
|
405
|
-
| 'machine_health_crit';
|
|
391
|
+
export type FleetEventKind = 'session_started' | 'session_resumed' | 'session_ended' | 'duplicate_session_process' | 'card_waiting_on_user' | 'machine_health_crit';
|
|
406
392
|
|
|
407
393
|
/** A conversation moved (or moving) to another surface — Hermes `handoff_*`. */
|
|
408
394
|
export interface Handoff {
|
|
@@ -836,7 +822,7 @@ export type SecretRef =
|
|
|
836
822
|
/** A key in the home's own `.env` file. */
|
|
837
823
|
| { dotenv: string };
|
|
838
824
|
|
|
839
|
-
/** A session's usage, kept on the session and summed upward (session → agent →
|
|
825
|
+
/** A session's usage, kept on the session and summed upward (session → agent → Org). */
|
|
840
826
|
export interface SessionUsage {
|
|
841
827
|
/** The agent the session belongs to, when it has one. */
|
|
842
828
|
agent?: string | null;
|
|
@@ -1134,11 +1120,6 @@ export interface Workflow {
|
|
|
1134
1120
|
params?: Record<string, unknown>;
|
|
1135
1121
|
/** Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person. */
|
|
1136
1122
|
roles?: Record<string, Expr>;
|
|
1137
|
-
/**
|
|
1138
|
-
* Opt-in fleet observations delivered as mail to each role's declared addresses. These are not card
|
|
1139
|
-
* transitions and grant no observation or machine authority. The manager is an implicit workflow role.
|
|
1140
|
-
*/
|
|
1141
|
-
listeners?: Record<string, RoleFleetListener>;
|
|
1142
1123
|
/** Where a new card starts: the first transition whose guard holds. */
|
|
1143
1124
|
initial: Transition[];
|
|
1144
1125
|
/** Every status, by name. */
|
|
@@ -1152,4 +1133,6 @@ export interface Workflow {
|
|
|
1152
1133
|
* are), the event kinds it hears and the template each is told in. A kind not named is not sent.
|
|
1153
1134
|
*/
|
|
1154
1135
|
notify?: Record<string, Record<string, string>>;
|
|
1136
|
+
/** Role fleet listeners; a declaration grants no machine or mailbox authority. */
|
|
1137
|
+
listeners?: Record<string, RoleFleetListener>;
|
|
1155
1138
|
}
|
package/orchestration.mjs
CHANGED
|
@@ -150,7 +150,7 @@ export async function load(root) {
|
|
|
150
150
|
const { orchestration, vault_keys } = await through((d) => d.orchestrationLoad({ root }));
|
|
151
151
|
let vault = readVault(orchestration);
|
|
152
152
|
if (vault_keys.some((k) => !vault.has(k))) {
|
|
153
|
-
await through((d) => d.orchestrationSave({ root, orchestration }));
|
|
153
|
+
await through((d) => d.orchestrationSave({ root, orchestration, preserve_workflow: true }));
|
|
154
154
|
vault = readVault(orchestration);
|
|
155
155
|
const missing = vault_keys.filter((k) => !vault.has(k));
|
|
156
156
|
if (missing.length) throw new Error(`${root}: ${missing.join(', ')} referenced but not in .env after normalization`);
|
|
@@ -164,12 +164,15 @@ export async function read(root) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
|
-
* `save(state, root)` —
|
|
167
|
+
* `save(state, root)` — persist runtime state, preserving the home's workflow.yaml.
|
|
168
|
+
* Board policy belongs to the live home, not the runtime's earlier snapshot. Full
|
|
169
|
+
* home creation/replacement uses orchestrationSave or importFrom explicitly.
|
|
170
|
+
* Other artifacts are canonical and byte-stable when their record has not changed.
|
|
168
171
|
* Answers the entries the door could not read (`rejected`, each `{ collection, key, reason, kept_stored }`): each was
|
|
169
172
|
* written as the home last held it, or left out when it held none, and none refuses the rest of the save.
|
|
170
173
|
*/
|
|
171
174
|
export async function save(state, root = state.root) {
|
|
172
|
-
const saved = await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state) }));
|
|
175
|
+
const saved = await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state), preserve_workflow: true }));
|
|
173
176
|
return { rejected: Array.isArray(saved?.rejected) ? saved.rejected : [] };
|
|
174
177
|
}
|
|
175
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.208",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@volter/supercode-teams": "^0.3.
|
|
36
|
-
"@volter/supercode-terminal": "^0.3.
|
|
37
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
38
|
-
"@volter/teams": "0.2.
|
|
35
|
+
"@volter/supercode-teams": "^0.3.345",
|
|
36
|
+
"@volter/supercode-terminal": "^0.3.10",
|
|
37
|
+
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
38
|
+
"@volter/teams": "0.2.98",
|
|
39
39
|
"@volter/ztrack": "^2.4.23",
|
|
40
40
|
"yaml": "^2.9.0"
|
|
41
41
|
},
|
|
@@ -61,5 +61,6 @@
|
|
|
61
61
|
"board",
|
|
62
62
|
"apply",
|
|
63
63
|
"README.md"
|
|
64
|
-
]
|
|
64
|
+
],
|
|
65
|
+
"gitHead": "db4a88b4d2f670654fa8e57dbc3e93c7daa8d2bb"
|
|
65
66
|
}
|
package/worker-route.mjs
CHANGED
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
//
|
|
28
28
|
// A route this table does not have is refused by name at launch, never run on the worker's default:
|
|
29
29
|
// that would spend on a model nobody chose.
|
|
30
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import { join } from 'node:path';
|
|
32
|
-
import { MODEL_ARGUMENTS } from './activation.mjs';
|
|
32
|
+
import { MODEL_ARGUMENTS, materializePersona, materializeSkills } from './activation.mjs';
|
|
33
33
|
|
|
34
34
|
/** The Codex provider id a Hermes provider is served under. */
|
|
35
35
|
const PROVIDER_ID = { custom: 'hermes_custom', openai: 'openai', 'openai-codex': 'hermes_codex' };
|
|
@@ -75,6 +75,7 @@ export function workerRoute(profile, worker, vault) {
|
|
|
75
75
|
const forward = vault?.get?.('HERMES_CODEX_BASE_URL') ?? process.env.HERMES_CODEX_BASE_URL;
|
|
76
76
|
if (!forward) throw new Error('model.provider openai-codex needs the Codex forward (HERMES_CODEX_BASE_URL in the home\'s .env, which the kit\'s start writes); refused rather than run on a login the worker would have to hold');
|
|
77
77
|
table.base_url = forward;
|
|
78
|
+
table.requires_openai_auth = false;
|
|
78
79
|
table.env_key = LITERAL_KEY_ENV;
|
|
79
80
|
env[LITERAL_KEY_ENV] = 'valve';
|
|
80
81
|
writeProviderTable(join(profile.dir, 'codex'), id, table);
|
|
@@ -126,7 +127,7 @@ function writeProviderTable(home, id, table) {
|
|
|
126
127
|
const file = join(home, 'config.toml');
|
|
127
128
|
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
128
129
|
const header = `[model_providers.${id}]`;
|
|
129
|
-
const block = [header, ...Object.entries(table).map(([k, v]) => `${k} = ${JSON.stringify(String(v))}`)].join('\n');
|
|
130
|
+
const block = [header, ...Object.entries(table).map(([k, v]) => `${k} = ${typeof v === 'boolean' ? String(v) : JSON.stringify(String(v))}`)].join('\n');
|
|
130
131
|
const lines = text.split('\n');
|
|
131
132
|
const start = lines.findIndex((line) => line.trim() === header);
|
|
132
133
|
let next;
|
|
@@ -138,3 +139,30 @@ function writeProviderTable(home, id, table) {
|
|
|
138
139
|
}
|
|
139
140
|
if (next !== text) writeFileSync(file, next, { mode: 0o600 });
|
|
140
141
|
}
|
|
142
|
+
|
|
143
|
+
/** The headful Codex lane must never fall back to the shared ChatGPT login.
|
|
144
|
+
* This returns only the configured forward's stand-in key and provider flags;
|
|
145
|
+
* it neither copies a credential nor starts a program or login. */
|
|
146
|
+
export function codexPaneRoute(profile, worker, vault) {
|
|
147
|
+
if (worker?.harness !== 'codex' || hermesModel(profile)?.provider !== 'openai-codex')
|
|
148
|
+
throw new Error('headful Codex requires model.provider openai-codex and its configured HERMES_CODEX_BASE_URL; no shared-login pane was launched');
|
|
149
|
+
if (worker.home === 'user') throw new Error('headful Codex requires a profile home; no shared-login pane was launched');
|
|
150
|
+
const forward = vault?.get?.('HERMES_CODEX_BASE_URL') ?? process.env.HERMES_CODEX_BASE_URL;
|
|
151
|
+
if (!forward) throw new Error('headful Codex requires the configured HERMES_CODEX_BASE_URL; no shared-login pane was launched');
|
|
152
|
+
let endpoint;
|
|
153
|
+
try { endpoint = new URL(forward); } catch { throw new Error('the configured Codex forward is not an HTTP endpoint; no pane was launched'); }
|
|
154
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password)
|
|
155
|
+
throw new Error('the configured Codex forward must be HTTP(S) without URL credentials; no pane was launched');
|
|
156
|
+
const home = join(profile.dir, 'codex');
|
|
157
|
+
if (Object.hasOwn(worker.env ?? {}, 'CODEX_HOME') && worker.env.CODEX_HOME !== home)
|
|
158
|
+
throw new Error('headful Codex has a different worker.env.CODEX_HOME; no shared-login pane was launched');
|
|
159
|
+
// lstat includes a dangling auth symlink. Do not read, unlink or migrate it.
|
|
160
|
+
try {
|
|
161
|
+
lstatSync(join(home, 'auth.json'));
|
|
162
|
+
throw new Error('the headful Codex profile home holds an auth.json entry; a login-free home is required and no credential was changed');
|
|
163
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
164
|
+
materializePersona(profile);
|
|
165
|
+
materializeSkills(profile);
|
|
166
|
+
const route = workerRoute(profile, worker, vault);
|
|
167
|
+
return { ...route, env: { CODEX_HOME: home, ...route.env } };
|
|
168
|
+
}
|