@wenrwa/marketplace-cli 0.8.12 → 0.8.14
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/dist/__tests__/git-token-refresh.test.d.ts +1 -0
- package/dist/__tests__/git-token-refresh.test.js +262 -0
- package/dist/__tests__/linked-repos-clone.test.d.ts +1 -0
- package/dist/__tests__/linked-repos-clone.test.js +164 -0
- package/dist/bridge/gitTokenBroker.d.ts +30 -0
- package/dist/bridge/gitTokenBroker.js +131 -0
- package/dist/bridge/linkedRepos.d.ts +31 -0
- package/dist/bridge/linkedRepos.js +52 -0
- package/dist/bridge/relayClient.d.ts +41 -0
- package/dist/bridge/relayClient.js +60 -21
- package/dist/commands/bridge.js +29 -3
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
/**
|
|
7
|
+
* a9be9720 — a hosted agent can push for as long as its session lives.
|
|
8
|
+
*
|
|
9
|
+
* WENRWA_GIT_TOKEN went onto a PTY's env once, at open, and a GitHub App
|
|
10
|
+
* installation token lives an hour: every agent session older than that held
|
|
11
|
+
* a dead credential ("remote: Invalid username or token") and no retry helped.
|
|
12
|
+
*
|
|
13
|
+
* Now `git-credential-wenrwa get` asks the bridge, over a 0600 unix socket,
|
|
14
|
+
* for a credential for $WENRWA_WORKSPACE_ID. The bridge caches it until shortly
|
|
15
|
+
* before it expires and otherwise asks the relay (`git-token`) for a fresh one.
|
|
16
|
+
* The injected env token stays as the fallback when the bridge cannot answer.
|
|
17
|
+
*
|
|
18
|
+
* Drives the REAL relay client against a fake relay, the REAL socket server,
|
|
19
|
+
* and the REAL shell helper from the box image.
|
|
20
|
+
*/
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const os_1 = __importDefault(require("os"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const ws_1 = require("ws");
|
|
26
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
27
|
+
const gitTokenBroker_1 = require("../bridge/gitTokenBroker");
|
|
28
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
29
|
+
class FakePty {
|
|
30
|
+
opts;
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
}
|
|
34
|
+
write() { }
|
|
35
|
+
resize() { }
|
|
36
|
+
kill() { }
|
|
37
|
+
onData() { }
|
|
38
|
+
onExit() { }
|
|
39
|
+
}
|
|
40
|
+
const makeFactory = () => ({ spawn: (o) => new FakePty(o) });
|
|
41
|
+
const HELPER = path_1.default.resolve(__dirname, '../../../infra/terminal-image/git-credential-wenrwa');
|
|
42
|
+
function startFakeRelay() {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
45
|
+
const queue = [];
|
|
46
|
+
const waiters = [];
|
|
47
|
+
wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
|
|
48
|
+
w(ws);
|
|
49
|
+
else
|
|
50
|
+
queue.push(ws); });
|
|
51
|
+
wss.on('listening', () => {
|
|
52
|
+
const addr = wss.address();
|
|
53
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
54
|
+
resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function closeRelay(wss) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
for (const client of wss.clients)
|
|
61
|
+
client.terminate();
|
|
62
|
+
wss.close((err) => (err ? reject(err) : resolve()));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** The next frame of type `t` the bridge sends (it sends reports too). */
|
|
66
|
+
function nextOfType(ws, t) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const onMsg = (raw) => {
|
|
69
|
+
const f = JSON.parse(String(raw));
|
|
70
|
+
if (f.t !== t)
|
|
71
|
+
return;
|
|
72
|
+
ws.off('message', onMsg);
|
|
73
|
+
resolve(f);
|
|
74
|
+
};
|
|
75
|
+
ws.on('message', onMsg);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const inMinutes = (m, from = Date.now()) => new Date(from + m * 60_000).toISOString();
|
|
79
|
+
describe('relay client — requestGitToken (a9be9720)', () => {
|
|
80
|
+
it('asks the relay with a git-token frame and resolves with its answer', async () => {
|
|
81
|
+
const relay = await startFakeRelay();
|
|
82
|
+
const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
|
|
83
|
+
try {
|
|
84
|
+
const ws = await relay.nextConnection();
|
|
85
|
+
ws.send(JSON.stringify({ t: 'noop' })); // the relay accepting us (any frame)
|
|
86
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
87
|
+
const asked = nextOfType(ws, 'git-token');
|
|
88
|
+
const pending = handle.requestGitToken('ws-1');
|
|
89
|
+
const req = await asked;
|
|
90
|
+
expect(req).toEqual({ t: 'git-token', requestId: expect.any(String), workspaceId: 'ws-1' });
|
|
91
|
+
// lr-02: a named repo rides along.
|
|
92
|
+
const asked2 = nextOfType(ws, 'git-token');
|
|
93
|
+
void handle.requestGitToken('ws-1', 'acme/backend').catch(() => undefined);
|
|
94
|
+
expect(await asked2).toMatchObject({ workspaceId: 'ws-1', repo: 'acme/backend' });
|
|
95
|
+
ws.send(JSON.stringify({ t: 'git-token', requestId: req.requestId, workspaceId: 'ws-1', ok: true, token: 'ghs_new', repoFullName: 'acme/w', expiresAt: '2026-10-24T13:00:00.000Z' }));
|
|
96
|
+
await expect(pending).resolves.toEqual({ token: 'ghs_new', repoFullName: 'acme/w', expiresAt: '2026-10-24T13:00:00.000Z' });
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
handle.close();
|
|
100
|
+
await closeRelay(relay.wss);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
it('a refusal rejects with the relay\'s code and sentence', async () => {
|
|
104
|
+
const relay = await startFakeRelay();
|
|
105
|
+
const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
|
|
106
|
+
try {
|
|
107
|
+
const ws = await relay.nextConnection();
|
|
108
|
+
ws.send(JSON.stringify({ t: 'noop' }));
|
|
109
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
110
|
+
const asked = nextOfType(ws, 'git-token');
|
|
111
|
+
const pending = handle.requestGitToken('ws-1');
|
|
112
|
+
const req = await asked;
|
|
113
|
+
ws.send(JSON.stringify({ t: 'git-token', requestId: req.requestId, ok: false, code: 'WORKSPACE_ACCESS_DENIED', message: 'Not a member of this workspace' }));
|
|
114
|
+
await expect(pending).rejects.toMatchObject({ code: 'WORKSPACE_ACCESS_DENIED', message: 'Not a member of this workspace' });
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
handle.close();
|
|
118
|
+
await closeRelay(relay.wss);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
it('no relay connection → rejects at once (the helper then falls back), never waits out a timeout', async () => {
|
|
122
|
+
const handle = (0, relayClient_1.startRelayClient)({ relayUrl: 'ws://127.0.0.1:9', relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
|
|
123
|
+
try {
|
|
124
|
+
const t0 = Date.now();
|
|
125
|
+
await expect(handle.requestGitToken('ws-1')).rejects.toMatchObject({ code: 'OFFLINE' });
|
|
126
|
+
expect(Date.now() - t0).toBeLessThan(500);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
handle.close();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('GitTokenBroker — one fresh credential per workspace, cached until shortly before it expires', () => {
|
|
134
|
+
it('reuses a credential while it has more than 5 minutes left, then asks again', async () => {
|
|
135
|
+
let now = Date.parse('2026-10-24T12:00:00.000Z');
|
|
136
|
+
let calls = 0;
|
|
137
|
+
const fetch = jest.fn(async () => ({ token: `t${++calls}`, repoFullName: 'acme/w', expiresAt: inMinutes(15, now) }));
|
|
138
|
+
const broker = new gitTokenBroker_1.GitTokenBroker(fetch, () => now);
|
|
139
|
+
expect((await broker.get('ws-1')).token).toBe('t1');
|
|
140
|
+
now += 9 * 60_000; // 6 minutes left
|
|
141
|
+
expect((await broker.get('ws-1')).token).toBe('t1');
|
|
142
|
+
now += 2 * 60_000; // 4 minutes left — refresh
|
|
143
|
+
expect((await broker.get('ws-1')).token).toBe('t2');
|
|
144
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
it('lr-02: each repo of a workspace has its own credential', async () => {
|
|
147
|
+
const fetch = jest.fn(async (ws, repo) => ({ token: `${ws}:${repo ?? '-'}`, repoFullName: repo ?? 'p', expiresAt: inMinutes(15) }));
|
|
148
|
+
const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
|
|
149
|
+
expect((await broker.get('ws-1')).token).toBe('ws-1:-');
|
|
150
|
+
expect((await broker.get('ws-1', 'acme/backend')).token).toBe('ws-1:acme/backend');
|
|
151
|
+
expect((await broker.get('ws-1', 'ACME/Backend')).token).toBe('ws-1:acme/backend');
|
|
152
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
it('workspaces never share a credential', async () => {
|
|
155
|
+
const fetch = jest.fn(async (ws) => ({ token: `tok-${ws}`, repoFullName: ws, expiresAt: inMinutes(15) }));
|
|
156
|
+
const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
|
|
157
|
+
expect((await broker.get('ws-A')).token).toBe('tok-ws-A');
|
|
158
|
+
expect((await broker.get('ws-B')).token).toBe('tok-ws-B');
|
|
159
|
+
});
|
|
160
|
+
it('a burst of git calls makes ONE request', async () => {
|
|
161
|
+
let release;
|
|
162
|
+
const gate = new Promise((r) => { release = r; });
|
|
163
|
+
const fetch = jest.fn(async () => { await gate; return { token: 't', repoFullName: 'r', expiresAt: inMinutes(15) }; });
|
|
164
|
+
const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
|
|
165
|
+
const all = Promise.all([broker.get('ws-1'), broker.get('ws-1'), broker.get('ws-1')]);
|
|
166
|
+
release();
|
|
167
|
+
await all;
|
|
168
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
it('a failure is not cached: the next call asks again', async () => {
|
|
171
|
+
const fetch = jest
|
|
172
|
+
.fn()
|
|
173
|
+
.mockRejectedValueOnce(Object.assign(new Error('offline'), { code: 'OFFLINE' }))
|
|
174
|
+
.mockResolvedValueOnce({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) });
|
|
175
|
+
const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
|
|
176
|
+
await expect(broker.get('ws-1')).rejects.toMatchObject({ code: 'OFFLINE' });
|
|
177
|
+
expect((await broker.get('ws-1')).token).toBe('t');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
describe('git token socket + the box\'s git-credential-wenrwa helper', () => {
|
|
181
|
+
let dir;
|
|
182
|
+
let sock;
|
|
183
|
+
let server = null;
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
// Short path: unix socket paths are limited to ~104 bytes on macOS.
|
|
186
|
+
dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'gtk-'));
|
|
187
|
+
sock = path_1.default.join(dir, 'git.sock');
|
|
188
|
+
});
|
|
189
|
+
afterEach(async () => {
|
|
190
|
+
await server?.close();
|
|
191
|
+
server = null;
|
|
192
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
193
|
+
});
|
|
194
|
+
const runHelper = (env, stdin = 'protocol=https\nhost=github.com\n\n') => new Promise((resolve) => {
|
|
195
|
+
const child = (0, child_process_1.execFile)('sh', [HELPER, 'get'], { env: { PATH: process.env.PATH ?? '', ...env } }, (err, stdout, stderr) => {
|
|
196
|
+
resolve({ code: err ? err.code ?? 1 : 0, stdout, stderr });
|
|
197
|
+
});
|
|
198
|
+
child.stdin?.end(stdin);
|
|
199
|
+
});
|
|
200
|
+
it('lr-02: with git\'s path= (credential.useHttpPath) the helper asks for THAT repo', async () => {
|
|
201
|
+
const asked = [];
|
|
202
|
+
server = await (0, gitTokenBroker_1.startGitTokenSocket)({
|
|
203
|
+
path: sock,
|
|
204
|
+
broker: new gitTokenBroker_1.GitTokenBroker(async (ws, repo) => { asked.push([ws, repo]); return { token: `tok-${repo ?? 'primary'}`, repoFullName: repo ?? 'p', expiresAt: inMinutes(15) }; }),
|
|
205
|
+
});
|
|
206
|
+
const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'stale' }, 'protocol=https\nhost=github.com\npath=acme/backend.git\n\n');
|
|
207
|
+
expect(out.stdout).toBe('username=x-access-token\npassword=tok-acme/backend\n');
|
|
208
|
+
expect(asked).toEqual([['ws-1', 'acme/backend']]);
|
|
209
|
+
});
|
|
210
|
+
it('lr-02: a path git sends for another host (Forgejo) is not passed — the primary answers', async () => {
|
|
211
|
+
const asked = [];
|
|
212
|
+
server = await (0, gitTokenBroker_1.startGitTokenSocket)({
|
|
213
|
+
path: sock,
|
|
214
|
+
broker: new gitTokenBroker_1.GitTokenBroker(async (ws, repo) => { asked.push([ws, repo]); return { token: 'primary', repoFullName: 'p', expiresAt: inMinutes(15) }; }),
|
|
215
|
+
});
|
|
216
|
+
await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1' }, 'protocol=https\nhost=forgejo.example\npath=pilot/deck.git\n\n');
|
|
217
|
+
expect(asked).toEqual([['ws-1', undefined]]);
|
|
218
|
+
});
|
|
219
|
+
it('the socket is owner-only (0600)', async () => {
|
|
220
|
+
server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(async () => ({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) })) });
|
|
221
|
+
expect(fs_1.default.statSync(sock).mode & 0o777).toBe(0o600);
|
|
222
|
+
});
|
|
223
|
+
it('the helper prints the bridge\'s FRESH credential, not the stale one on its env', async () => {
|
|
224
|
+
server = await (0, gitTokenBroker_1.startGitTokenSocket)({
|
|
225
|
+
path: sock,
|
|
226
|
+
broker: new gitTokenBroker_1.GitTokenBroker(async (ws) => ({ token: `fresh-for-${ws}`, repoFullName: 'acme/w', expiresAt: inMinutes(15) })),
|
|
227
|
+
});
|
|
228
|
+
const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'stale' });
|
|
229
|
+
expect(out.code).toBe(0);
|
|
230
|
+
expect(out.stdout).toBe('username=x-access-token\npassword=fresh-for-ws-1\n');
|
|
231
|
+
});
|
|
232
|
+
it('bridge cannot mint (offline, refused) → the helper falls back to the injected token', async () => {
|
|
233
|
+
server = await (0, gitTokenBroker_1.startGitTokenSocket)({
|
|
234
|
+
path: sock,
|
|
235
|
+
broker: new gitTokenBroker_1.GitTokenBroker(async () => { throw Object.assign(new Error('offline'), { code: 'OFFLINE' }); }),
|
|
236
|
+
});
|
|
237
|
+
const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'injected' });
|
|
238
|
+
expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
|
|
239
|
+
});
|
|
240
|
+
it('no bridge socket at all (an older bridge) → the injected token, exactly as before', async () => {
|
|
241
|
+
const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: path_1.default.join(dir, 'missing.sock'), WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'injected' });
|
|
242
|
+
expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
|
|
243
|
+
});
|
|
244
|
+
it('a request without a workspace is refused (the bridge never guesses one)', async () => {
|
|
245
|
+
const fetch = jest.fn();
|
|
246
|
+
server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(fetch) });
|
|
247
|
+
const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_GIT_TOKEN: 'injected' });
|
|
248
|
+
expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
|
|
249
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
describe('assembleSessionEnv — a PTY knows where to ask', () => {
|
|
253
|
+
it('carries the socket path beside the injected token', () => {
|
|
254
|
+
const env = (0, relayClient_1.assembleSessionEnv)({
|
|
255
|
+
workspaceId: 'ws-1',
|
|
256
|
+
gitCredential: { token: 't', repoFullName: 'acme/w', expiresAt: inMinutes(15) },
|
|
257
|
+
gitTokenSocket: '/home/wenrwa/.wenrwa/git.sock',
|
|
258
|
+
});
|
|
259
|
+
expect(env.WENRWA_GIT_TOKEN_SOCKET).toBe('/home/wenrwa/.wenrwa/git.sock');
|
|
260
|
+
expect(env.WENRWA_GIT_TOKEN).toBe('t');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* lr-03 — the box clones a workspace's linked repos into
|
|
5
|
+
* /data/linked/<workspaceId>/<alias> (docs/prd/linked-repos-backend.md).
|
|
6
|
+
*
|
|
7
|
+
* Not /data/repos/<ws>.linked: the checkout inventory treats every directory
|
|
8
|
+
* under /data/repos as a workspace id, so a `<ws>.linked` folder would read as
|
|
9
|
+
* a deleted workspace — a reclaim candidate.
|
|
10
|
+
*/
|
|
11
|
+
const ws_1 = require("ws");
|
|
12
|
+
const linkedRepos_1 = require("../bridge/linkedRepos");
|
|
13
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
14
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
15
|
+
class FakePty {
|
|
16
|
+
opts;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
}
|
|
20
|
+
write() { }
|
|
21
|
+
resize() { }
|
|
22
|
+
kill() { }
|
|
23
|
+
onData() { }
|
|
24
|
+
onExit() { }
|
|
25
|
+
}
|
|
26
|
+
const WS = '11111111-1111-4111-8111-111111111111';
|
|
27
|
+
function harness(opts = {}) {
|
|
28
|
+
const ensured = [];
|
|
29
|
+
const credentialFor = jest.fn(async (_ws, fullName) => ({ token: `tok-${fullName}`, repoFullName: fullName, expiresAt: 'x' }));
|
|
30
|
+
const deps = {
|
|
31
|
+
linkedBaseDir: '/data/linked',
|
|
32
|
+
credentialFor,
|
|
33
|
+
ensure: jest.fn(async (args) => {
|
|
34
|
+
ensured.push({ base: args.baseDir, alias: args.name, repoUrl: args.repoUrl, token: args.gitCredential?.token });
|
|
35
|
+
if (args.name === opts.fail)
|
|
36
|
+
throw new Error('clone failed');
|
|
37
|
+
return { action: 'cloned', path: `${args.baseDir}/${args.name}` };
|
|
38
|
+
}),
|
|
39
|
+
log: jest.fn(),
|
|
40
|
+
};
|
|
41
|
+
return { deps, ensured, credentialFor };
|
|
42
|
+
}
|
|
43
|
+
const repos = [
|
|
44
|
+
{ alias: 'backend', fullName: 'acme/backend', cloneUrl: 'https://github.com/acme/backend.git' },
|
|
45
|
+
{ alias: 'cli', fullName: 'acme/cli', cloneUrl: 'https://github.com/acme/cli.git' },
|
|
46
|
+
];
|
|
47
|
+
describe('ensureLinkedRepos', () => {
|
|
48
|
+
it('ensures each linked repo under /data/linked/<ws>/<alias>, each with ITS OWN credential', async () => {
|
|
49
|
+
const h = harness();
|
|
50
|
+
const results = await (0, linkedRepos_1.ensureLinkedRepos)({ workspaceId: WS, linkedRepos: repos }, h.deps);
|
|
51
|
+
expect(h.ensured).toEqual([
|
|
52
|
+
{ base: `/data/linked/${WS}`, alias: 'backend', repoUrl: 'https://github.com/acme/backend.git', token: 'tok-acme/backend' },
|
|
53
|
+
{ base: `/data/linked/${WS}`, alias: 'cli', repoUrl: 'https://github.com/acme/cli.git', token: 'tok-acme/cli' },
|
|
54
|
+
]);
|
|
55
|
+
expect(results.map((r) => r.action)).toEqual(['cloned', 'cloned']);
|
|
56
|
+
});
|
|
57
|
+
it('one failing clone never stops the others, and never throws', async () => {
|
|
58
|
+
const h = harness({ fail: 'backend' });
|
|
59
|
+
const results = await (0, linkedRepos_1.ensureLinkedRepos)({ workspaceId: WS, linkedRepos: repos }, h.deps);
|
|
60
|
+
expect(results.map((r) => r.action)).toEqual(['failed', 'cloned']);
|
|
61
|
+
expect(h.deps.log).toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
it('a credential the relay refuses → that repo is skipped (no anonymous clone of a private repo)', async () => {
|
|
64
|
+
const h = harness();
|
|
65
|
+
h.credentialFor.mockRejectedValueOnce(Object.assign(new Error('not linked'), { code: 'REPO_NOT_LINKED' }));
|
|
66
|
+
const results = await (0, linkedRepos_1.ensureLinkedRepos)({ workspaceId: WS, linkedRepos: repos }, h.deps);
|
|
67
|
+
expect(h.ensured.map((e) => e.alias)).toEqual(['cli']);
|
|
68
|
+
expect(results[0]).toMatchObject({ alias: 'backend', action: 'failed' });
|
|
69
|
+
});
|
|
70
|
+
it('refuses anything that could escape its folder: bad alias, bad workspace id, a non-github URL', async () => {
|
|
71
|
+
const h = harness();
|
|
72
|
+
await (0, linkedRepos_1.ensureLinkedRepos)({
|
|
73
|
+
workspaceId: WS,
|
|
74
|
+
linkedRepos: [
|
|
75
|
+
{ alias: '../repos', fullName: 'acme/x', cloneUrl: 'https://github.com/acme/x.git' },
|
|
76
|
+
{ alias: 'ok', fullName: 'acme/x', cloneUrl: 'file:///etc' },
|
|
77
|
+
{ alias: 'ok2', fullName: '../../x', cloneUrl: 'https://github.com/acme/x.git' },
|
|
78
|
+
],
|
|
79
|
+
}, h.deps);
|
|
80
|
+
await (0, linkedRepos_1.ensureLinkedRepos)({ workspaceId: '../../etc', linkedRepos: repos }, h.deps);
|
|
81
|
+
expect(h.ensured).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
it('nothing linked → nothing done', async () => {
|
|
84
|
+
const h = harness();
|
|
85
|
+
expect(await (0, linkedRepos_1.ensureLinkedRepos)({ workspaceId: WS, linkedRepos: [] }, h.deps)).toEqual([]);
|
|
86
|
+
expect(h.deps.ensure).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('relay client — an open with linkedRepos (lr-03)', () => {
|
|
90
|
+
function startFakeRelay() {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
93
|
+
const q = [];
|
|
94
|
+
const w = [];
|
|
95
|
+
wss.on('connection', (ws) => { const f = w.shift(); if (f)
|
|
96
|
+
f(ws);
|
|
97
|
+
else
|
|
98
|
+
q.push(ws); });
|
|
99
|
+
wss.on('listening', () => {
|
|
100
|
+
const a = wss.address();
|
|
101
|
+
resolve({ wss, port: typeof a === 'object' && a ? a.port : 0, next: () => (q.length ? Promise.resolve(q.shift()) : new Promise((r) => w.push(r))) });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
it('the terminal opens first; the linked repos are ensured after the primary checkout resolves', async () => {
|
|
106
|
+
const relay = await startFakeRelay();
|
|
107
|
+
const order = [];
|
|
108
|
+
const spawned = [];
|
|
109
|
+
let release;
|
|
110
|
+
const ensureLinkedRepos = jest.fn(async () => { order.push('linked'); });
|
|
111
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
112
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
113
|
+
relayToken: 'tok',
|
|
114
|
+
sessions: new sessionManager_1.SessionManager({ spawn: (o) => { order.push('pty'); spawned.push(o); return new FakePty(o); } }),
|
|
115
|
+
resolveWorkspaceCwd: () => new Promise((r) => { release = r; }),
|
|
116
|
+
ensureLinkedRepos,
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
const ws = await relay.next();
|
|
120
|
+
ws.send(JSON.stringify({
|
|
121
|
+
t: 'open', cols: 80, rows: 24, sessionId: 'agent-A', workspaceId: WS,
|
|
122
|
+
repoUrl: 'https://github.com/acme/app.git',
|
|
123
|
+
linkedRepos: repos,
|
|
124
|
+
}));
|
|
125
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
126
|
+
expect(ensureLinkedRepos).not.toHaveBeenCalled(); // waits for the primary
|
|
127
|
+
release('/data/repos/ws');
|
|
128
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
129
|
+
expect(order).toEqual(['pty', 'linked']);
|
|
130
|
+
expect(ensureLinkedRepos).toHaveBeenCalledWith({ workspaceId: WS, linkedRepos: repos });
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
handle.close();
|
|
134
|
+
for (const c of relay.wss.clients)
|
|
135
|
+
c.terminate();
|
|
136
|
+
await new Promise((r) => relay.wss.close(r));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
it('a failing ensure never touches the session', async () => {
|
|
140
|
+
const relay = await startFakeRelay();
|
|
141
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
142
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
143
|
+
relayToken: 'tok',
|
|
144
|
+
sessions: new sessionManager_1.SessionManager({ spawn: (o) => new FakePty(o) }),
|
|
145
|
+
resolveWorkspaceCwd: async () => '/data/repos/ws',
|
|
146
|
+
ensureLinkedRepos: async () => { throw new Error('boom'); },
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const ws = await relay.next();
|
|
150
|
+
const errors = [];
|
|
151
|
+
ws.on('message', (raw) => { const f = JSON.parse(String(raw)); if (f.t === 'error')
|
|
152
|
+
errors.push(f); });
|
|
153
|
+
ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A', workspaceId: WS, linkedRepos: repos }));
|
|
154
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
155
|
+
expect(errors).toEqual([]);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
handle.close();
|
|
159
|
+
for (const c of relay.wss.clients)
|
|
160
|
+
c.terminate();
|
|
161
|
+
await new Promise((r) => relay.wss.close(r));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface GitCredential {
|
|
2
|
+
token: string;
|
|
3
|
+
repoFullName: string;
|
|
4
|
+
expiresAt: string;
|
|
5
|
+
}
|
|
6
|
+
/** `repo` (owner/repo) is the repo git asked about, when the helper knows it
|
|
7
|
+
* (lr-02: linked repos have credentials of their own). Absent = the primary. */
|
|
8
|
+
export type GitCredentialFetcher = (workspaceId: string, repo?: string) => Promise<GitCredential>;
|
|
9
|
+
/** Ask again once a cached credential has less than this left. */
|
|
10
|
+
export declare const REFRESH_BEFORE_EXPIRY_MS: number;
|
|
11
|
+
export declare class GitTokenBroker {
|
|
12
|
+
private fetch;
|
|
13
|
+
private now;
|
|
14
|
+
private cache;
|
|
15
|
+
private inFlight;
|
|
16
|
+
constructor(fetch: GitCredentialFetcher, now?: () => number);
|
|
17
|
+
get(workspaceId: string, repo?: string): Promise<GitCredential>;
|
|
18
|
+
}
|
|
19
|
+
export interface GitTokenSocketHandle {
|
|
20
|
+
path: string;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
|
|
25
|
+
* socket file left by a previous bridge.
|
|
26
|
+
*/
|
|
27
|
+
export declare function startGitTokenSocket(opts: {
|
|
28
|
+
path: string;
|
|
29
|
+
broker: GitTokenBroker;
|
|
30
|
+
}): Promise<GitTokenSocketHandle>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GitTokenBroker = exports.REFRESH_BEFORE_EXPIRY_MS = void 0;
|
|
7
|
+
exports.startGitTokenSocket = startGitTokenSocket;
|
|
8
|
+
/**
|
|
9
|
+
* a9be9720 — fresh git credentials for a box's agents, for as long as they run.
|
|
10
|
+
*
|
|
11
|
+
* The relay puts a workspace's git token on each PTY's env once, at open
|
|
12
|
+
* (WENRWA_GIT_TOKEN). It is a GitHub App installation token and lives an
|
|
13
|
+
* hour; agent sessions live for days. So every session older than that held a
|
|
14
|
+
* dead credential and could not push — "remote: Invalid username or token",
|
|
15
|
+
* and no retry helped.
|
|
16
|
+
*
|
|
17
|
+
* Now `git-credential-wenrwa get` (infra/terminal-image) asks THIS bridge,
|
|
18
|
+
* over a unix socket, for a credential for $WENRWA_WORKSPACE_ID:
|
|
19
|
+
*
|
|
20
|
+
* GET /git-credential?workspace=<id> → 200 "username=…\npassword=…\n"
|
|
21
|
+
*
|
|
22
|
+
* The answer is already in git's credential-helper format, so the shell helper
|
|
23
|
+
* only has to print it. The bridge keeps each workspace's credential in memory
|
|
24
|
+
* until shortly before it expires, and otherwise asks the relay for a fresh
|
|
25
|
+
* one (`git-token`, relayClient.requestGitToken).
|
|
26
|
+
*
|
|
27
|
+
* Never on disk, never logged. The socket is 0600 and lives in the bridge
|
|
28
|
+
* user's home: every process that can reach it is the same user whose PTYs
|
|
29
|
+
* already carry these tokens on their env.
|
|
30
|
+
*
|
|
31
|
+
* Any failure answers non-2xx with a one-line reason, and the helper falls back
|
|
32
|
+
* to the injected token — so a bridge that cannot mint is never worse than
|
|
33
|
+
* before.
|
|
34
|
+
*/
|
|
35
|
+
const fs_1 = __importDefault(require("fs"));
|
|
36
|
+
const http_1 = __importDefault(require("http"));
|
|
37
|
+
const path_1 = __importDefault(require("path"));
|
|
38
|
+
/** Ask again once a cached credential has less than this left. */
|
|
39
|
+
exports.REFRESH_BEFORE_EXPIRY_MS = 5 * 60_000;
|
|
40
|
+
class GitTokenBroker {
|
|
41
|
+
fetch;
|
|
42
|
+
now;
|
|
43
|
+
cache = new Map();
|
|
44
|
+
inFlight = new Map();
|
|
45
|
+
constructor(fetch, now = Date.now) {
|
|
46
|
+
this.fetch = fetch;
|
|
47
|
+
this.now = now;
|
|
48
|
+
}
|
|
49
|
+
async get(workspaceId, repo) {
|
|
50
|
+
const key = `${workspaceId}\u0000${repo ? repo.toLowerCase() : ''}`;
|
|
51
|
+
const cached = this.cache.get(key);
|
|
52
|
+
if (cached && Date.parse(cached.expiresAt) - this.now() > exports.REFRESH_BEFORE_EXPIRY_MS)
|
|
53
|
+
return cached;
|
|
54
|
+
// A burst of git calls (fetch + push, a submodule walk) makes ONE request.
|
|
55
|
+
const pending = this.inFlight.get(key);
|
|
56
|
+
if (pending)
|
|
57
|
+
return pending;
|
|
58
|
+
const request = (repo ? this.fetch(workspaceId, repo) : this.fetch(workspaceId))
|
|
59
|
+
.then((cred) => {
|
|
60
|
+
this.cache.set(key, cred);
|
|
61
|
+
return cred;
|
|
62
|
+
})
|
|
63
|
+
.finally(() => this.inFlight.delete(key));
|
|
64
|
+
this.inFlight.set(key, request);
|
|
65
|
+
return request;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.GitTokenBroker = GitTokenBroker;
|
|
69
|
+
/** Workspace ids are UUIDs; anything else is refused before it is used. */
|
|
70
|
+
const WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
71
|
+
const REPO = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
72
|
+
/**
|
|
73
|
+
* Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
|
|
74
|
+
* socket file left by a previous bridge.
|
|
75
|
+
*/
|
|
76
|
+
async function startGitTokenSocket(opts) {
|
|
77
|
+
fs_1.default.mkdirSync(path_1.default.dirname(opts.path), { recursive: true, mode: 0o700 });
|
|
78
|
+
try {
|
|
79
|
+
fs_1.default.unlinkSync(opts.path);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* no previous socket */
|
|
83
|
+
}
|
|
84
|
+
const server = http_1.default.createServer((req, res) => {
|
|
85
|
+
const reply = (status, body) => {
|
|
86
|
+
res.writeHead(status, { 'content-type': 'text/plain', 'cache-control': 'no-store' });
|
|
87
|
+
res.end(body);
|
|
88
|
+
};
|
|
89
|
+
const url = new URL(req.url ?? '/', 'http://bridge');
|
|
90
|
+
if (req.method !== 'GET' || url.pathname !== '/git-credential') {
|
|
91
|
+
reply(404, 'not found\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const workspaceId = url.searchParams.get('workspace') ?? '';
|
|
95
|
+
if (!WORKSPACE_ID.test(workspaceId)) {
|
|
96
|
+
reply(400, 'no workspace: WENRWA_WORKSPACE_ID is not set in this environment\n');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const repo = url.searchParams.get('repo') ?? '';
|
|
100
|
+
if (repo && (!REPO.test(repo) || repo.includes('..'))) {
|
|
101
|
+
reply(400, 'repo must be owner/repo\n');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
opts.broker.get(workspaceId, repo || undefined).then((cred) => reply(200, `username=x-access-token\npassword=${cred.token}\n`), (err) => {
|
|
105
|
+
const e = err;
|
|
106
|
+
const code = typeof e?.code === 'string' ? e.code : 'GIT_TOKEN_FAILED';
|
|
107
|
+
const message = typeof e?.message === 'string' ? e.message : 'could not get a git token';
|
|
108
|
+
reply(502, `${code}: ${message}\n`);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
server.once('error', reject);
|
|
113
|
+
server.listen(opts.path, () => {
|
|
114
|
+
server.off('error', reject);
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
fs_1.default.chmodSync(opts.path, 0o600);
|
|
119
|
+
return {
|
|
120
|
+
path: opts.path,
|
|
121
|
+
close: () => new Promise((resolve) => {
|
|
122
|
+
server.close(() => resolve());
|
|
123
|
+
try {
|
|
124
|
+
fs_1.default.unlinkSync(opts.path);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* already gone */
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GitCredential } from './gitTokenBroker';
|
|
2
|
+
export interface LinkedRepoRef {
|
|
3
|
+
alias: string;
|
|
4
|
+
fullName: string;
|
|
5
|
+
cloneUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LinkedRepoResult {
|
|
8
|
+
alias: string;
|
|
9
|
+
action: 'existing' | 'cloned' | 'failed';
|
|
10
|
+
path?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface LinkedRepoDeps {
|
|
13
|
+
/** /data/linked */
|
|
14
|
+
linkedBaseDir: string;
|
|
15
|
+
credentialFor(workspaceId: string, fullName: string): Promise<GitCredential>;
|
|
16
|
+
/** ensureWorkspaceRepo with `reposBaseDir = baseDir`, `workspaceId = name`. */
|
|
17
|
+
ensure(args: {
|
|
18
|
+
baseDir: string;
|
|
19
|
+
name: string;
|
|
20
|
+
repoUrl: string;
|
|
21
|
+
gitCredential?: GitCredential;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
action: string;
|
|
24
|
+
path?: string;
|
|
25
|
+
}>;
|
|
26
|
+
log?(line: string): void;
|
|
27
|
+
}
|
|
28
|
+
export declare function ensureLinkedRepos(opts: {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
linkedRepos: LinkedRepoRef[];
|
|
31
|
+
}, deps: LinkedRepoDeps): Promise<LinkedRepoResult[]>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ensureLinkedRepos = ensureLinkedRepos;
|
|
7
|
+
/**
|
|
8
|
+
* lr-03 — clone a workspace's LINKED repos on the box
|
|
9
|
+
* (docs/prd/linked-repos-backend.md).
|
|
10
|
+
*
|
|
11
|
+
* The relay's `open` frame lists them; once the primary checkout resolves the
|
|
12
|
+
* bridge ensures each one at /data/linked/<workspaceId>/<alias>, in the
|
|
13
|
+
* background, through the SAME ensureWorkspaceRepo the primary uses (clone
|
|
14
|
+
* mutex, free-space and storage-quota preflights, interrupted-clone cleanup).
|
|
15
|
+
*
|
|
16
|
+
* - Not under /data/repos: the checkout inventory treats every directory there
|
|
17
|
+
* as a workspace id, so `<ws>.linked` would read as a deleted workspace.
|
|
18
|
+
* - Each repo gets ITS OWN credential (read-only unless the link may push),
|
|
19
|
+
* fetched fresh from the relay; a refused credential skips that repo rather
|
|
20
|
+
* than cloning a private repo anonymously.
|
|
21
|
+
* - Never deletes anything: an unlinked repo's clone stays on the volume.
|
|
22
|
+
* - Never throws: a failed clone must not break the terminal that opened.
|
|
23
|
+
*/
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const ALIAS = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
26
|
+
const WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
27
|
+
const FULL_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
28
|
+
const GITHUB_CLONE_URL = /^https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\.git$/;
|
|
29
|
+
async function ensureLinkedRepos(opts, deps) {
|
|
30
|
+
if (!WORKSPACE_ID.test(opts.workspaceId))
|
|
31
|
+
return [];
|
|
32
|
+
const baseDir = path_1.default.join(deps.linkedBaseDir, opts.workspaceId);
|
|
33
|
+
const results = [];
|
|
34
|
+
// One at a time: two big clones at once on a small box is how a disk fills.
|
|
35
|
+
for (const ref of opts.linkedRepos) {
|
|
36
|
+
if (!ALIAS.test(ref.alias) || !FULL_NAME.test(ref.fullName) || ref.fullName.includes('..') || !GITHUB_CLONE_URL.test(ref.cloneUrl)) {
|
|
37
|
+
deps.log?.(`[linked] skipped a malformed linked repo entry`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const gitCredential = await deps.credentialFor(opts.workspaceId, ref.fullName);
|
|
42
|
+
const r = await deps.ensure({ baseDir, name: ref.alias, repoUrl: ref.cloneUrl, gitCredential });
|
|
43
|
+
const action = r.action === 'existing' || r.action === 'cloned' ? r.action : 'failed';
|
|
44
|
+
results.push({ alias: ref.alias, action, ...(r.path ? { path: r.path } : {}) });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
deps.log?.(`[linked] ${ref.alias} (${ref.fullName}) not ready: ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
+
results.push({ alias: ref.alias, action: 'failed' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ht-05 — Terminal Bridge relay mode (dial-out).
|
|
3
|
+
*
|
|
4
|
+
* Instead of listening on localhost for browser connections (server.ts),
|
|
5
|
+
* this DIALS OUT to the platform relay over a single outbound wss
|
|
6
|
+
* connection, authenticates with a per-container relay token, and serves
|
|
7
|
+
* the same v1 frame protocol (see PROTOCOL.md) through the existing
|
|
8
|
+
* SessionManager. Multi-session multiplexing is unchanged: every frame
|
|
9
|
+
* already carries a sessionId, so one outbound socket serves every session
|
|
10
|
+
* exactly like the local server serves them over N inbound sockets.
|
|
11
|
+
*
|
|
12
|
+
* PTYs live in the SessionManager, not the socket — a relay drop never
|
|
13
|
+
* kills a session. This module owns only: dialing out, the auth frame,
|
|
14
|
+
* frame routing through SessionManager, and reconnect-with-backoff. It
|
|
15
|
+
* does no scrollback bookkeeping of its own (SessionManager already does
|
|
16
|
+
* that) — on re-pair the pane sends `attach` again and gets a fresh
|
|
17
|
+
* `replay`, exactly as it would against the local server.
|
|
18
|
+
*/
|
|
19
|
+
import type { LinkedRepoRef } from './linkedRepos';
|
|
1
20
|
import { type OpencodeLaunchInput } from './agentLaunch';
|
|
2
21
|
import type { PreparedLaunch } from './opencodeBinary';
|
|
3
22
|
import { SessionManager } from './sessionManager';
|
|
@@ -77,6 +96,8 @@ export declare function assembleSessionEnv(frame: {
|
|
|
77
96
|
};
|
|
78
97
|
env?: Record<string, string>;
|
|
79
98
|
sessionId?: string;
|
|
99
|
+
/** a9be9720 — where git-credential-wenrwa asks this bridge for a fresh token. */
|
|
100
|
+
gitTokenSocket?: string;
|
|
80
101
|
}): Record<string, string>;
|
|
81
102
|
/** Minimal surface relayClient needs from a socket — lets tests inject fakes if ever needed. */
|
|
82
103
|
export interface WebSocketLike {
|
|
@@ -103,6 +124,16 @@ export interface BackoffOpts {
|
|
|
103
124
|
jitter?: () => number;
|
|
104
125
|
}
|
|
105
126
|
export interface RelayClientOpts {
|
|
127
|
+
/** lr-03 — clone the workspace's linked repos once its primary checkout
|
|
128
|
+
* resolves (linkedRepos.ts). Background, best-effort. Absent → ignored. */
|
|
129
|
+
ensureLinkedRepos?: (opts: {
|
|
130
|
+
workspaceId: string;
|
|
131
|
+
linkedRepos: LinkedRepoRef[];
|
|
132
|
+
}) => Promise<unknown>;
|
|
133
|
+
/** a9be9720 — the unix socket this bridge serves fresh git credentials on
|
|
134
|
+
* (gitTokenBroker.ts). Put on every workspace PTY's env. Absent → the
|
|
135
|
+
* helper uses the token injected at open, as before. */
|
|
136
|
+
gitTokenSocket?: string;
|
|
106
137
|
relayUrl: string;
|
|
107
138
|
relayToken: string;
|
|
108
139
|
sessions: SessionManager;
|
|
@@ -716,6 +747,16 @@ export interface RelayClientHandle {
|
|
|
716
747
|
/** Stop reconnecting and close the current socket. Sessions are untouched — they live in SessionManager. */
|
|
717
748
|
close(): void;
|
|
718
749
|
isConnected(): boolean;
|
|
750
|
+
/**
|
|
751
|
+
* a9be9720 — ask the relay for a fresh git credential for one workspace.
|
|
752
|
+
* Rejects at once (code OFFLINE) with no relay connection, and with the
|
|
753
|
+
* relay's own code/sentence on a refusal; bounded, never hangs.
|
|
754
|
+
*/
|
|
755
|
+
requestGitToken(workspaceId: string, repo?: string): Promise<{
|
|
756
|
+
token: string;
|
|
757
|
+
repoFullName: string;
|
|
758
|
+
expiresAt: string;
|
|
759
|
+
}>;
|
|
719
760
|
/**
|
|
720
761
|
* stc-05 → stc-06 — free caches + idle preview slots before a disk grows.
|
|
721
762
|
* Behind a kill switch (storageCleanupDeps.autoCleanupEnabled, default
|
|
@@ -38,24 +38,6 @@ exports.assembleSessionEnv = assembleSessionEnv;
|
|
|
38
38
|
exports.formatHealthLine = formatHealthLine;
|
|
39
39
|
exports.formatDisconnectLine = formatDisconnectLine;
|
|
40
40
|
exports.startRelayClient = startRelayClient;
|
|
41
|
-
/**
|
|
42
|
-
* ht-05 — Terminal Bridge relay mode (dial-out).
|
|
43
|
-
*
|
|
44
|
-
* Instead of listening on localhost for browser connections (server.ts),
|
|
45
|
-
* this DIALS OUT to the platform relay over a single outbound wss
|
|
46
|
-
* connection, authenticates with a per-container relay token, and serves
|
|
47
|
-
* the same v1 frame protocol (see PROTOCOL.md) through the existing
|
|
48
|
-
* SessionManager. Multi-session multiplexing is unchanged: every frame
|
|
49
|
-
* already carries a sessionId, so one outbound socket serves every session
|
|
50
|
-
* exactly like the local server serves them over N inbound sockets.
|
|
51
|
-
*
|
|
52
|
-
* PTYs live in the SessionManager, not the socket — a relay drop never
|
|
53
|
-
* kills a session. This module owns only: dialing out, the auth frame,
|
|
54
|
-
* frame routing through SessionManager, and reconnect-with-backoff. It
|
|
55
|
-
* does no scrollback bookkeeping of its own (SessionManager already does
|
|
56
|
-
* that) — on re-pair the pane sends `attach` again and gets a fresh
|
|
57
|
-
* `replay`, exactly as it would against the local server.
|
|
58
|
-
*/
|
|
59
41
|
const redact_1 = require("../secrets/redact");
|
|
60
42
|
const agentLaunch_1 = require("./agentLaunch");
|
|
61
43
|
const resumeDetector_1 = require("./resumeDetector");
|
|
@@ -189,6 +171,10 @@ function assembleSessionEnv(frame) {
|
|
|
189
171
|
env.WENRWA_GIT_TOKEN = frame.gitCredential.token;
|
|
190
172
|
env.WENRWA_GIT_REPO = frame.gitCredential.repoFullName;
|
|
191
173
|
}
|
|
174
|
+
// a9be9720 — the token above is an hour-lived GitHub token injected once;
|
|
175
|
+
// the helper asks the bridge here first and keeps it only as a fallback.
|
|
176
|
+
if (frame.gitTokenSocket)
|
|
177
|
+
env.WENRWA_GIT_TOKEN_SOCKET = frame.gitTokenSocket;
|
|
192
178
|
// grad-06 — the commit identity. Nothing set one, so `git commit` failed in
|
|
193
179
|
// every hosted terminal with "Author identity unknown": a container has no
|
|
194
180
|
// domain in its hostname, so git refuses to auto-detect an address. Both
|
|
@@ -245,6 +231,8 @@ function describeDialError(err) {
|
|
|
245
231
|
}
|
|
246
232
|
return String(err);
|
|
247
233
|
}
|
|
234
|
+
/** a9be9720 — how long a `git-token` request may go unanswered. `git` waits on it. */
|
|
235
|
+
const GIT_TOKEN_REQUEST_TIMEOUT_MS = 10_000;
|
|
248
236
|
function send(ws, frame) {
|
|
249
237
|
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
250
238
|
ws.send(JSON.stringify(frame));
|
|
@@ -491,6 +479,32 @@ function startRelayClient(opts) {
|
|
|
491
479
|
let stopped = false;
|
|
492
480
|
let ws = null;
|
|
493
481
|
let connected = false;
|
|
482
|
+
// a9be9720 — outstanding git-token requests, by requestId.
|
|
483
|
+
const pendingGitTokens = new Map();
|
|
484
|
+
let gitTokenSeq = 0;
|
|
485
|
+
const gitTokenError = (code, message) => Object.assign(new Error(message), { code });
|
|
486
|
+
const failPendingGitTokens = (code, message) => {
|
|
487
|
+
for (const [id, p] of pendingGitTokens) {
|
|
488
|
+
clearTimeout(p.timer);
|
|
489
|
+
pendingGitTokens.delete(id);
|
|
490
|
+
p.reject(gitTokenError(code, message));
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const requestGitToken = (workspaceId, repo) => new Promise((resolve, reject) => {
|
|
494
|
+
const socket = ws;
|
|
495
|
+
if (!connected || !socket || socket.readyState !== ws_1.WebSocket.OPEN) {
|
|
496
|
+
reject(gitTokenError('OFFLINE', 'The bridge is not connected to HiveOp'));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const requestId = `gt-${++gitTokenSeq}-${Date.now().toString(36)}`;
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
pendingGitTokens.delete(requestId);
|
|
502
|
+
reject(gitTokenError('TIMEOUT', 'HiveOp did not answer the git token request'));
|
|
503
|
+
}, GIT_TOKEN_REQUEST_TIMEOUT_MS);
|
|
504
|
+
timer.unref?.();
|
|
505
|
+
pendingGitTokens.set(requestId, { resolve, reject, timer });
|
|
506
|
+
send(socket, { t: 'git-token', requestId, workspaceId, ...(repo ? { repo } : {}) });
|
|
507
|
+
});
|
|
494
508
|
let attempt = 0;
|
|
495
509
|
let reconnectHandle = null;
|
|
496
510
|
let healthHandle = null;
|
|
@@ -1050,10 +1064,24 @@ function startRelayClient(opts) {
|
|
|
1050
1064
|
}
|
|
1051
1065
|
try {
|
|
1052
1066
|
switch (frame.t) {
|
|
1067
|
+
case 'git-token': {
|
|
1068
|
+
const p = pendingGitTokens.get(frame.requestId);
|
|
1069
|
+
if (!p)
|
|
1070
|
+
break;
|
|
1071
|
+
pendingGitTokens.delete(frame.requestId);
|
|
1072
|
+
clearTimeout(p.timer);
|
|
1073
|
+
if (frame.ok && frame.token && frame.repoFullName && frame.expiresAt) {
|
|
1074
|
+
p.resolve({ token: frame.token, repoFullName: frame.repoFullName, expiresAt: frame.expiresAt });
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
p.reject(gitTokenError(frame.code ?? 'GIT_TOKEN_FAILED', frame.message ?? 'HiveOp could not mint a git token'));
|
|
1078
|
+
}
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1053
1081
|
case 'open': {
|
|
1054
1082
|
// Capture into locals so the async .then closures below keep the
|
|
1055
1083
|
// discriminated-union narrowing TS would otherwise drop.
|
|
1056
|
-
const { cols, rows, workspaceId, repoUrl, autonomous, connectToken, gitCredential, gitIdentity, opencode } = frame;
|
|
1084
|
+
const { cols, rows, workspaceId, repoUrl, autonomous, connectToken, gitCredential, gitIdentity, opencode, linkedRepos } = frame;
|
|
1057
1085
|
// pucc-14 multiplex: the RELAY owns the session id (== agentId) and
|
|
1058
1086
|
// stamps it on the open + every later frame. Honor it so the PTY is
|
|
1059
1087
|
// registered under exactly that id and container→pane frames demux
|
|
@@ -1231,11 +1259,19 @@ function startRelayClient(opts) {
|
|
|
1231
1259
|
// shared container. autolaunch.sh reads HT_AUTONOMOUS from the
|
|
1232
1260
|
// PTY's own env (claude vs claude --dangerously-skip-permissions),
|
|
1233
1261
|
// NOT a machine-wide var.
|
|
1234
|
-
const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, gitIdentity, env: injectedEnv, sessionId: relaySessionId });
|
|
1262
|
+
const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, gitIdentity, env: injectedEnv, sessionId: relaySessionId, gitTokenSocket: opts.gitTokenSocket });
|
|
1235
1263
|
if (opts.resolveWorkspaceCwd) {
|
|
1236
1264
|
opts
|
|
1237
1265
|
.resolveWorkspaceCwd({ workspaceId, repoUrl, gitCredential })
|
|
1238
|
-
.then((cwd) =>
|
|
1266
|
+
.then((cwd) => {
|
|
1267
|
+
openSession(cwd ?? undefined, env);
|
|
1268
|
+
// lr-03 — after the PTY: a linked clone never delays the terminal.
|
|
1269
|
+
if (cwd && Array.isArray(linkedRepos) && linkedRepos.length > 0 && opts.ensureLinkedRepos) {
|
|
1270
|
+
Promise.resolve()
|
|
1271
|
+
.then(() => opts.ensureLinkedRepos({ workspaceId, linkedRepos }))
|
|
1272
|
+
.catch((err) => console.error(`[bridge] linked repos for ${workspaceId}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1273
|
+
}
|
|
1274
|
+
})
|
|
1239
1275
|
.catch((err) => {
|
|
1240
1276
|
// hcs-07 — a clone-time preflight refusal is a typed,
|
|
1241
1277
|
// user-actionable refusal (see diskPreflight.ts), not an
|
|
@@ -1986,6 +2022,7 @@ function startRelayClient(opts) {
|
|
|
1986
2022
|
});
|
|
1987
2023
|
const onDown = (info) => {
|
|
1988
2024
|
connected = false;
|
|
2025
|
+
failPendingGitTokens('OFFLINE', 'The bridge lost its HiveOp connection');
|
|
1989
2026
|
stopHeartbeat();
|
|
1990
2027
|
// Detach (not close) every live handle: the sessions/PTYs survive the
|
|
1991
2028
|
// drop in the SessionManager, scrollback keeps accumulating, and the
|
|
@@ -2184,9 +2221,11 @@ function startRelayClient(opts) {
|
|
|
2184
2221
|
if (hookActivityHandle !== null)
|
|
2185
2222
|
cancelInterval(hookActivityHandle);
|
|
2186
2223
|
opts.companionForwarder?.stop(); // bws-08 — close every forwarded listener on bridge shutdown
|
|
2224
|
+
failPendingGitTokens('OFFLINE', 'The bridge is shutting down');
|
|
2187
2225
|
ws?.close();
|
|
2188
2226
|
},
|
|
2189
2227
|
isConnected: () => connected,
|
|
2228
|
+
requestGitToken,
|
|
2190
2229
|
runAutomaticCleanup,
|
|
2191
2230
|
};
|
|
2192
2231
|
}
|
package/dist/commands/bridge.js
CHANGED
|
@@ -64,6 +64,8 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
64
64
|
const sessionManager_1 = require("../bridge/sessionManager");
|
|
65
65
|
const server_1 = require("../bridge/server");
|
|
66
66
|
const relayClient_1 = require("../bridge/relayClient");
|
|
67
|
+
const gitTokenBroker_1 = require("../bridge/gitTokenBroker");
|
|
68
|
+
const linkedRepos_1 = require("../bridge/linkedRepos");
|
|
67
69
|
const browserDriver_1 = require("../bridge/browserDriver");
|
|
68
70
|
const browserHost_1 = require("../bridge/browserHost");
|
|
69
71
|
const browserIdle_1 = require("../bridge/browserIdle");
|
|
@@ -619,8 +621,11 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
619
621
|
* (that PTY-scoped invariant is exactly why the raw Fly exec could never
|
|
620
622
|
* have cloned for itself — see issues/msf-34).
|
|
621
623
|
*/
|
|
622
|
-
|
|
623
|
-
|
|
624
|
+
// lr-03: the same checkout, in any base dir — /data/repos for a
|
|
625
|
+
// workspace's primary, /data/linked/<ws> for its linked repos. Free space
|
|
626
|
+
// and quota are still measured on /data/repos (same volume, always there).
|
|
627
|
+
const checkoutIn = (baseDir) => (args) => (0, repoCheckout_1.ensureWorkspaceRepo)({ workspaceId: args.workspaceId, repoUrl: args.repoUrl }, {
|
|
628
|
+
reposBaseDir: baseDir,
|
|
624
629
|
exists: (p) => fs.existsSync(p),
|
|
625
630
|
mkdir: (p) => fs.mkdirSync(p, { recursive: true }),
|
|
626
631
|
// msf-34: the clone gets the workspace credential IN ITS ENV.
|
|
@@ -664,6 +669,7 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
664
669
|
// does not).
|
|
665
670
|
rm: rmCheckoutDir,
|
|
666
671
|
});
|
|
672
|
+
const ensureWorkspaceCheckout = checkoutIn(reposBaseDir);
|
|
667
673
|
// tp-06 (ADR-0014) — the Team preview forwarder. Started here, in the
|
|
668
674
|
// container branch only: the local bridge never opens it. It is also a
|
|
669
675
|
// no-op without a `fly-local-6pn` entry, and stays off until
|
|
@@ -682,10 +688,30 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
682
688
|
});
|
|
683
689
|
// cvt-07: the box's one shared Chromium, created lazily by the first tab.
|
|
684
690
|
let browserHost;
|
|
685
|
-
|
|
691
|
+
// a9be9720 — agents' git credentials stay fresh past the hour the
|
|
692
|
+
// injected one lives: git-credential-wenrwa asks this socket, the broker
|
|
693
|
+
// asks the relay. Hosted lane only — a laptop pushes with its own git.
|
|
694
|
+
const gitTokenSocket = process.env.WENRWA_GIT_TOKEN_SOCKET || path.join(os.homedir(), '.wenrwa', 'git-token.sock');
|
|
695
|
+
let relayClient = null;
|
|
696
|
+
const gitTokenBroker = new gitTokenBroker_1.GitTokenBroker((workspaceId, repo) => relayClient
|
|
697
|
+
? relayClient.requestGitToken(workspaceId, repo)
|
|
698
|
+
: Promise.reject(Object.assign(new Error('The bridge is starting'), { code: 'OFFLINE' })));
|
|
699
|
+
(0, gitTokenBroker_1.startGitTokenSocket)({ path: gitTokenSocket, broker: gitTokenBroker }).catch((err) => console.error(`[bridge] git token socket failed to start — agents fall back to the token from open: ${err instanceof Error ? err.message : String(err)}`));
|
|
700
|
+
// lr-03 — a workspace's linked repos, cloned beside (never inside)
|
|
701
|
+
// /data/repos, each with its own credential from the broker.
|
|
702
|
+
const linkedBaseDir = process.env.WENRWA_LINKED_DIR || path.join(path.dirname(reposBaseDir), 'linked');
|
|
703
|
+
const ensureLinked = (args) => (0, linkedRepos_1.ensureLinkedRepos)(args, {
|
|
704
|
+
linkedBaseDir,
|
|
705
|
+
credentialFor: (workspaceId, fullName) => gitTokenBroker.get(workspaceId, fullName),
|
|
706
|
+
ensure: ({ baseDir, name, repoUrl, gitCredential }) => checkoutIn(baseDir)({ workspaceId: name, repoUrl, gitCredential }),
|
|
707
|
+
log: (line) => console.error(line),
|
|
708
|
+
});
|
|
709
|
+
relayClient = (0, relayClient_1.startRelayClient)({
|
|
686
710
|
relayUrl: relayMode.relayUrl,
|
|
687
711
|
relayToken: relayMode.relayToken,
|
|
688
712
|
sessions,
|
|
713
|
+
gitTokenSocket,
|
|
714
|
+
ensureLinkedRepos: ensureLinked,
|
|
689
715
|
// Rock-solid sleep/wake: this is the HOSTED container bridge, the only
|
|
690
716
|
// process that knows which agent PTYs are actually running. The backend
|
|
691
717
|
// reconciles claude_code_agents.lifecycle_state against this report —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenrwa/marketplace-cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.14",
|
|
4
4
|
"description": "CLI tool for the Wenrwa Agent Marketplace — monitor bounties, agents, workspaces, and treasury from your terminal",
|
|
5
5
|
"author": "Wenrwa <dev@wenrwa.com>",
|
|
6
6
|
"license": "MIT",
|