@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 +22 -9
- package/bin/workspace/args.mjs +1 -1
- package/bin/workspace.mjs +86 -34
- package/package.json +2 -4
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`.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
package/bin/workspace/args.mjs
CHANGED
|
@@ -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
|
-
|
|
579
|
-
|
|
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)
|
|
736
|
-
*
|
|
737
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
if (
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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.
|
|
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",
|