@volter/supercode-teams 0.3.141 → 0.3.142

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.
@@ -1,6 +1,9 @@
1
1
  // Native session hints choose the nearest actual harness when terminal servers inherited both variables.
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { basename } from 'node:path';
4
+ import { openLocalMachine, nodeRpc } from '../../client.mjs';
5
+ import { teamsHome, paths } from '@volter/teams/home';
6
+ import { ownClaim } from '../../live-sessions.mjs';
4
7
  export function agentSessionHint(env=process.env){
5
8
  if(env.SUPERCODE_AGENT_SESSION)return env.SUPERCODE_AGENT_SESSION;
6
9
  const claude=env.CLAUDE_CODE_SESSION_ID,codex=env.CODEX_THREAD_ID;
@@ -18,3 +21,38 @@ export function agentSessionHint(env=process.env){
18
21
  }
19
22
  throw new Error('native session hints are ambiguous; name --session SESSION');
20
23
  }
24
+
25
+ /** The daemon's retained binding is authoritative even when a launch has an old or empty context. */
26
+ export async function agentSessionEnvironment(env = process.env, session = agentSessionHint(env)) {
27
+ if (!session) return env;
28
+ let channel;
29
+ try {
30
+ channel = await openLocalMachine(paths(teamsHome(env)).socket);
31
+ const rpc = await nodeRpc(channel);
32
+ const identity = await rpc.call('harness.v1.agents.session', { session, claim: ownClaim(env) });
33
+ if (!identity) return env;
34
+ if (env.SUPERCODE_AGENT_ID && env.SUPERCODE_AGENT_ID !== identity.agent)
35
+ throw Object.assign(new Error('session ownership does not match its launch identity'), { code: 'forbidden' });
36
+ if (env.SUPERCODE_AGENT_ORG && env.SUPERCODE_AGENT_ORG !== identity.org)
37
+ throw Object.assign(new Error('session Org does not match its launch identity'), { code: 'forbidden' });
38
+ return { ...env, SUPERCODE_AGENT_CONTEXT: identity.context ?? '', SUPERCODE_AGENT_ID: identity.agent,
39
+ SUPERCODE_AGENT_ORG: identity.org, SUPERCODE_AGENT_ASSIGNMENTS: JSON.stringify(identity.assignments) };
40
+ } catch (error) {
41
+ // An offline/older daemon does not authorize a fallback to a person's context.
42
+ // An existing launch reader can still be checked by the server itself.
43
+ if (!['ENOENT', 'ECONNREFUSED', 'machine_offline', -32601].includes(error.code)) throw error;
44
+ return env;
45
+ } finally { channel?.close(); }
46
+ }
47
+
48
+ /** Choosing a context never changes an agent into the connected person. */
49
+ export function agentContextName(contexts, env = process.env, explicit = null) {
50
+ if (!env.SUPERCODE_AGENT_ID) return explicit ?? env.SUPERCODE_CONTEXT ?? contexts.read().current;
51
+ const name = explicit ?? (env.SUPERCODE_AGENT_CONTEXT || null);
52
+ if (!name) throw Object.assign(new Error('agent delegation is unavailable; no custodian context fallback is permitted'), { code: 'authentication_required' });
53
+ const selected = contexts.read().contexts[name];
54
+ if (!selected || selected.principal_id !== env.SUPERCODE_AGENT_ID
55
+ || (env.SUPERCODE_AGENT_ORG && selected.team_id !== env.SUPERCODE_AGENT_ORG))
56
+ throw Object.assign(new Error('selected context does not belong to this agent and Org'), { code: 'forbidden' });
57
+ return name;
58
+ }
package/bin/workspace.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { StringDecoder } from 'node:string_decoder';
3
3
  import { hostname } from 'node:os';
4
- import { agentSessionHint } from './workspace/agent-session.mjs';
4
+ import { agentSessionHint, agentSessionEnvironment, agentContextName } from './workspace/agent-session.mjs';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
7
7
  import { basename, dirname, join, resolve } from 'node:path';
@@ -60,20 +60,8 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
60
60
  if (verb === 'login') return loginCommand({ flags, origin: sub, rest, home, output });
