Repository navigation
Expand file tree
/
Copy pathgithub.mjs
More file actions
152 lines (148 loc) · 8.38 KB
/
Copy pathgithub.mjs
File metadata and controls
152 lines (148 loc) · 8.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import { configuredRepo } from './config.mjs';
import { spawn } from 'node:child_process';
export const STORAGE_BRANCH = 'dev-agent-mailbox';
export const STORAGE_FILE = 'mailbox.json';
export const emptyState = () => ({ version: 1, agents: {}, messages: [], claims: [] });
const accessDetails = Object.freeze({
RELAY_PRIVATE_REQUIRED: 'Development mailbox requires a PRIVATE repository',
RELAY_USER_REQUIRED: 'Dead Drop requires an authenticated GitHub user identity. This credential cannot access the user endpoint; integration-only cloud credentials are not supported. Use an authorized user session on this host.',
RELAY_IDENTITY_INVALID: 'GitHub did not return a valid user login. Dead Drop cannot establish worker ownership; check the authenticated user session on this host.',
RELAY_WRITE_REQUIRED: 'GitHub account needs write access to this private repository. Repository metadata did not confirm write access; check the invitation and credential permissions on this host.',
});
export function accessDiagnostic(error) {
return Object.hasOwn(accessDetails, error?.code) ? accessDetails[error.code] : null;
}
function accessError(code) {
return Object.assign(new Error(accessDetails[code]), {code});
}
export function run(command, args, input) {
return new Promise((resolve, reject) => {
// No shell interpolation, no token copied out of the user's gh credential store.
const child = spawn(command, args, { windowsHide: true, stdio: ['pipe','pipe','pipe'] });
let out = '', err = '', size = 0;
const timer = setTimeout(() => { child.kill(); reject(new Error('Command timed out; check connectivity before retrying')); }, 30000);
child.on('error', error => { clearTimeout(timer); reject(error); });
child.stdout.on('data', data => { size += data.length; if (size > 3000000) child.kill(); else out += data; });
child.stderr.on('data', data => { if (err.length < 4000) err += data; });
child.on('close', code => {
clearTimeout(timer);
if (code === 0 && size <= 3000000) resolve(out);
else {
const error = new Error(err.trim() || `Command failed (${code})`);
error.status = Number(err.match(/HTTP (\d{3})/)?.[1]) || 0;
reject(error);
}
});
child.stdin.on('error', () => {});
child.stdin.end(input);
});
}
export class GitHubStore {
constructor(repo = configuredRepo()) {
if (typeof repo !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(repo) || repo.split('/').some(part => part === '.' || part === '..')) throw new Error('Repository must be owner/name');
this.repo = repo;
}
async api(path, method = 'GET', body) {
const args = ['api', '--hostname', 'github.com', '--method', method, '-H', 'Accept: application/vnd.github+json', path];
if (body !== undefined) args.push('--input', '-');
return JSON.parse(await run('gh', args, body === undefined ? undefined : JSON.stringify(body)));
}
async verify() {
const repo = await this.api(`repos/${this.repo}`);
if (!repo.private) throw accessError('RELAY_PRIVATE_REQUIRED');
let user;
try { user = await this.api('user'); }
catch (error) {
if (error.status === 403 && /Resource not accessible by integration/i.test(error.message)) {
throw accessError('RELAY_USER_REQUIRED');
}
throw error;
}
if (typeof user?.login !== 'string' || !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(user.login)) throw accessError('RELAY_IDENTITY_INVALID');
if (repo.permissions?.push !== true) throw accessError('RELAY_WRITE_REQUIRED');
return user.login;
}
async read() {
// Check visibility on every read, including writes' read-before-CAS. Never silently
// continue using the mailbox after someone makes the repository public.
const repo = await this.api(`repos/${this.repo}`);
if (!repo.private) throw new Error('Mailbox disabled: repository is no longer private');
const file = await this.api(`repos/${this.repo}/contents/${STORAGE_FILE}?ref=${STORAGE_BRANCH}`);
if (file.encoding !== 'base64' || file.size > 750000) throw new Error('Unsupported or oversized mailbox state');
const state = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
if (['decisions','integrationChecks','contracts','reviewCycles','checkpoints','skillImprovements','notificationReceipts','collaborationAgreements','negotiations','handoffs'].some(key => state[key] !== undefined && !Array.isArray(state[key]))) throw new Error('Unsupported mailbox schema');
if (state.version !== 1 || !state.agents || !Array.isArray(state.messages) || !Array.isArray(state.claims) || (state.tasks !== undefined && !Array.isArray(state.tasks)) || (state.skills !== undefined && !Array.isArray(state.skills))) throw new Error('Unsupported mailbox schema');
return { state, sha: file.sha };
}
async write(state, sha) {
const json = JSON.stringify(state);
if (Buffer.byteLength(json) > 750000) throw new Error('Mailbox full; archive it before adding more data');
await this.api(`repos/${this.repo}/contents/${STORAGE_FILE}`, 'PUT', {
message: 'Update development mailbox [skip ci]', branch: STORAGE_BRANCH, sha,
content: Buffer.from(json).toString('base64'),
});
}
async initialize() {
await this.verify();
try { await this.read(); return { created: false }; }
catch (error) {
if (error.status !== 404 && error.status !== 409) throw error;
// GitHub's Git database API cannot create the first reference in an empty
// repository. Only explicit initialization may seed it through Contents.
// A 409 alone is not evidence of an empty repository.
if (error.status === 409) await this.seedEmptyRepository(error);
}
const treeInput = {
tree: [{ path: STORAGE_FILE, mode: '100644', type: 'blob', content: JSON.stringify(emptyState()) }],
};
let tree;
try {tree=await this.api(`repos/${this.repo}/git/trees`,'POST',treeInput);}
catch(error) {
// Contents may report a missing ref as 404 even for an empty repository;
// the first Git database write then reports 409. Use the same guarded
// bootstrap, retrying this object creation only once.
if(error.status!==409)throw error;
await this.seedEmptyRepository(error);
tree=await this.api(`repos/${this.repo}/git/trees`,'POST',treeInput);
}
const commit = await this.api(`repos/${this.repo}/git/commits`, 'POST', {
message: 'Initialize private development mailbox [skip ci]', tree: tree.sha, parents: [],
});
try {
await this.api(`repos/${this.repo}/git/refs`, 'POST', { ref: `refs/heads/${STORAGE_BRANCH}`, sha: commit.sha });
} catch (error) {
// Another setup may have created it. Never overwrite an existing branch.
if (error.status !== 422) throw error;
await this.read();
return { created: false };
}
return { created: true };
}
async seedEmptyRepository(originalError) {
const base=`repos/${this.repo}`,repo=await this.api(base);
if(!repo.private || !repo.permissions?.push || repo.size!==0 ||
typeof repo.default_branch!=='string' || !repo.default_branch || repo.default_branch===STORAGE_BRANCH) throw originalError;
const branches=await this.api(base+'/branches?per_page=1');
const tags=await this.api(base+'/tags?per_page=1');
if(!Array.isArray(branches)||branches.length||!Array.isArray(tags)||tags.length)throw originalError;
let empty=false;
try {await this.api(base+'/commits?per_page=1');}
catch(error){if(error.status!==409)throw error;empty=true;}
if(!empty)throw originalError;
try {
// No sha means create-only: a concurrent initializer's README is never
// replaced. Mailbox data remains exclusively on the separate orphan ref.
await this.api(base+'/contents/README.md','PUT',{
message:'Initialize private coordination repository [skip ci]',branch:repo.default_branch,
content:Buffer.from('# Private Dead Drop mailbox\n\nCoordination data lives on the separate dev-agent-mailbox branch. Keep this repository private. Never merge that branch into a code branch.\n').toString('base64')
});
}catch(error){
if(![409,422].includes(error.status))throw error;
// A raced create is acceptable only if a branch now actually exists.
const current=await this.api(base+'/branches?per_page=1');
if(!Array.isArray(current)||current.length===0)throw error;
}
// Recheck privacy/access before constructing mailbox objects after a race.
await this.verify();
}
}