@commonlyai/cli 0.1.229 → 0.1.231
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/package.json +1 -1
- package/src/commands/agent.js +70 -5
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -560,7 +560,12 @@ const listAgents = async (opts, deps) => {
|
|
|
560
560
|
return 0;
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
-
|
|
563
|
+
// The pod roster also names the handle a person types to reach each agent
|
|
564
|
+
// (F44). It is printed whole: a cut handle reaches nobody.
|
|
565
|
+
const handleOf = (inst) => (inst.mention ? `@${inst.mention}` : '');
|
|
566
|
+
const handleWidth = opts.pod ? Math.max(6, ...rows.map((inst) => handleOf(inst).length)) : 0;
|
|
567
|
+
const handleCol = (s) => (opts.pod ? `${col(s, handleWidth)} ` : '');
|
|
568
|
+
log(`${col('NAME', 16)} ${col('INSTANCE', 10)} ${handleCol('HANDLE')}${col('RUNTIME', 14)} ${col('HOST', 6)} ${col('STATUS', 10)} LAST SEEN`);
|
|
564
569
|
log('─'.repeat(80));
|
|
565
570
|
rows.forEach((inst) => {
|
|
566
571
|
// The pod roster names the agent `name`; the admin route uses `agentName`.
|
|
@@ -569,7 +574,7 @@ const listAgents = async (opts, deps) => {
|
|
|
569
574
|
const host = inst.config?.runtime?.host || inst.runtime?.host || '';
|
|
570
575
|
const seen = inst.lastActiveAt || inst.usage?.lastUsedAt || inst.connection?.lastSeenAt;
|
|
571
576
|
const lastSeen = seen ? new Date(seen).toLocaleString() : 'never';
|
|
572
|
-
log(`${col(displayName, 16)} ${col(inst.instanceId, 10)} ${col(runtimeType, 14)} ${col(host, 6)} ${col(inst.status, 10)} ${lastSeen}`);
|
|
577
|
+
log(`${col(displayName, 16)} ${col(inst.instanceId, 10)} ${handleCol(handleOf(inst))}${col(runtimeType, 14)} ${col(host, 6)} ${col(inst.status, 10)} ${lastSeen}`);
|
|
573
578
|
});
|
|
574
579
|
return 0;
|
|
575
580
|
} catch (err) {
|
|
@@ -579,6 +584,64 @@ const listAgents = async (opts, deps) => {
|
|
|
579
584
|
}
|
|
580
585
|
};
|
|
581
586
|
|
|
587
|
+
/**
|
|
588
|
+
* The seat `agent logs <name> --pod` reads (F44). An agent has three names: its
|
|
589
|
+
* agentName, its instance, and the handle its @mentions route to. Any of them
|
|
590
|
+
* finds it in the pod roster, so a seat whose instance isn't "default" no longer
|
|
591
|
+
* reads as idle. An explicit --instance-id is used as given.
|
|
592
|
+
*/
|
|
593
|
+
export const resolveLogsSeat = async (client, podId, typed, instanceId) => {
|
|
594
|
+
// Activity is stored under the bare agentName, never an @ spelling.
|
|
595
|
+
const bare = String(typed).trim().replace(/^@/, '');
|
|
596
|
+
const isHandle = String(typed).trim().startsWith('@');
|
|
597
|
+
// A plain name with an explicit instance is used as given. An @handle still
|
|
598
|
+
// needs the roster: activity is stored under the agentName, not the handle.
|
|
599
|
+
if (instanceId && !isHandle) return { agentName: bare, instanceId };
|
|
600
|
+
const want = bare.toLowerCase();
|
|
601
|
+
let agents;
|
|
602
|
+
try {
|
|
603
|
+
({ agents = [] } = await client.get(`/api/registry/pods/${encodeURIComponent(podId)}/agents`));
|
|
604
|
+
} catch (err) {
|
|
605
|
+
// The default instance is still worth reading when the roster isn't.
|
|
606
|
+
return {
|
|
607
|
+
agentName: bare,
|
|
608
|
+
instanceId: instanceId || 'default',
|
|
609
|
+
note: `The pod roster couldn't be read (${err.message}), so instance ${instanceId || 'default'} was read for '${bare}'.`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const seatOf = (inst) => ({ agentName: inst.name || inst.agentName, instanceId: inst.instanceId || 'default' });
|
|
613
|
+
// An @handle routes to one seat, so it wins over another seat's instance of the same name.
|
|
614
|
+
if (isHandle) {
|
|
615
|
+
const byHandle = agents.filter((inst) => typeof inst.mention === 'string' && inst.mention.toLowerCase() === want);
|
|
616
|
+
if (byHandle.length === 1) return instanceId ? { ...seatOf(byHandle[0]), instanceId } : seatOf(byHandle[0]);
|
|
617
|
+
if (instanceId) {
|
|
618
|
+
return {
|
|
619
|
+
agentName: bare,
|
|
620
|
+
instanceId,
|
|
621
|
+
note: `'${typed}' is not a handle in this pod's roster, so agent name '${bare}' was read at instance ${instanceId}.`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const named = (inst) => [inst.mention, inst.name || inst.agentName, inst.instanceId !== 'default' && inst.instanceId]
|
|
626
|
+
.some((n) => typeof n === 'string' && n.toLowerCase() === want);
|
|
627
|
+
const hits = agents.filter(named);
|
|
628
|
+
if (hits.length > 1) {
|
|
629
|
+
const pairs = hits.map((h) => `${h.name || h.agentName}/${h.instanceId}`).join(', ');
|
|
630
|
+
throw new Error(`${hits.length} seats are named '${typed}' here: ${pairs}. `
|
|
631
|
+
+ `Run: commonly agent logs <agentName> --pod ${podId} --instance-id <instance>`);
|
|
632
|
+
}
|
|
633
|
+
if (hits.length === 0) {
|
|
634
|
+
// A typo must not read like an idle seat, so say what was read instead.
|
|
635
|
+
const handles = agents.map((inst) => inst.mention).filter(Boolean).map((m) => `@${m}`).join(', ');
|
|
636
|
+
return {
|
|
637
|
+
agentName: bare,
|
|
638
|
+
instanceId: 'default',
|
|
639
|
+
note: `'${typed}' is not in this pod's roster, so instance default was read.${handles ? ` Seats here: ${handles}` : ''}`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
return seatOf(hits[0]);
|
|
643
|
+
};
|
|
644
|
+
|
|
582
645
|
/**
|
|
583
646
|
* Enumerate every agent attached on this laptop — i.e. every file in
|
|
584
647
|
* ~/.commonly/tokens/*.json. Cross-references the session store for a
|
|
@@ -4466,7 +4529,7 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
|
|
|
4466
4529
|
.command('logs <name>')
|
|
4467
4530
|
.description("Show a seat's recent log: local daemon log, or server activity with --pod")
|
|
4468
4531
|
.option('--pod <podId>', 'Read server-side events and runs for a pod you belong to')
|
|
4469
|
-
.option('--instance-id <id>', 'Seat instance ID
|
|
4532
|
+
.option('--instance-id <id>', 'Seat instance ID (with --pod, found from the name when omitted)')
|
|
4470
4533
|
.option('--follow', 'Continue printing new output', false)
|
|
4471
4534
|
.option('--lines <n>', 'Number of trailing local lines to show', '50')
|
|
4472
4535
|
.option('--instance <url>', 'Target Commonly instance (with --pod)')
|
|
@@ -4520,10 +4583,12 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
|
|
|
4520
4583
|
if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
|
|
4521
4584
|
const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
|
|
4522
4585
|
const printActivity = async () => {
|
|
4586
|
+
const seat = await resolveLogsSeat(client, opts.pod, name, opts.instanceId);
|
|
4587
|
+
if (seat.note) console.error(seat.note);
|
|
4523
4588
|
const { events = [], runs = [] } = await client.get('/api/agents/seat-activity', {
|
|
4524
4589
|
podId: opts.pod,
|
|
4525
|
-
agentName:
|
|
4526
|
-
instanceId:
|
|
4590
|
+
agentName: seat.agentName,
|
|
4591
|
+
instanceId: seat.instanceId,
|
|
4527
4592
|
});
|
|
4528
4593
|
events.forEach((e) => {
|
|
4529
4594
|
const ts = new Date(e.createdAt).toTimeString().slice(0, 8);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Push-only Git remote helper for a confined host seat. No provider credential enters this process. */
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { connect } from 'net';
|
|
5
|
+
import { mkdtemp, readFile, rm } from 'fs/promises';
|
|
6
|
+
import { createInterface } from 'readline';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
const MAX_BUNDLE_BYTES = 6 * 1024 * 1024;
|
|
10
|
+
const OID = /^[0-9a-f]{40}$/;
|
|
11
|
+
const REF = /^refs\/heads\/seat\/[A-Za-z0-9._/-]+$/;
|
|
12
|
+
const grantId = String(process.argv[3] || '').replace(/^commonly::/, '');
|
|
13
|
+
|
|
14
|
+
const git = (args) => new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
16
|
+
let stdout = '';
|
|
17
|
+
let stderr = '';
|
|
18
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; if (stdout.length > 4096) child.kill(); });
|
|
19
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; if (stderr.length > 4096) child.kill(); });
|
|
20
|
+
child.on('error', reject);
|
|
21
|
+
child.on('close', (code) => (code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || 'git command failed'))));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const broker = (args) => new Promise((resolve, reject) => {
|
|
25
|
+
const path = process.env.COMMONLY_GH_SOCKET;
|
|
26
|
+
if (!path?.startsWith('/')) return reject(new Error('seat GitHub broker is unavailable'));
|
|
27
|
+
const socket = connect(path);
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let bytes = 0;
|
|
30
|
+
socket.setTimeout(185_000, () => socket.destroy(new Error('push broker timed out')));
|
|
31
|
+
socket.on('connect', () => socket.end(JSON.stringify({ grantId, operation: 'push.branch', args })));
|
|
32
|
+
socket.on('data', (chunk) => {
|
|
33
|
+
bytes += chunk.length;
|
|
34
|
+
if (bytes > 128 * 1024) socket.destroy(new Error('push broker response is too large'));
|
|
35
|
+
else chunks.push(chunk);
|
|
36
|
+
});
|
|
37
|
+
socket.on('error', reject);
|
|
38
|
+
socket.on('end', () => {
|
|
39
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch {
|
|
40
|
+
reject(new Error('push broker response is invalid'));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const push = async (commands) => {
|
|
46
|
+
if (commands.length !== 1 || !commands[0].startsWith('push ')) {
|
|
47
|
+
throw new Error('exactly one branch push is allowed');
|
|
48
|
+
}
|
|
49
|
+
const spec = commands[0].slice(5);
|
|
50
|
+
if (spec.startsWith('+') || spec.split(':').length !== 2) throw new Error('force and deletion are not allowed');
|
|
51
|
+
const [source, ref] = spec.split(':');
|
|
52
|
+
if (!/^(HEAD|refs\/heads\/[A-Za-z0-9._/-]+)$/.test(source)
|
|
53
|
+
|| !REF.test(ref) || ref.includes('..') || ref.endsWith('.lock') || ref.endsWith('.')) {
|
|
54
|
+
throw new Error('branch ref is not allowed');
|
|
55
|
+
}
|
|
56
|
+
const newOid = await git(['rev-parse', '--verify', `${source}^{commit}`]);
|
|
57
|
+
const baseOid = await git(['merge-base', newOid, 'refs/remotes/origin/main']);
|
|
58
|
+
if (!OID.test(newOid) || !OID.test(baseOid)) throw new Error('invalid local commit');
|
|
59
|
+
const gitPath = await git(['rev-parse', '--git-path', 'commonly-push']);
|
|
60
|
+
const directory = await mkdtemp(`${gitPath}-`);
|
|
61
|
+
try {
|
|
62
|
+
const file = join(directory, 'push.bundle');
|
|
63
|
+
await git(['bundle', 'create', file, source, `^${baseOid}`]);
|
|
64
|
+
const bundle = await readFile(file);
|
|
65
|
+
if (bundle.length > MAX_BUNDLE_BYTES) throw new Error('push bundle exceeds 6 MiB');
|
|
66
|
+
const answer = await broker({ ref, baseOid, newOid, bundle: bundle.toString('base64') });
|
|
67
|
+
if (answer.status !== 200) throw new Error(answer.body?.error || 'broker refused push');
|
|
68
|
+
return ref;
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(directory, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const main = async () => {
|
|
75
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(grantId)) throw new Error('invalid broker grant id');
|
|
76
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
77
|
+
let batch = [];
|
|
78
|
+
for await (const line of lines) {
|
|
79
|
+
if (line === 'capabilities') {
|
|
80
|
+
process.stdout.write('push\n\n');
|
|
81
|
+
} else if (line === 'list for-push') {
|
|
82
|
+
process.stdout.write('\n');
|
|
83
|
+
} else if (line.startsWith('push ')) {
|
|
84
|
+
batch.push(line);
|
|
85
|
+
} else if (line === '' && batch.length) {
|
|
86
|
+
const ref = batch[0].split(':').at(-1);
|
|
87
|
+
try {
|
|
88
|
+
const accepted = await push(batch);
|
|
89
|
+
process.stdout.write(`ok ${accepted}\n\n`);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
process.stdout.write(`error ${ref} ${String(error.message).replace(/[\r\n]/g, ' ')}\n\n`);
|
|
92
|
+
}
|
|
93
|
+
batch = [];
|
|
94
|
+
} else if (line !== '') {
|
|
95
|
+
throw new Error('unsupported Git remote command');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 2; });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-spawn GitHub command bridge for a confined host seat. The socket carries
|
|
3
|
+
* no secret: its trusted owner holds the runtime token and chooses the backend
|
|
4
|
+
* URL. The Codex permission profile must allow only this seat's exact socket.
|
|
5
|
+
*/
|
|
6
|
+
import { createServer } from 'net';
|
|
7
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { join, resolve } from 'path';
|
|
11
|
+
|
|
12
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 128 * 1024;
|
|
14
|
+
const SHIM_SOURCE = fileURLToPath(new URL('./host-gh-shim.mjs', import.meta.url));
|
|
15
|
+
const PUSH_HELPER_SOURCE = fileURLToPath(new URL('./git-remote-commonly.mjs', import.meta.url));
|
|
16
|
+
const MAX_PUSH_REQUEST_BYTES = 9 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const reply = (socket, status, body) => {
|
|
19
|
+
if (!socket.destroyed) socket.end(JSON.stringify({ status, body }));
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const startSeatGhBroker = async ({
|
|
23
|
+
runtimeToken, instanceUrl, shimRoot, fetchImpl = fetch,
|
|
24
|
+
root = join(homedir(), '.commonly', 'host-gh'),
|
|
25
|
+
}) => {
|
|
26
|
+
if (typeof runtimeToken !== 'string' || !runtimeToken.startsWith('cm_agent_')) {
|
|
27
|
+
throw new Error('host GitHub broker requires a runtime token');
|
|
28
|
+
}
|
|
29
|
+
const endpoint = new URL(instanceUrl);
|
|
30
|
+
if (!['https:', 'http:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) {
|
|
31
|
+
throw new Error('host GitHub broker requires an HTTP instance URL');
|
|
32
|
+
}
|
|
33
|
+
if (endpoint.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)) {
|
|
34
|
+
throw new Error('host GitHub broker requires HTTPS outside localhost');
|
|
35
|
+
}
|
|
36
|
+
if (typeof shimRoot !== 'string' || !shimRoot) {
|
|
37
|
+
throw new Error('host GitHub broker requires a seat-visible shim root');
|
|
38
|
+
}
|
|
39
|
+
if (resolve(shimRoot) === resolve(root)) {
|
|
40
|
+
throw new Error('host GitHub shims cannot share the protected socket root');
|
|
41
|
+
}
|
|
42
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
43
|
+
const rootStats = await lstat(root);
|
|
44
|
+
if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
|
|
45
|
+
throw new Error('host GitHub broker root must be a real directory');
|
|
46
|
+
}
|
|
47
|
+
await chmod(root, 0o700);
|
|
48
|
+
const shimRootStats = await lstat(shimRoot);
|
|
49
|
+
if (!shimRootStats.isDirectory() || shimRootStats.isSymbolicLink()) {
|
|
50
|
+
throw new Error('host GitHub shim root must be a real directory');
|
|
51
|
+
}
|
|
52
|
+
let socketDirectory;
|
|
53
|
+
let directory;
|
|
54
|
+
let server;
|
|
55
|
+
const connections = new Set();
|
|
56
|
+
try {
|
|
57
|
+
socketDirectory = await mkdtemp(join(root, 'seat-'));
|
|
58
|
+
directory = await mkdtemp(join(shimRoot, '.commonly-gh-'));
|
|
59
|
+
const socketPath = join(socketDirectory, 'broker.sock');
|
|
60
|
+
const shimPath = join(directory, 'gh');
|
|
61
|
+
const pushHelperPath = join(directory, 'git-remote-commonly');
|
|
62
|
+
const shimSource = await readFile(SHIM_SOURCE, 'utf8');
|
|
63
|
+
await writeFile(shimPath, `#!${process.execPath}\n${shimSource.replace(/^#![^\n]*\n/, '')}`, { mode: 0o500 });
|
|
64
|
+
await chmod(shimPath, 0o555);
|
|
65
|
+
const helperSource = await readFile(PUSH_HELPER_SOURCE, 'utf8');
|
|
66
|
+
await writeFile(pushHelperPath, `#!${process.execPath}\n${helperSource.replace(/^#![^\n]*\n/, '')}`, { mode: 0o500 });
|
|
67
|
+
await chmod(pushHelperPath, 0o555);
|
|
68
|
+
server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
69
|
+
connections.add(socket);
|
|
70
|
+
socket.on('close', () => connections.delete(socket));
|
|
71
|
+
let chunks = [];
|
|
72
|
+
let size = 0;
|
|
73
|
+
socket.setTimeout(15_000, () => socket.destroy());
|
|
74
|
+
socket.on('error', () => {});
|
|
75
|
+
socket.on('data', (chunk) => {
|
|
76
|
+
size += chunk.length;
|
|
77
|
+
if (size > MAX_PUSH_REQUEST_BYTES) {
|
|
78
|
+
reply(socket, 413, { error: 'request_too_large' });
|
|
79
|
+
chunks = [];
|
|
80
|
+
} else {
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
socket.on('end', async () => {
|
|
85
|
+
if (socket.destroyed || size > MAX_PUSH_REQUEST_BYTES) return;
|
|
86
|
+
let request;
|
|
87
|
+
try { request = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
|
|
88
|
+
reply(socket, 400, { error: 'invalid_request' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { grantId, operation, args } = request || {};
|
|
92
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(grantId)
|
|
93
|
+
|| typeof operation !== 'string' || !args || typeof args !== 'object'
|
|
94
|
+
|| Array.isArray(args) || Object.keys(request).some((key) => !['grantId', 'operation', 'args'].includes(key))) {
|
|
95
|
+
reply(socket, 400, { error: 'invalid_request' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (operation !== 'push.branch' && size > MAX_REQUEST_BYTES) {
|
|
99
|
+
reply(socket, 413, { error: 'request_too_large' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (operation === 'push.branch') socket.setTimeout(180_000, () => socket.destroy());
|
|
103
|
+
try {
|
|
104
|
+
const suffix = operation === 'push.branch' ? 'push' : 'cli';
|
|
105
|
+
const url = new URL(`/api/mcp/grants/${encodeURIComponent(grantId)}/${suffix}`, endpoint);
|
|
106
|
+
const response = await fetchImpl(url, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { Authorization: `Bearer ${runtimeToken}`, 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(operation === 'push.branch' ? args : { operation, args }),
|
|
110
|
+
signal: AbortSignal.timeout(operation === 'push.branch' ? 180_000 : 15_000),
|
|
111
|
+
});
|
|
112
|
+
const bodyText = await response.text();
|
|
113
|
+
if (Buffer.byteLength(bodyText, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
114
|
+
reply(socket, 502, { error: 'broker_response_too_large' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let body;
|
|
118
|
+
try { body = JSON.parse(bodyText); } catch { body = { error: 'broker_response_invalid' }; }
|
|
119
|
+
reply(socket, response.status, body);
|
|
120
|
+
} catch {
|
|
121
|
+
reply(socket, 502, { error: 'broker_unavailable' });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
server.once('error', reject);
|
|
127
|
+
server.listen(socketPath, () => {
|
|
128
|
+
server.off('error', reject);
|
|
129
|
+
resolve();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
socketPath,
|
|
134
|
+
shimPath,
|
|
135
|
+
pushHelperPath,
|
|
136
|
+
directory,
|
|
137
|
+
socketDirectory,
|
|
138
|
+
async close() {
|
|
139
|
+
for (const socket of connections) socket.destroy();
|
|
140
|
+
await new Promise((resolve) => server.close(resolve));
|
|
141
|
+
await rm(directory, { recursive: true, force: true });
|
|
142
|
+
await rm(socketDirectory, { recursive: true, force: true });
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
} catch (error) {
|
|
146
|
+
for (const socket of connections) socket.destroy();
|
|
147
|
+
if (server?.listening) await new Promise((resolve) => server.close(resolve));
|
|
148
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
149
|
+
if (socketDirectory) await rm(socketDirectory, { recursive: true, force: true });
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Narrow gh-compatible entrypoint for confined Commonly host seats. */
|
|
3
|
+
import { connect } from 'net';
|
|
4
|
+
|
|
5
|
+
const fail = (message) => {
|
|
6
|
+
process.stderr.write(`${message}\n`);
|
|
7
|
+
process.exitCode = 2;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const parse = (argv) => {
|
|
11
|
+
const parts = [...argv];
|
|
12
|
+
const grantFlag = parts.indexOf('--grant-id');
|
|
13
|
+
if (grantFlag < 0 || grantFlag + 1 >= parts.length) throw new Error('pass --grant-id <grant>');
|
|
14
|
+
const [grantId] = parts.splice(grantFlag + 1, 1);
|
|
15
|
+
parts.splice(grantFlag, 1);
|
|
16
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(grantId)) throw new Error('invalid grant id');
|
|
17
|
+
|
|
18
|
+
const [area, action, ...rest] = parts;
|
|
19
|
+
let operation;
|
|
20
|
+
let requiredFlags = [];
|
|
21
|
+
let allowedFlags = [];
|
|
22
|
+
let numberField;
|
|
23
|
+
if (area === 'pr' && action === 'create') {
|
|
24
|
+
operation = 'pr.create';
|
|
25
|
+
requiredFlags = ['--title', '--head', '--base'];
|
|
26
|
+
allowedFlags = [...requiredFlags, '--body', '--draft'];
|
|
27
|
+
} else if (area === 'pr' && action === 'view') {
|
|
28
|
+
operation = 'pr.view'; numberField = 'pullNumber';
|
|
29
|
+
} else if (area === 'pr' && action === 'comment') {
|
|
30
|
+
operation = 'pr.comment'; numberField = 'pullNumber';
|
|
31
|
+
requiredFlags = ['--body']; allowedFlags = requiredFlags;
|
|
32
|
+
} else if (area === 'pr' && action === 'review') {
|
|
33
|
+
operation = 'pr.review_comment'; numberField = 'pullNumber';
|
|
34
|
+
requiredFlags = ['--comment', '--body']; allowedFlags = requiredFlags;
|
|
35
|
+
} else if (area === 'issue' && action === 'comment') {
|
|
36
|
+
operation = 'issue.comment'; numberField = 'issueNumber';
|
|
37
|
+
requiredFlags = ['--body']; allowedFlags = requiredFlags;
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error('gh operation is not allowed for this seat');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const args = {};
|
|
43
|
+
if (numberField) {
|
|
44
|
+
const value = rest.shift();
|
|
45
|
+
if (!/^[1-9][0-9]*$/.test(value || '') || !Number.isSafeInteger(Number(value))) {
|
|
46
|
+
throw new Error('pass a positive issue or PR number');
|
|
47
|
+
}
|
|
48
|
+
args[numberField] = Number(value);
|
|
49
|
+
}
|
|
50
|
+
const flags = new Set();
|
|
51
|
+
while (rest.length) {
|
|
52
|
+
const flag = rest.shift();
|
|
53
|
+
if (!allowedFlags.includes(flag) || flags.has(flag)) throw new Error(`flag is not allowed: ${flag}`);
|
|
54
|
+
flags.add(flag);
|
|
55
|
+
if (flag === '--draft' || flag === '--comment') continue;
|
|
56
|
+
const value = rest.shift();
|
|
57
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`missing value for ${flag}`);
|
|
58
|
+
if (value.length > 32 * 1024) throw new Error(`value is too long for ${flag}`);
|
|
59
|
+
args[flag.slice(2)] = value;
|
|
60
|
+
}
|
|
61
|
+
if (requiredFlags.some((flag) => !flags.has(flag))) throw new Error('required flag is missing');
|
|
62
|
+
if (flags.has('--draft')) args.draft = true;
|
|
63
|
+
return { grantId, operation, args };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const main = async () => {
|
|
67
|
+
const request = parse(process.argv.slice(2));
|
|
68
|
+
const socketPath = process.env.COMMONLY_GH_SOCKET;
|
|
69
|
+
if (!socketPath || !socketPath.startsWith('/')) throw new Error('seat GitHub broker is unavailable');
|
|
70
|
+
const answer = await new Promise((resolve, reject) => {
|
|
71
|
+
const socket = connect(socketPath);
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let size = 0;
|
|
74
|
+
socket.setTimeout(20_000, () => socket.destroy(new Error('seat GitHub broker timed out')));
|
|
75
|
+
socket.on('connect', () => socket.end(JSON.stringify(request)));
|
|
76
|
+
socket.on('data', (chunk) => {
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
if (size > 128 * 1024) socket.destroy(new Error('seat GitHub response is too large'));
|
|
79
|
+
else chunks.push(chunk);
|
|
80
|
+
});
|
|
81
|
+
socket.on('error', reject);
|
|
82
|
+
socket.on('end', () => {
|
|
83
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch {
|
|
84
|
+
reject(new Error('seat GitHub response is invalid'));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
if (answer.status === 202 && answer.body?.status === 'pending_approval') {
|
|
89
|
+
process.stdout.write(`${JSON.stringify(answer.body, null, 2)}\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (answer.status < 200 || answer.status >= 300) {
|
|
93
|
+
throw new Error(answer.body?.error || answer.body?.status || 'GitHub operation refused');
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(`${JSON.stringify(answer.body, null, 2)}\n`);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
main().catch((error) => fail(error.message));
|