61
61
  if (verb === 'auth') return authCommand({ flags, sub, rest, home, contexts, output, env, dependencies });
62
62
  if (verb === 'connect' && flags.enrollment_stdin) adoptEnrollment(contexts, home, flags);
63
- if(['agent','sessions'].includes(verb)){
64
- const session=flags.session??agentSessionHint(env);
65
- if(session){
66
- let channel;
67
- try{
68
- channel=await openLocalMachine(paths(teamsHome()).socket);const rpc=await nodeRpc(channel);
69
- const identity=await rpc.call('harness.v1.agents.session',{session});
70
- if(identity&&!flags.context&&!env.SUPERCODE_AGENT_CONTEXT)env={...env,SUPERCODE_AGENT_CONTEXT:identity.context,SUPERCODE_AGENT_ID:identity.agent,SUPERCODE_AGENT_ASSIGNMENTS:JSON.stringify(identity.assignments)};
71
- }catch(error){
72
- // Only absence of an older/offline daemon permits the ordinary plain-session context.
73
- if(!['ENOENT','ECONNREFUSED','machine_offline',-32601].includes(error.code))throw error;
74
- }finally{channel?.close();}
75
- }
76
- }
63
+ if(env.SUPERCODE_AGENT_ID||['agent','sessions'].includes(verb))
64
+ env=await agentSessionEnvironment(env,flags.session??agentSessionHint(env));
77
65
  // Every door called from an agent starts under its delegated context. A sibling product verb
78
66
  // must not silently recover the custodian's ordinary login when an agent's reader is bounded.
79
67
  const agentContext = env.SUPERCODE_AGENT_CONTEXT || null;
@@ -87,7 +75,7 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
87
75
  throw new Error('agent delegation is unavailable; no custodian context fallback is permitted');
88
76
  }
89
77
  const explicitContext = flags.context ?? agentContext ?? env.SUPERCODE_CONTEXT;
