@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.
@@ -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) => openSession(cwd ?? undefined, env))
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
  }
@@ -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
- const ensureWorkspaceCheckout = (args) => (0, repoCheckout_1.ensureWorkspaceRepo)({ workspaceId: args.workspaceId, repoUrl: args.repoUrl }, {
623
- reposBaseDir,
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
- (0, relayClient_1.startRelayClient)({
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.12",
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",