@wenrwa/marketplace-cli 0.8.13 → 0.8.15
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__/bridge-linked-repos-pointer.test.d.ts +1 -0
- package/dist/__tests__/bridge-linked-repos-pointer.test.js +44 -0
- package/dist/__tests__/git-token-refresh.test.js +33 -2
- package/dist/__tests__/linked-repos-clone.test.d.ts +1 -0
- package/dist/__tests__/linked-repos-clone.test.js +164 -0
- package/dist/bridge/contextWriter.d.ts +7 -0
- package/dist/bridge/contextWriter.js +3 -0
- package/dist/bridge/gitTokenBroker.d.ts +4 -2
- package/dist/bridge/gitTokenBroker.js +15 -8
- package/dist/bridge/linkedRepos.d.ts +35 -0
- package/dist/bridge/linkedRepos.js +57 -0
- package/dist/bridge/relayClient.d.ts +27 -1
- package/dist/bridge/relayClient.js +14 -22
- package/dist/bridge/workGraphPointer.d.ts +13 -0
- package/dist/bridge/workGraphPointer.js +16 -0
- package/dist/commands/bridge.js +33 -5
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* lr-03 — the managed region tells an agent which linked repos it has, where
|
|
5
|
+
* they are on the box, and whether it may push to each. Without this an agent
|
|
6
|
+
* never learns the backend sits at /data/linked/<ws>/backend.
|
|
7
|
+
*/
|
|
8
|
+
const contextWriter_1 = require("../bridge/contextWriter");
|
|
9
|
+
const workGraphPointer_1 = require("../bridge/workGraphPointer");
|
|
10
|
+
const WS = '11111111-1111-4111-8111-111111111111';
|
|
11
|
+
const LINKED = [
|
|
12
|
+
{ alias: 'backend', fullName: 'acme/backend', writable: true, path: `/data/linked/${WS}/backend` },
|
|
13
|
+
{ alias: 'cli', fullName: 'acme/cli', writable: false, path: `/data/linked/${WS}/cli` },
|
|
14
|
+
];
|
|
15
|
+
describe('linkedReposSection', () => {
|
|
16
|
+
it('one line per repo: alias, repo, path, and whether pushing is allowed', () => {
|
|
17
|
+
expect((0, workGraphPointer_1.linkedReposSection)({ linkedRepos: LINKED })).toBe([
|
|
18
|
+
'## Linked repos',
|
|
19
|
+
'This workspace also has these repos, each its own checkout:',
|
|
20
|
+
`- \`backend\` (acme/backend) at /data/linked/${WS}/backend — you can push`,
|
|
21
|
+
`- \`cli\` (acme/cli) at /data/linked/${WS}/cli — read-only, pushes are refused`,
|
|
22
|
+
].join('\n'));
|
|
23
|
+
});
|
|
24
|
+
it('nothing linked → no section', () => {
|
|
25
|
+
expect((0, workGraphPointer_1.linkedReposSection)({ linkedRepos: [] })).toBeNull();
|
|
26
|
+
expect((0, workGraphPointer_1.linkedReposSection)({})).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('writeContext carries it inside the pointer block', () => {
|
|
30
|
+
it('a pointers-only refresh writes the section between the pointer markers', () => {
|
|
31
|
+
const files = new Map();
|
|
32
|
+
(0, contextWriter_1.writeContext)({ workspaceId: WS, pointersOnly: true, linkedRepos: LINKED }, {
|
|
33
|
+
reposBaseDir: '/data/repos',
|
|
34
|
+
exists: (p) => files.has(p),
|
|
35
|
+
readFile: (p) => files.get(p) ?? '',
|
|
36
|
+
writeFile: (p, c) => { files.set(p, c); },
|
|
37
|
+
mkdirp: () => { },
|
|
38
|
+
});
|
|
39
|
+
const written = [...files.values()].join('\n');
|
|
40
|
+
const block = written.slice(written.indexOf(contextWriter_1.POINTERS_START_MARKER), written.indexOf(contextWriter_1.POINTERS_END_MARKER));
|
|
41
|
+
expect(block).toContain('## Linked repos');
|
|
42
|
+
expect(block).toContain(`/data/linked/${WS}/cli — read-only`);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -88,6 +88,10 @@ describe('relay client — requestGitToken (a9be9720)', () => {
|
|
|
88
88
|
const pending = handle.requestGitToken('ws-1');
|
|
89
89
|
const req = await asked;
|
|
90
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' });
|
|
91
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' }));
|
|
92
96
|
await expect(pending).resolves.toEqual({ token: 'ghs_new', repoFullName: 'acme/w', expiresAt: '2026-10-24T13:00:00.000Z' });
|
|
93
97
|
}
|
|
@@ -139,6 +143,14 @@ describe('GitTokenBroker — one fresh credential per workspace, cached until sh
|
|
|
139
143
|
expect((await broker.get('ws-1')).token).toBe('t2');
|
|
140
144
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
141
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
|
+
});
|
|
142
154
|
it('workspaces never share a credential', async () => {
|
|
143
155
|
const fetch = jest.fn(async (ws) => ({ token: `tok-${ws}`, repoFullName: ws, expiresAt: inMinutes(15) }));
|
|
144
156
|
const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
|
|
@@ -179,11 +191,30 @@ describe('git token socket + the box\'s git-credential-wenrwa helper', () => {
|
|
|
179
191
|
server = null;
|
|
180
192
|
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
181
193
|
});
|
|
182
|
-
const runHelper = (env) => new Promise((resolve) => {
|
|
194
|
+
const runHelper = (env, stdin = 'protocol=https\nhost=github.com\n\n') => new Promise((resolve) => {
|
|
183
195
|
const child = (0, child_process_1.execFile)('sh', [HELPER, 'get'], { env: { PATH: process.env.PATH ?? '', ...env } }, (err, stdout, stderr) => {
|
|
184
196
|
resolve({ code: err ? err.code ?? 1 : 0, stdout, stderr });
|
|
185
197
|
});
|
|
186
|
-
child.stdin?.end(
|
|
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]]);
|
|
187
218
|
});
|
|
188
219
|
it('the socket is owner-only (0600)', async () => {
|
|
189
220
|
server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(async () => ({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) })) });
|
|
@@ -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
|
+
});
|
|
@@ -35,6 +35,13 @@ export declare function writeContext(input: {
|
|
|
35
35
|
/** Absent only for a pointers-only refresh (wmr-07). */
|
|
36
36
|
content?: string;
|
|
37
37
|
workGraphAvailable?: boolean;
|
|
38
|
+
/** lr-03 — this workspace's linked repos on the box (linkedReposSection). */
|
|
39
|
+
linkedRepos?: Array<{
|
|
40
|
+
alias: string;
|
|
41
|
+
fullName: string;
|
|
42
|
+
writable: boolean;
|
|
43
|
+
path: string;
|
|
44
|
+
}>;
|
|
38
45
|
/** 2026-10-02 — a match workspace: MATCH_POINTER replaces the work-queue pointer (workSection). */
|
|
39
46
|
match?: boolean;
|
|
40
47
|
/**
|
|
@@ -173,6 +173,9 @@ function writeContextFile(input, claudeMdPath, seedPath, workspaceRoot, deps) {
|
|
|
173
173
|
pointers.push(work);
|
|
174
174
|
if (input.memoryAvailable)
|
|
175
175
|
pointers.push((0, memoryPointer_1.memorySection)(input.memoryBrief));
|
|
176
|
+
const linked = (0, workGraphPointer_1.linkedReposSection)(input);
|
|
177
|
+
if (linked)
|
|
178
|
+
pointers.push(linked);
|
|
176
179
|
const browser = (0, workGraphPointer_1.browserSection)(input);
|
|
177
180
|
if (browser)
|
|
178
181
|
pointers.push(browser);
|
|
@@ -3,7 +3,9 @@ export interface GitCredential {
|
|
|
3
3
|
repoFullName: string;
|
|
4
4
|
expiresAt: string;
|
|
5
5
|
}
|
|
6
|
-
|
|
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>;
|
|
7
9
|
/** Ask again once a cached credential has less than this left. */
|
|
8
10
|
export declare const REFRESH_BEFORE_EXPIRY_MS: number;
|
|
9
11
|
export declare class GitTokenBroker {
|
|
@@ -12,7 +14,7 @@ export declare class GitTokenBroker {
|
|
|
12
14
|
private cache;
|
|
13
15
|
private inFlight;
|
|
14
16
|
constructor(fetch: GitCredentialFetcher, now?: () => number);
|
|
15
|
-
get(workspaceId: string): Promise<GitCredential>;
|
|
17
|
+
get(workspaceId: string, repo?: string): Promise<GitCredential>;
|
|
16
18
|
}
|
|
17
19
|
export interface GitTokenSocketHandle {
|
|
18
20
|
path: string;
|
|
@@ -46,27 +46,29 @@ class GitTokenBroker {
|
|
|
46
46
|
this.fetch = fetch;
|
|
47
47
|
this.now = now;
|
|
48
48
|
}
|
|
49
|
-
async get(workspaceId) {
|
|
50
|
-
const
|
|
49
|
+
async get(workspaceId, repo) {
|
|
50
|
+
const key = `${workspaceId}\u0000${repo ? repo.toLowerCase() : ''}`;
|
|
51
|
+
const cached = this.cache.get(key);
|
|
51
52
|
if (cached && Date.parse(cached.expiresAt) - this.now() > exports.REFRESH_BEFORE_EXPIRY_MS)
|
|
52
53
|
return cached;
|
|
53
54
|
// A burst of git calls (fetch + push, a submodule walk) makes ONE request.
|
|
54
|
-
const pending = this.inFlight.get(
|
|
55
|
+
const pending = this.inFlight.get(key);
|
|
55
56
|
if (pending)
|
|
56
57
|
return pending;
|
|
57
|
-
const request = this.fetch(workspaceId)
|
|
58
|
+
const request = (repo ? this.fetch(workspaceId, repo) : this.fetch(workspaceId))
|
|
58
59
|
.then((cred) => {
|
|
59
|
-
this.cache.set(
|
|
60
|
+
this.cache.set(key, cred);
|
|
60
61
|
return cred;
|
|
61
62
|
})
|
|
62
|
-
.finally(() => this.inFlight.delete(
|
|
63
|
-
this.inFlight.set(
|
|
63
|
+
.finally(() => this.inFlight.delete(key));
|
|
64
|
+
this.inFlight.set(key, request);
|
|
64
65
|
return request;
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
exports.GitTokenBroker = GitTokenBroker;
|
|
68
69
|
/** Workspace ids are UUIDs; anything else is refused before it is used. */
|
|
69
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}$/;
|
|
70
72
|
/**
|
|
71
73
|
* Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
|
|
72
74
|
* socket file left by a previous bridge.
|
|
@@ -94,7 +96,12 @@ async function startGitTokenSocket(opts) {
|
|
|
94
96
|
reply(400, 'no workspace: WENRWA_WORKSPACE_ID is not set in this environment\n');
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
|
-
|
|
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) => {
|
|
98
105
|
const e = err;
|
|
99
106
|
const code = typeof e?.code === 'string' ? e.code : 'GIT_TOKEN_FAILED';
|
|
100
107
|
const message = typeof e?.message === 'string' ? e.message : 'could not get a git token';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GitCredential } from './gitTokenBroker';
|
|
2
|
+
export interface LinkedRepoRef {
|
|
3
|
+
alias: string;
|
|
4
|
+
fullName: string;
|
|
5
|
+
cloneUrl: string;
|
|
6
|
+
/** Shown to the agent (context pointer); GitHub enforces it via the token. */
|
|
7
|
+
writable?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** /data/linked/<workspaceId>/<alias> — the ONE place a linked clone lives. */
|
|
10
|
+
export declare function linkedRepoPath(linkedBaseDir: string, workspaceId: string, alias: string): string;
|
|
11
|
+
export interface LinkedRepoResult {
|
|
12
|
+
alias: string;
|
|
13
|
+
action: 'existing' | 'cloned' | 'failed';
|
|
14
|
+
path?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface LinkedRepoDeps {
|
|
17
|
+
/** /data/linked */
|
|
18
|
+
linkedBaseDir: string;
|
|
19
|
+
credentialFor(workspaceId: string, fullName: string): Promise<GitCredential>;
|
|
20
|
+
/** ensureWorkspaceRepo with `reposBaseDir = baseDir`, `workspaceId = name`. */
|
|
21
|
+
ensure(args: {
|
|
22
|
+
baseDir: string;
|
|
23
|
+
name: string;
|
|
24
|
+
repoUrl: string;
|
|
25
|
+
gitCredential?: GitCredential;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
action: string;
|
|
28
|
+
path?: string;
|
|
29
|
+
}>;
|
|
30
|
+
log?(line: string): void;
|
|
31
|
+
}
|
|
32
|
+
export declare function ensureLinkedRepos(opts: {
|
|
33
|
+
workspaceId: string;
|
|
34
|
+
linkedRepos: LinkedRepoRef[];
|
|
35
|
+
}, deps: LinkedRepoDeps): Promise<LinkedRepoResult[]>;
|
|
@@ -0,0 +1,57 @@
|
|
|
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.linkedRepoPath = linkedRepoPath;
|
|
7
|
+
exports.ensureLinkedRepos = ensureLinkedRepos;
|
|
8
|
+
/**
|
|
9
|
+
* lr-03 — clone a workspace's LINKED repos on the box
|
|
10
|
+
* (docs/prd/linked-repos-backend.md).
|
|
11
|
+
*
|
|
12
|
+
* The relay's `open` frame lists them; once the primary checkout resolves the
|
|
13
|
+
* bridge ensures each one at /data/linked/<workspaceId>/<alias>, in the
|
|
14
|
+
* background, through the SAME ensureWorkspaceRepo the primary uses (clone
|
|
15
|
+
* mutex, free-space and storage-quota preflights, interrupted-clone cleanup).
|
|
16
|
+
*
|
|
17
|
+
* - Not under /data/repos: the checkout inventory treats every directory there
|
|
18
|
+
* as a workspace id, so `<ws>.linked` would read as a deleted workspace.
|
|
19
|
+
* - Each repo gets ITS OWN credential (read-only unless the link may push),
|
|
20
|
+
* fetched fresh from the relay; a refused credential skips that repo rather
|
|
21
|
+
* than cloning a private repo anonymously.
|
|
22
|
+
* - Never deletes anything: an unlinked repo's clone stays on the volume.
|
|
23
|
+
* - Never throws: a failed clone must not break the terminal that opened.
|
|
24
|
+
*/
|
|
25
|
+
const path_1 = __importDefault(require("path"));
|
|
26
|
+
/** /data/linked/<workspaceId>/<alias> — the ONE place a linked clone lives. */
|
|
27
|
+
function linkedRepoPath(linkedBaseDir, workspaceId, alias) {
|
|
28
|
+
return path_1.default.join(linkedBaseDir, workspaceId, alias);
|
|
29
|
+
}
|
|
30
|
+
const ALIAS = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
31
|
+
const WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
32
|
+
const FULL_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
33
|
+
const GITHUB_CLONE_URL = /^https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\.git$/;
|
|
34
|
+
async function ensureLinkedRepos(opts, deps) {
|
|
35
|
+
if (!WORKSPACE_ID.test(opts.workspaceId))
|
|
36
|
+
return [];
|
|
37
|
+
const baseDir = path_1.default.join(deps.linkedBaseDir, opts.workspaceId);
|
|
38
|
+
const results = [];
|
|
39
|
+
// One at a time: two big clones at once on a small box is how a disk fills.
|
|
40
|
+
for (const ref of opts.linkedRepos) {
|
|
41
|
+
if (!ALIAS.test(ref.alias) || !FULL_NAME.test(ref.fullName) || ref.fullName.includes('..') || !GITHUB_CLONE_URL.test(ref.cloneUrl)) {
|
|
42
|
+
deps.log?.(`[linked] skipped a malformed linked repo entry`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const gitCredential = await deps.credentialFor(opts.workspaceId, ref.fullName);
|
|
47
|
+
const r = await deps.ensure({ baseDir, name: ref.alias, repoUrl: ref.cloneUrl, gitCredential });
|
|
48
|
+
const action = r.action === 'existing' || r.action === 'cloned' ? r.action : 'failed';
|
|
49
|
+
results.push({ alias: ref.alias, action, ...(r.path ? { path: r.path } : {}) });
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
deps.log?.(`[linked] ${ref.alias} (${ref.fullName}) not ready: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
results.push({ alias: ref.alias, action: 'failed' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
@@ -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';
|
|
@@ -105,6 +124,12 @@ export interface BackoffOpts {
|
|
|
105
124
|
jitter?: () => number;
|
|
106
125
|
}
|
|
107
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>;
|
|
108
133
|
/** a9be9720 — the unix socket this bridge serves fresh git credentials on
|
|
109
134
|
* (gitTokenBroker.ts). Put on every workspace PTY's env. Absent → the
|
|
110
135
|
* helper uses the token injected at open, as before. */
|
|
@@ -266,6 +291,7 @@ export interface RelayClientOpts {
|
|
|
266
291
|
refreshContextPointers?: (args: {
|
|
267
292
|
workspaceId: string;
|
|
268
293
|
engine?: string;
|
|
294
|
+
linkedRepos?: LinkedRepoRef[];
|
|
269
295
|
}) => void | Promise<void>;
|
|
270
296
|
writeContext?: (manifest: {
|
|
271
297
|
workspaceId: string;
|
|
@@ -727,7 +753,7 @@ export interface RelayClientHandle {
|
|
|
727
753
|
* Rejects at once (code OFFLINE) with no relay connection, and with the
|
|
728
754
|
* relay's own code/sentence on a refusal; bounded, never hangs.
|
|
729
755
|
*/
|
|
730
|
-
requestGitToken(workspaceId: string): Promise<{
|
|
756
|
+
requestGitToken(workspaceId: string, repo?: string): Promise<{
|
|
731
757
|
token: string;
|
|
732
758
|
repoFullName: string;
|
|
733
759
|
expiresAt: string;
|
|
@@ -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");
|
|
@@ -508,7 +490,7 @@ function startRelayClient(opts) {
|
|
|
508
490
|
p.reject(gitTokenError(code, message));
|
|
509
491
|
}
|
|
510
492
|
};
|
|
511
|
-
const requestGitToken = (workspaceId) => new Promise((resolve, reject) => {
|
|
493
|
+
const requestGitToken = (workspaceId, repo) => new Promise((resolve, reject) => {
|
|
512
494
|
const socket = ws;
|
|
513
495
|
if (!connected || !socket || socket.readyState !== ws_1.WebSocket.OPEN) {
|
|
514
496
|
reject(gitTokenError('OFFLINE', 'The bridge is not connected to HiveOp'));
|
|
@@ -521,7 +503,7 @@ function startRelayClient(opts) {
|
|
|
521
503
|
}, GIT_TOKEN_REQUEST_TIMEOUT_MS);
|
|
522
504
|
timer.unref?.();
|
|
523
505
|
pendingGitTokens.set(requestId, { resolve, reject, timer });
|
|
524
|
-
send(socket, { t: 'git-token', requestId, workspaceId });
|
|
506
|
+
send(socket, { t: 'git-token', requestId, workspaceId, ...(repo ? { repo } : {}) });
|
|
525
507
|
});
|
|
526
508
|
let attempt = 0;
|
|
527
509
|
let reconnectHandle = null;
|
|
@@ -1099,7 +1081,7 @@ function startRelayClient(opts) {
|
|
|
1099
1081
|
case 'open': {
|
|
1100
1082
|
// Capture into locals so the async .then closures below keep the
|
|
1101
1083
|
// discriminated-union narrowing TS would otherwise drop.
|
|
1102
|
-
const { cols, rows, workspaceId, repoUrl, autonomous, connectToken, gitCredential, gitIdentity, opencode } = frame;
|
|
1084
|
+
const { cols, rows, workspaceId, repoUrl, autonomous, connectToken, gitCredential, gitIdentity, opencode, linkedRepos } = frame;
|
|
1103
1085
|
// pucc-14 multiplex: the RELAY owns the session id (== agentId) and
|
|
1104
1086
|
// stamps it on the open + every later frame. Honor it so the PTY is
|
|
1105
1087
|
// registered under exactly that id and container→pane frames demux
|
|
@@ -1249,6 +1231,8 @@ function startRelayClient(opts) {
|
|
|
1249
1231
|
if (workspaceId && opts.refreshContextPointers) {
|
|
1250
1232
|
void Promise.resolve(opts.refreshContextPointers({
|
|
1251
1233
|
workspaceId,
|
|
1234
|
+
// lr-03: absent on the frame = none linked (or an older relay).
|
|
1235
|
+
linkedRepos: Array.isArray(linkedRepos) ? linkedRepos : [],
|
|
1252
1236
|
// oc-23: the relay-injected env names the agent's engine; absent = Claude.
|
|
1253
1237
|
...(injectedEnv?.WENRWA_AGENT_ENGINE ? { engine: injectedEnv.WENRWA_AGENT_ENGINE } : {}),
|
|
1254
1238
|
})).catch((err) => {
|
|
@@ -1281,7 +1265,15 @@ function startRelayClient(opts) {
|
|
|
1281
1265
|
if (opts.resolveWorkspaceCwd) {
|
|
1282
1266
|
opts
|
|
1283
1267
|
.resolveWorkspaceCwd({ workspaceId, repoUrl, gitCredential })
|
|
1284
|
-
.then((cwd) =>
|
|
1268
|
+
.then((cwd) => {
|
|
1269
|
+
openSession(cwd ?? undefined, env);
|
|
1270
|
+
// lr-03 — after the PTY: a linked clone never delays the terminal.
|
|
1271
|
+
if (cwd && Array.isArray(linkedRepos) && linkedRepos.length > 0 && opts.ensureLinkedRepos) {
|
|
1272
|
+
Promise.resolve()
|
|
1273
|
+
.then(() => opts.ensureLinkedRepos({ workspaceId, linkedRepos }))
|
|
1274
|
+
.catch((err) => console.error(`[bridge] linked repos for ${workspaceId}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1275
|
+
}
|
|
1276
|
+
})
|
|
1285
1277
|
.catch((err) => {
|
|
1286
1278
|
// hcs-07 — a clone-time preflight refusal is a typed,
|
|
1287
1279
|
// user-actionable refusal (see diskPreflight.ts), not an
|
|
@@ -51,6 +51,19 @@ export declare function workSection(input: {
|
|
|
51
51
|
workGraphAvailable?: boolean;
|
|
52
52
|
match?: boolean;
|
|
53
53
|
}): string | null;
|
|
54
|
+
/**
|
|
55
|
+
* lr-03 — which linked repos this workspace has, where each is on the box, and
|
|
56
|
+
* whether the agent may push there (a read-only link's token is contents:read,
|
|
57
|
+
* so a push is refused by GitHub). Silent when nothing is linked.
|
|
58
|
+
*/
|
|
59
|
+
export declare function linkedReposSection(input: {
|
|
60
|
+
linkedRepos?: Array<{
|
|
61
|
+
alias: string;
|
|
62
|
+
fullName: string;
|
|
63
|
+
writable: boolean;
|
|
64
|
+
path: string;
|
|
65
|
+
}>;
|
|
66
|
+
}): string | null;
|
|
54
67
|
/**
|
|
55
68
|
* cvt-09 (amended by the 2026-10-04 show-in-browser-tabs change) — tells the
|
|
56
69
|
* agent its pilot watches a Browser pane and WHEN to call `show_in_browser`:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SECRETS_POINTER = exports.KEYS_POINTER = exports.BROWSER_POINTER = exports.MATCH_POINTER = exports.WORK_GRAPH_POINTER = void 0;
|
|
4
4
|
exports.workSection = workSection;
|
|
5
|
+
exports.linkedReposSection = linkedReposSection;
|
|
5
6
|
exports.browserSection = browserSection;
|
|
6
7
|
exports.keysSection = keysSection;
|
|
7
8
|
exports.withSecretsSection = withSecretsSection;
|
|
@@ -67,6 +68,21 @@ function workSection(input) {
|
|
|
67
68
|
return exports.MATCH_POINTER;
|
|
68
69
|
return input.workGraphAvailable ? exports.WORK_GRAPH_POINTER : null;
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* lr-03 — which linked repos this workspace has, where each is on the box, and
|
|
73
|
+
* whether the agent may push there (a read-only link's token is contents:read,
|
|
74
|
+
* so a push is refused by GitHub). Silent when nothing is linked.
|
|
75
|
+
*/
|
|
76
|
+
function linkedReposSection(input) {
|
|
77
|
+
const repos = input.linkedRepos ?? [];
|
|
78
|
+
if (repos.length === 0)
|
|
79
|
+
return null;
|
|
80
|
+
return [
|
|
81
|
+
'## Linked repos',
|
|
82
|
+
'This workspace also has these repos, each its own checkout:',
|
|
83
|
+
...repos.map((r) => `- \`${r.alias}\` (${r.fullName}) at ${r.path} — ${r.writable ? 'you can push' : 'read-only, pushes are refused'}`),
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
70
86
|
/**
|
|
71
87
|
* cvt-09 (amended by the 2026-10-04 show-in-browser-tabs change) — tells the
|
|
72
88
|
* agent its pilot watches a Browser pane and WHEN to call `show_in_browser`:
|
package/dist/commands/bridge.js
CHANGED
|
@@ -65,6 +65,7 @@ const sessionManager_1 = require("../bridge/sessionManager");
|
|
|
65
65
|
const server_1 = require("../bridge/server");
|
|
66
66
|
const relayClient_1 = require("../bridge/relayClient");
|
|
67
67
|
const gitTokenBroker_1 = require("../bridge/gitTokenBroker");
|
|
68
|
+
const linkedRepos_1 = require("../bridge/linkedRepos");
|
|
68
69
|
const browserDriver_1 = require("../bridge/browserDriver");
|
|
69
70
|
const browserHost_1 = require("../bridge/browserHost");
|
|
70
71
|
const browserIdle_1 = require("../bridge/browserIdle");
|
|
@@ -620,8 +621,11 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
620
621
|
* (that PTY-scoped invariant is exactly why the raw Fly exec could never
|
|
621
622
|
* have cloned for itself — see issues/msf-34).
|
|
622
623
|
*/
|
|
623
|
-
|
|
624
|
-
|
|
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,
|
|
625
629
|
exists: (p) => fs.existsSync(p),
|
|
626
630
|
mkdir: (p) => fs.mkdirSync(p, { recursive: true }),
|
|
627
631
|
// msf-34: the clone gets the workspace credential IN ITS ENV.
|
|
@@ -665,6 +669,7 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
665
669
|
// does not).
|
|
666
670
|
rm: rmCheckoutDir,
|
|
667
671
|
});
|
|
672
|
+
const ensureWorkspaceCheckout = checkoutIn(reposBaseDir);
|
|
668
673
|
// tp-06 (ADR-0014) — the Team preview forwarder. Started here, in the
|
|
669
674
|
// container branch only: the local bridge never opens it. It is also a
|
|
670
675
|
// no-op without a `fly-local-6pn` entry, and stays off until
|
|
@@ -688,15 +693,34 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
688
693
|
// asks the relay. Hosted lane only — a laptop pushes with its own git.
|
|
689
694
|
const gitTokenSocket = process.env.WENRWA_GIT_TOKEN_SOCKET || path.join(os.homedir(), '.wenrwa', 'git-token.sock');
|
|
690
695
|
let relayClient = null;
|
|
691
|
-
const gitTokenBroker = new gitTokenBroker_1.GitTokenBroker((workspaceId) => relayClient
|
|
692
|
-
? relayClient.requestGitToken(workspaceId)
|
|
696
|
+
const gitTokenBroker = new gitTokenBroker_1.GitTokenBroker((workspaceId, repo) => relayClient
|
|
697
|
+
? relayClient.requestGitToken(workspaceId, repo)
|
|
693
698
|
: Promise.reject(Object.assign(new Error('The bridge is starting'), { code: 'OFFLINE' })));
|
|
694
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
|
+
// The latest list each workspace's open frame carried — so a file-selection
|
|
704
|
+
// write (which has no frame of its own) keeps the agent's linked-repo lines.
|
|
705
|
+
const linkedReposByWorkspace = new Map();
|
|
706
|
+
const linkedPointerFor = (workspaceId) => (linkedReposByWorkspace.get(workspaceId) ?? []).map((r) => ({
|
|
707
|
+
alias: r.alias,
|
|
708
|
+
fullName: r.fullName,
|
|
709
|
+
writable: r.writable === true,
|
|
710
|
+
path: (0, linkedRepos_1.linkedRepoPath)(linkedBaseDir, workspaceId, r.alias),
|
|
711
|
+
}));
|
|
712
|
+
const ensureLinked = (args) => (0, linkedRepos_1.ensureLinkedRepos)(args, {
|
|
713
|
+
linkedBaseDir,
|
|
714
|
+
credentialFor: (workspaceId, fullName) => gitTokenBroker.get(workspaceId, fullName),
|
|
715
|
+
ensure: ({ baseDir, name, repoUrl, gitCredential }) => checkoutIn(baseDir)({ workspaceId: name, repoUrl, gitCredential }),
|
|
716
|
+
log: (line) => console.error(line),
|
|
717
|
+
});
|
|
695
718
|
relayClient = (0, relayClient_1.startRelayClient)({
|
|
696
719
|
relayUrl: relayMode.relayUrl,
|
|
697
720
|
relayToken: relayMode.relayToken,
|
|
698
721
|
sessions,
|
|
699
722
|
gitTokenSocket,
|
|
723
|
+
ensureLinkedRepos: ensureLinked,
|
|
700
724
|
// Rock-solid sleep/wake: this is the HOSTED container bridge, the only
|
|
701
725
|
// process that knows which agent PTYs are actually running. The backend
|
|
702
726
|
// reconciles claude_code_agents.lifecycle_state against this report —
|
|
@@ -896,7 +920,9 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
896
920
|
only sends one when the pilot has files selected, so most workspaces
|
|
897
921
|
never got the pointers at all.
|
|
898
922
|
*/
|
|
899
|
-
refreshContextPointers: async ({ workspaceId, engine }) => {
|
|
923
|
+
refreshContextPointers: async ({ workspaceId, engine, linkedRepos }) => {
|
|
924
|
+
if (linkedRepos)
|
|
925
|
+
linkedReposByWorkspace.set(workspaceId, linkedRepos);
|
|
900
926
|
const memoryAvailable = Boolean(process.env.WENRWA_CONNECT_TOKEN);
|
|
901
927
|
// 2026-10-02 — the same reply says whether this is a MATCH workspace.
|
|
902
928
|
const briefInfo = memoryAvailable ? await (0, memoryBrief_1.fetchWorkspaceBrief)(workspaceId) : null;
|
|
@@ -905,6 +931,7 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
905
931
|
workspaceId,
|
|
906
932
|
pointersOnly: true,
|
|
907
933
|
engine,
|
|
934
|
+
linkedRepos: linkedPointerFor(workspaceId),
|
|
908
935
|
workGraphAvailable: Boolean(process.env.WENRWA_CONNECT_TOKEN),
|
|
909
936
|
memoryAvailable,
|
|
910
937
|
memoryBrief,
|
|
@@ -934,6 +961,7 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
934
961
|
workspaceId,
|
|
935
962
|
content,
|
|
936
963
|
engine,
|
|
964
|
+
linkedRepos: linkedPointerFor(workspaceId),
|
|
937
965
|
// wgcp-01 — same gate as checkStorageQuota above: WENRWA_CONNECT_TOKEN
|
|
938
966
|
// is only injected at container boot when this workspace actually has
|
|
939
967
|
// MCP world-wiring (hosted-container.service.ts's machineEnv). Absent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenrwa/marketplace-cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.15",
|
|
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",
|