90
- let selected = explicitContext ?? contexts.read().current;
78
+ let selected = agentContextName(contexts,env,flags.context??null);
91
79
  const urlReference = verb === 'sessions' ? rest.find(value => /^https?:\/\//.test(value)) : null;
92
80
  if (!explicitContext && urlReference) selected = contextForUrl(contexts.read(), urlReference) ?? selected;
93
81
  if (!selected) usage('no workspace context selected');
@@ -871,7 +859,7 @@ async function machinesCommand(sub, { flags, rest, output, client, context, team
871
859
  }
872
860
  if (action === 'grant') {
873
861
  for (const key of ['to', 'caps', 'ttl']) if (!flags[key]) usage(`--${key} is required`);
874
- const subject = /^(user|service|group):(.+)$/.exec(flags.to)?.[2]; if (!subject) usage('--to must be user:ID, service:ID or group:ID');
862
+ const subject = /^(user|service|agent|group):(.+)$/.exec(flags.to)?.[2]; if (!subject) usage('--to must be user:ID, service:ID, agent:ID or group:ID');
875
863
  const ttl = duration(flags.ttl);
876
864
  if (flags.session || flags.binding) {
877
865
  // One session binding's native live actions (§5).
package/fleet.mjs CHANGED
@@ -8,9 +8,14 @@ import { harnessTransport, nodeRpc, openRemoteMachine } from './client.mjs';
8
8
  import { openMachine } from './machine-door.mjs';
9
9
  import { WorkspaceContexts, fromContext } from './context.mjs';
10
10
  import { teamsHome } from '@volter/teams/home';
11
+ import { agentSessionEnvironment, agentContextName } from './bin/workspace/agent-session.mjs';
11
12
 
12
13
  /** Open the channel to the machine `name` names in `context` (or this box's daemon when name is null). */
13
- export function openOn(name = null, { context = null, env = process.env } = {}) {
14
+ export async function openOn(name = null, { context = null, env = process.env } = {}) {
15
+ if (name && env.SUPERCODE_AGENT_ID) {
16
+ env = await agentSessionEnvironment(env);
17
+ context = agentContextName(new WorkspaceContexts(env.SUPERCODE_HOME ?? resolve(teamsHome(env), '..')), env, context);
18
+ }
14
19
  return openMachine({ machine: name, context, env });
15
20
  }
16
21
 
@@ -44,6 +49,7 @@ export async function nodeRequest(channel, method, params = {}) {
44
49
  * that refused or is offline carries its reason. Nothing is stored.
45
50
  */
46
51
  export async function fleetDiscover({ context = null, machines = null, params = {}, env = process.env } = {}) {
52
+ if (env.SUPERCODE_AGENT_ID) env = await agentSessionEnvironment(env);
47
53
  // One page from each machine, never its whole catalog (yuerans' was 5,620 sessions, 20 MB, 13.5 s): the caller's
48
54
  // limit, or FLEET_PAGE; each machine answers it from its daemon's held index.
49
55
  // `--limit 0` is every session, as it always was
@@ -51,7 +57,7 @@ export async function fleetDiscover({ context = null, machines = null, params =
51
57
  const home = teamsHome(env);
52
58
  const supercodeHome = env.SUPERCODE_HOME ?? resolve(home, '..');
53
59
  const contexts = new WorkspaceContexts(supercodeHome);
54
- const name = context ?? env.SUPERCODE_CONTEXT ?? contexts.read().current;
60
+ const name = agentContextName(contexts, env, context);
55
61
  if (!name || name === 'local') throw new Error('--fleet discovers the machines of a Teams context: `supercode teams login …`, then `supercode context use NAME` or --context NAME');
56
62
  const { client, context: selected } = fromContext({ supercodeHome, name, fetch: globalThis.fetch });
57
63
  const token = contexts.credential(selected.credential_ref).token;
@@ -105,4 +111,3 @@ export async function fleetDiscover({ context = null, machines = null, params =
105
111
 
106
112
  /** Sessions `discover --fleet` asks each machine for when the caller names no limit. */
107
113
  const FLEET_PAGE = 50;
108
-
package/machine.mjs CHANGED
@@ -682,13 +682,15 @@ export class TeamsMachine extends MachineCore {
682
682
  // one follow at a time: a change while one is in flight is followed again when it ends, never beside it (two in flight
683
683
  // would leave the earlier subscription unsubscribed)
684
684
  let pending = null, followed = '', backoffMs = 0, running = false, again = false;
685
+ this.stopLiveFollow = () => { clearTimeout(pending); pending = null; again = false; this.live.stop(); };
685
686
  const follow = async () => {
686
687
  pending = null;
688
+ if (this.stopping) return;
687
689
  if (running) { again = true; return; }
688
690
  running = true;
689
691
  try { await followNow(); } finally {
690
692
  running = false;
691
- if (again) { again = false; if (!pending) { pending = setTimeout(follow, 0); pending.unref?.(); } }
693
+ if (again && !this.stopping) { again = false; if (!pending) { pending = setTimeout(follow, 0); pending.unref?.(); } }
692
694
  }
693
695
  };
694
696
  const followNow = async () => {
@@ -699,6 +701,7 @@ export class TeamsMachine extends MachineCore {
699
701
  try {
700
702
  if (locators.length) {
701
703
  const opened = await this.#serveRequest('harness.v1.sessions.activity.subscribe', { locators }, 30_000);
704
+ if (this.stopping) return;
702
705
  this.liveActivity = opened?.subscription ?? null;
703
706
  this.live.codexActivity(opened?.initial ?? [], { replace: true });
704
707
  } else { this.liveActivity = null; this.live.codexActivity([], { replace: true }); }
@@ -706,6 +709,7 @@ export class TeamsMachine extends MachineCore {
706
709
  this.live.codexFollowedNow();
707
710
  if (previous) void this.#serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: previous }, 30_000).catch(() => {});
708
711
  } catch (error) {
712
+ if (this.stopping) return;
709
713
  backoffMs = Math.min(300_000, Math.max(2000, (backoffMs || 1000) * 2));
710
714
  this.emit('records-log', `live sessions: the Codex sessions' activity could not be followed (${error.message}); tried again in ${Math.round(backoffMs / 1000)} s`);
711
715
  pending = setTimeout(follow, backoffMs); pending.unref?.();
@@ -713,7 +717,7 @@ export class TeamsMachine extends MachineCore {
713
717
  };
714
718
  // a change is followed after 2 s (changes come in bursts); the first follow after the index's first answer at once,
715
719
  // as sends wait on it (the Codex sessions are not known until it is done)
716
- this.live.on('codex-followed', () => { if (!pending) { pending = setTimeout(follow, this.live.codexKnown ? 2000 : 0); pending.unref?.(); } });
720
+ this.live.on('codex-followed', () => { if (!this.stopping && !pending) { pending = setTimeout(follow, this.live.codexKnown ? 2000 : 0); pending.unref?.(); } });
717
721
  this.on('internal-notification', (message) => {
718
722
  const params = message.params ?? {};
719
723
  if (params.subscription && params.subscription === this.liveActivity) this.live.codexActivity(params.activities);
@@ -1158,6 +1162,8 @@ export class TeamsMachine extends MachineCore {
1158
1162
  this.jobs?.stopAll();
1159
1163
  void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
1160
1164
  this.carrier?.stop();
1165
+ this.stopLiveFollow?.();
1166
+ this.mailStore?.stop();
1161
1167
  clearInterval(this.hookSpoolTimer);
1162
1168
  this.idleNotices?.stop();
1163
1169
  this.agentUpkeep?.stop();
@@ -1353,17 +1359,30 @@ export class TeamsMachine extends MachineCore {
1353
1359
  accept();
1354
1360
  this.log.append({ principal, cap: 'harness', target: '*', outcome: 'opened' });
1355
1361
  const lines = lineSplitter();
1362
+ let forwarding = Promise.resolve();
1356
1363
  stream.on('data', (chunk) => {
1357
1364
  for (const line of lines(chunk)) {
1358
1365
  if (!line) continue;
1359
- let message;
1360
- try { message = JSON.parse(line); } catch { continue; }
1361
- const asked = message.id !== undefined && message.id !== null ? message.id : null;
1362
- if (asked !== null) message.id = joinId(index, message.id);
1363
- // a serve that is not there to take it answers the request at once, on this stream only
1364
- if (!this.#toServe(JSON.stringify(message) + '\n') && asked !== null && !stream.closed) {
1365
- try { stream.write(`${JSON.stringify({ jsonrpc: '2.0', id: asked, error: { code: -32000, message: 'harness serve is not running; it is being started again' } })}\n`); } catch { /* the stream went */ }
1366
- }
1366
+ forwarding = forwarding.then(async () => {
1367
+ if (stream.closed) return;
1368
+ if (entry.verify) {
1369
+ const fresh = await entry.verify();
1370
+ if (fresh?.class !== 'operator') {
1371
+ this.log.append({ principal, cap: 'harness', target: '*', outcome: 'refused', detail: 'no live operator grant' });
1372
+ return stream.close('no live operator grant');
1373
+ }
1374
+ entry.admitted = fresh;
1375
+ }
1376
+ if (stream.closed) return;
1377
+ let message;
1378
+ try { message = JSON.parse(line); } catch { return; }
1379
+ const asked = message.id !== undefined && message.id !== null ? message.id : null;
1380
+ if (asked !== null) message.id = joinId(index, message.id);
1381
+ // a serve that is not there to take it answers the request at once, on this stream only
1382
+ if (!this.#toServe(JSON.stringify(message) + '\n') && asked !== null && !stream.closed) {
1383
+ try { stream.write(`${JSON.stringify({ jsonrpc: '2.0', id: asked, error: { code: -32000, message: 'harness serve is not running; it is being started again' } })}\n`); } catch { /* the stream went */ }
1384
+ }
1385
+ }).catch(() => { if (!stream.closed) stream.close('machine authority could not be verified'); });
1367
1386
  }
1368
1387
  });
1369
1388
  stream.on('close', () => { entry.harness = null; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.141",
3
+ "version": "0.3.142",
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",
@@ -70,7 +70,7 @@
70
70
  "@volter/supercode-health": "^0.1.0",
71
71
  "@volter/supercode-terminal": "^0.2.54",
72
72
  "@volter/supercode-trace": "^0.1.0",
73
- "@volter/teams": "0.2.36",
73
+ "@volter/teams": "0.2.37",
74
74
  "@xterm/addon-serialize": "^0.14.0",
75
75
  "@xterm/headless": "^6.0.0",
76
76
  "protobufjs": "8.8.0",