@volter/supercode-teams 0.2.27 → 0.2.29

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 CHANGED
@@ -49,15 +49,28 @@ A workplace can open one pane or one folder for someone who is not a member of t
49
49
  server started with `--delegated-issuer ORIGIN` (the Worker's `DELEGATED_ISSUER`) trusts short-lived
50
50
  tokens that issuer signs (Ed25519, keys at `ORIGIN/.well-known/jwks.json`, audience this server's
51
51
  public origin), each naming a pane (`pane:<key>`) or a folder (`root:/path`) on one machine of one
52
- team, and `view` or `control`. The hosted viewer at `/view#token=…` opens it; the server turns the
53
- token into a machine grant on exactly that target (a pane: `observe`, and `control` to type; a
54
- folder: `observe` to read, `files` to change) that expires with the token, and closes the channel
55
- then. A page embedding the viewer sends it a fresh token by `postMessage({ type: 'delegated-token',
56
- token })`, which the viewer asks for with `delegated-token-needed`.
57
-
58
- `teams publish` puts a pane or a folder of the connected machine (or `--machine NAME`) into a
59
- workplace Room as a Surface, through the workplace's own door with a session token of yours;
60
- `teams unpublish SURFACE` retires it:
52
+ team, and `view` or `control`. A token opens only a surface the machine's custodian **published** on
53
+ this server (the machine's publications, `/machines/{id}/publications`): the same team, machine and
54
+ exact target, published to that workplace and to the Room the token names, by the machine's current
55
+ custodian while still an active member of the team. A token naming no Room, or one for
56
+ anything else, for a wildcard (`pane:*`) or for the whole filesystem (`root:/`) opens nothing, and a
57
+ token must carry `iat` and live at most 15 minutes (`exp - iat` and `exp - now`). The hosted viewer
58
+ at `/view#token=…` opens it; the server turns the token into a machine grant on exactly that target
59
+ (a pane: `observe`, and `control` to type; a folder: `observe` to read, `files` to change) that
60
+ expires with the token, and closes the channel then. A page embedding the viewer sends it a fresh
61
+ token by `postMessage({ type: 'delegated-token', token })`, which the viewer asks for with
62
+ `delegated-token-needed`.
63
+
64
+ `teams publish` records the surface on this server (only the machine's custodian can), then puts
65
+ the pane or folder of the connected machine (or `--machine NAME`) into a workplace Room as a
66
+ Surface, through the workplace's own door with a session token of yours; if the workplace refuses,
67
+ the record is withdrawn. `teams unpublish SURFACE` (the surface key or the publication id)
68
+ deletes this server's record first, on whichever of your machines holds it, which deletes the
69
+ grants minted on it and closes their channels at once, then retires it in the workplace. It fails
70
+ when this server holds no such record, unless `--workplace-only`; `--record-only` deletes the
71
+ record here without a workplace call or token. Removing a member likewise ends every delegated
72
+ grant on the surfaces they published. A surface published before this server kept records opens nothing until its custodian
73
+ records it with `teams publish … --record-only` (no workplace call, no workplace token):
61
74
 
62
75
  ```sh
63
76
  supercode teams publish pane <ctx> --room room_… --workplace https://pilot.runhuman.com --organization acme --token-stdin
@@ -1,7 +1,7 @@
1
1
  const BOOLEANS = new Set([
2
2
  'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
3
3
  'invite-stdin', 'include-children', 'rotate', 'install', 'force',
4
- 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect',
4
+ 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect', 'record-only', 'workplace-only',
5
5
  ]);
6
6
 
7
7
  const VALUES = new Set([
package/bin/workspace.mjs CHANGED
@@ -575,8 +575,10 @@ async function machinesCommand(sub, { flags, rest, output, client, context, team
575
575
  const machine = reference.startsWith('mach_') ? reference : await resolveName((await team.machines.list()).items, reference, item => item.name, item => item.id, 'machine');
576
576
  const machineBase = teamPath(context, `/machines/${machine}/access`), liveBase = teamPath(context, `/machines/${machine}/grants`);
577
577
  if (action === 'list') {
578
- const [machineGrants, liveGrants] = await Promise.all([client.request('GET', machineBase), client.request('GET', liveBase)]);
579
- return output({ version: 1, context: scope(context), data: { machine_grants: machineGrants.items, live_grants: liveGrants.items } });
578
+ // A team owner or admin manages a machine's grants; its live session grants are its custodian's alone, so
579
+ // for anyone else that half is not found and the machine grants still answer.
580
+ const [machineGrants, liveGrants] = await Promise.all([client.request('GET', machineBase), client.request('GET', liveBase).catch(error => { if (error.code === 'not_found') return null; throw error; })]);
581
+ return output({ version: 1, context: scope(context), data: { machine_grants: machineGrants.items, live_grants: liveGrants?.items ?? null } });
580
582
  }
581
583
  if (action === 'revoke') {
582
584
  if (!flags.grant) usage('--grant is required');
@@ -636,6 +638,16 @@ async function syncCommand(sub, { flags, rest, output, client, context, team, ho
636
638
  usage('teams sync add|list|show|pause|resume|remove');
637
639
  }
638
640
 
641
+ // Asks the connector this machine runs for a context to stop, through its control socket, and waits (bounded) for
642
+ // its process to end.
643
+ async function stopRunningConnector(saved) {
644
+ const running = saved?.process;
645
+ if (!running?.socket || !running?.token || !(await activeSocket(running.socket))) return;
646
+ await requestStop(running.socket, running.token);
647
+ const alive = () => { try { process.kill(running.pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
648
+ for (let waited = 0; running.pid > 0 && alive() && waited < 15_000; waited += 250) await new Promise(resolve => setTimeout(resolve, 250));
649
+ }
650
+
639
651
  async function connectorCommand(verb, { flags, rest, output, client, context, team, contextName }, home) {
640
652
  requireArgs(rest, 0, 0, `teams ${verb}`); const state = new WorkspaceContexts(home).connector(context.server_id, context.team_id);
641
653
  if (verb === 'status') {
@@ -680,6 +692,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
680
692
  if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
681
693
  const serviceBin = process.env.SUPERCODE_BIN;
682
694
  if (!serviceBin) unsupported('--install requires invocation through the native supercode CLI');
695
+ // Ending a Windows task ends only the console host it started, never the connector under it, so the task
696
+ // would refuse its new run while the old connector went on: the running one is asked to stop first.
697
+ if (process.platform === 'win32') await stopRunningConnector(saved);
683
698
  try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); }
684
699
  catch (error) {
685
700
  // A platform with no service manager still connects: here, for as long as this terminal runs it. Anything else is an error.
@@ -732,40 +747,77 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
732
747
  /**
733
748
  * PUBLISH: one pane or folder of a machine of this team, put into a workplace Room as a Surface (RH2's Surface
734
749
  * door). The workplace decides who in the Room may see or drive it and signs them delegated tokens; this server
735
- * admits those if it was started with that workplace as its delegated issuer (server/delegated.mjs). The machine is
736
- * the one `teams connect` enrolled here, or `--machine NAME`. The workplace credential is a session token, from
737
- * --token-stdin or WORKPLACE_TOKEN. `unpublish SURFACE` retires it.
750
+ * admits those if it was started with that workplace as its delegated issuer (server/delegated.mjs), and only for a
751
+ * surface its custodian published: so `publish` first records the surface on this server (the machine's
752
+ * publications, server/publications.mjs), then puts it into the workplace, and withdraws the record if the workplace
753
+ * refuses. `--record-only` records it here without touching the workplace (a surface put there before this server kept
754
+ * records). The machine is the one `teams connect` enrolled here, or `--machine NAME`. The workplace credential is a
755
+ * session token, from --token-stdin or WORKPLACE_TOKEN. `unpublish SURFACE` (the surface key, or the publication id)
756
+ * deletes this server's record first, on whichever of the caller's machines holds it (every token for it stops working
757
+ * at once), then retires it in the workplace; it fails when this server holds no such record, unless
758
+ * `--workplace-only`. `unpublish --record-only` deletes the record here and leaves the workplace alone.
738
759
  */
739
- async function publishCommand(verb, sub, { flags, rest, output, context, team, home, env }) {
740
- only(flags, ['json', 'help', 'context', 'room', 'workplace', 'organization', 'name', 'key', 'machine', 'token_stdin']);
741
- if (!flags.room || !flags.workplace || !flags.organization) usage(`teams ${verb} needs --room ROOM --workplace ORIGIN --organization ORG`);
760
+ async function publishCommand(verb, sub, { flags, rest, output, context, team, client, home, env }) {
761
+ only(flags, ['json', 'help', 'context', 'room', 'workplace', 'organization', 'name', 'key', 'machine', 'token_stdin', 'record_only', 'workplace_only']);
762
+ const recordOnly = Boolean(flags.record_only);
763
+ if (!flags.room || !flags.workplace || (!recordOnly && !flags.organization)) usage(`teams ${verb} needs --room ROOM --workplace ORIGIN --organization ORG`);
742
764
  const workplace = new URL(flags.workplace).origin;
743
- const token = flags.token_stdin ? readFileSync(0, 'utf8').trim() : env.WORKPLACE_TOKEN;
744
- if (!token) usage('the workplace credential comes from --token-stdin or WORKPLACE_TOKEN');
765
+ const token = recordOnly ? null : flags.token_stdin ? readFileSync(0, 'utf8').trim() : env.WORKPLACE_TOKEN;
766
+ if (!recordOnly && !token) usage('the workplace credential comes from --token-stdin or WORKPLACE_TOKEN');
767
+ const machine = flags.machine
768
+ ? resolveName((await team.machines.list()).items ?? [], flags.machine, item => item.name, item => item.id, 'machine')
769
+ : new WorkspaceContexts(home).connector(context.server_id, context.team_id).read()?.machine?.id;
770
+ if (!machine && verb === 'publish') usage('this machine is not connected to the team: run teams connect, or name --machine');
771
+ const publicationsOf = (id) => teamPath(context, `/machines/${encodeURIComponent(id)}/publications`);
772
+ const publications = machine ? publicationsOf(machine) : null;
773
+ const recorded = async () => (await client.request('GET', publications)).items ?? [];
745
774
  const roomPath = `${workplace}/api/v3/rooms/${encodeURIComponent(flags.room)}/surfaces`;
746
- let url, body;
775
+ const workplaceCall = async (url, body) => {
776
+ const response = await fetch(url, { body: JSON.stringify(body), headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', origin: workplace, 'x-rh2-organization': String(flags.organization) }, method: 'POST' });
777
+ const payload = await response.json().catch(() => null);
778
+ if (!response.ok) throw Object.assign(new Error(payload?.error?.message ?? `the workplace answered ${response.status}`), { code: response.status === 403 || response.status === 404 ? 'forbidden' : response.status === 401 ? 'authentication_required' : 'invalid_request' });
779
+ return payload;
780
+ };
747
781
  if (verb === 'unpublish') {
748
- requireArgs([sub, ...rest].filter(Boolean), 1, 1, 'teams unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG');
749
- url = `${roomPath}/${encodeURIComponent(sub)}/retire`; body = {};
750
- } else {
751
- if (sub !== 'pane' && sub !== 'root') usage('teams publish pane CONTEXT_KEY|root PATH --room ROOM --workplace ORIGIN --organization ORG [--name NAME] [--key KEY] [--machine NAME]');
752
- requireArgs(rest, 1, 1, `teams publish ${sub}`);
753
- const machine = flags.machine
754
- ? resolveName((await team.machines.list()).items ?? [], flags.machine, item => item.name, item => item.id, 'machine')
755
- : new WorkspaceContexts(home).connector(context.server_id, context.team_id).read()?.machine?.id;
756
- if (!machine) usage('this machine is not connected to the team: run teams connect, or name --machine');
757
- // A folder on this machine is its real path; on another machine, the path as given there.
758
- const target = sub === 'pane' ? `pane:${rest[0]}` : `root:${flags.machine ? rest[0] : realpathSync(resolve(rest[0]))}`;
759
- if (sub === 'root' && !target.startsWith('root:/')) usage('a folder on another machine is named by its absolute path');
760
- const label = sub === 'pane' ? rest[0] : basename(target.slice(5)) || 'root';
761
- const key = flags.key ?? `${sub === 'pane' ? 'pane' : 'folder'}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || createHash('sha256').update(target).digest('hex').slice(0, 8)}`;
762
- url = roomPath;
763
- body = { config: { machine, target, team: context.team_id, teamsUrl: new URL(context.origin).origin }, key, kind: sub === 'pane' ? 'pane' : 'folder', name: flags.name ?? label };
764
- }
765
- const response = await fetch(url, { body: JSON.stringify(body), headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', origin: workplace, 'x-rh2-organization': String(flags.organization) }, method: 'POST' });
766
- const payload = await response.json().catch(() => null);
767
- if (!response.ok) throw Object.assign(new Error(payload?.error?.message ?? `the workplace answered ${response.status}`), { code: response.status === 403 || response.status === 404 ? 'forbidden' : response.status === 401 ? 'authentication_required' : 'invalid_request' });
768
- return output({ version: 1, context: scope(context), data: payload?.data ?? null });
782
+ requireArgs([sub, ...rest].filter(Boolean), 1, 1, 'teams unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--machine NAME] [--record-only|--workplace-only]');
783
+ // This server's record goes first: the moment it does, no delegated token opens the surface, whatever the workplace
784
+ // does next. Named --machine, only that machine; otherwise every machine of the team this caller may read the
785
+ // publications of (its own; all of them for an admin).
786
+ const machineIds = flags.machine ? [machine] : ((await team.machines.list()).items ?? []).map(item => item.id);
787
+ const matching = [];
788
+ for (const id of machineIds) {
789
+ let items;
790
+ try { items = (await client.request('GET', publicationsOf(id))).items ?? []; } catch (error) { if (flags.machine || !['forbidden', 'not_found'].includes(error?.code)) throw error; continue; }
791
+ for (const item of items) if (item.workplace === workplace && item.room === flags.room && (item.surface_key === sub || item.id === sub)) matching.push(item);
792
+ }
793
+ if (new Set(matching.map(item => item.machine_id)).size > 1) usage(`${sub} is published from more than one machine: name the publication id, or --machine`);
794
+ if (!matching.length && !flags.workplace_only) throw Object.assign(new Error(`this server holds no publication ${sub} for that Room and workplace (name the publication id, or --machine; --workplace-only retires it in the workplace alone)`), { code: 'not_found' });
795
+ for (const item of matching) await client.request('DELETE', `${publicationsOf(item.machine_id)}/${encodeURIComponent(item.id)}`);
796
+ if (recordOnly) return output({ version: 1, context: scope(context), data: { revoked: matching.map(item => item.id) } });
797
+ const payload = await workplaceCall(`${roomPath}/${encodeURIComponent(sub)}/retire`, {});
798
+ return output({ version: 1, context: scope(context), data: { ...(payload?.data && typeof payload.data === 'object' ? payload.data : {}), revoked: matching.map(item => item.id) } });
799
+ }
800
+ if (sub !== 'pane' && sub !== 'root') usage('teams publish pane CONTEXT_KEY|root PATH --room ROOM --workplace ORIGIN --organization ORG [--name NAME] [--key KEY] [--machine NAME] [--record-only]');
801
+ requireArgs(rest, 1, 1, `teams publish ${sub}`);
802
+ // A folder on this machine is its real path; on another machine, the path as given there.
803
+ const target = sub === 'pane' ? `pane:${rest[0]}` : `root:${flags.machine ? rest[0] : realpathSync(resolve(rest[0]))}`;
804
+ if (sub === 'root' && !target.startsWith('root:/')) usage('a folder on another machine is named by its absolute path');
805
+ if (sub === 'root' && target === 'root:/') usage('publish a folder, not the whole filesystem');
806
+ const label = sub === 'pane' ? rest[0] : basename(target.slice(5)) || 'root';
807
+ const key = flags.key ?? `${sub === 'pane' ? 'pane' : 'folder'}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || createHash('sha256').update(target).digest('hex').slice(0, 8)}`;
808
+ const kind = sub === 'pane' ? 'pane' : 'folder';
809
+ const existed = (await recorded()).some(item => item.target === target && item.workplace === workplace && item.room === flags.room);
810
+ const publication = (await client.request('POST', publications, { body: { kind, target, workplace, room: flags.room, surface_key: key } })).data;
811
+ if (recordOnly) return output({ version: 1, context: scope(context), data: { publication } });
812
+ let payload;
813
+ try {
814
+ payload = await workplaceCall(roomPath, { config: { machine, target, team: context.team_id, teamsUrl: new URL(context.origin).origin }, key, kind, name: flags.name ?? label });
815
+ } catch (error) {
816
+ // Nothing is left published here that the workplace does not show.
817
+ if (!existed) await client.request('DELETE', `${publications}/${encodeURIComponent(publication.id)}`).catch(() => {});
818
+ throw error;
819
+ }
820
+ return output({ version: 1, context: scope(context), data: payload?.data && typeof payload.data === 'object' ? { ...payload.data, publication } : { surface: payload?.data ?? null, publication } });
769
821
  }
770
822
 
771
823
  async function listQuery(flags, { client, context, team }) {
@@ -861,8 +913,8 @@ function printHelp() {
861
913
  login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
862
914
  identity link
863
915
  server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN]
864
- publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--name] [--key] [--machine] [--token-stdin]
865
- unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--token-stdin]
916
+ publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--name] [--key] [--machine] [--token-stdin] [--record-only]
917
+ unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--machine] [--token-stdin] [--record-only|--workplace-only]
866
918
  connect|disconnect|status
867
919
  members invite|list|remove|role
868
920
  groups list|create|remove|add-member|remove-member
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
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",
@@ -53,9 +53,7 @@
53
53
  "engines": {
54
54
  "node": ">=20"
55
55
  },
56
- "scripts": {
57
- "typecheck:teams": "tsc --noEmit -p test/tsconfig.json"
58
- },
56
+ "scripts": {},
59
57
  "dependencies": {
60
58
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
61
59
  "@volter/supercode-frontend": "^0.2.2",