@wenrwa/marketplace-cli 0.8.13 → 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.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/gitTokenBroker.d.ts +4 -2
- package/dist/bridge/gitTokenBroker.js +15 -8
- package/dist/bridge/linkedRepos.d.ts +31 -0
- package/dist/bridge/linkedRepos.js +52 -0
- package/dist/bridge/relayClient.d.ts +26 -1
- package/dist/bridge/relayClient.js +12 -22
- package/dist/commands/bridge.js +19 -4
- package/package.json +1 -1
|
@@ -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
|
+
});
|
|
@@ -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,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';
|
|
@@ -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. */
|
|
@@ -727,7 +752,7 @@ export interface RelayClientHandle {
|
|
|
727
752
|
* Rejects at once (code OFFLINE) with no relay connection, and with the
|
|
728
753
|
* relay's own code/sentence on a refusal; bounded, never hangs.
|
|
729
754
|
*/
|
|
730
|
-
requestGitToken(workspaceId: string): Promise<{
|
|
755
|
+
requestGitToken(workspaceId: string, repo?: string): Promise<{
|
|
731
756
|
token: string;
|
|
732
757
|
repoFullName: string;
|
|
733
758
|
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
|
|
@@ -1281,7 +1263,15 @@ function startRelayClient(opts) {
|
|
|
1281
1263
|
if (opts.resolveWorkspaceCwd) {
|
|
1282
1264
|
opts
|
|
1283
1265
|
.resolveWorkspaceCwd({ workspaceId, repoUrl, gitCredential })
|
|
1284
|
-
.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
|
+
})
|
|
1285
1275
|
.catch((err) => {
|
|
1286
1276
|
// hcs-07 — a clone-time preflight refusal is a typed,
|
|
1287
1277
|
// user-actionable refusal (see diskPreflight.ts), not an
|
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,25 @@ 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
|
+
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
|
+
});
|
|
695
709
|
relayClient = (0, relayClient_1.startRelayClient)({
|
|
696
710
|
relayUrl: relayMode.relayUrl,
|
|
697
711
|
relayToken: relayMode.relayToken,
|
|
698
712
|
sessions,
|
|
699
713
|
gitTokenSocket,
|
|
714
|
+
ensureLinkedRepos: ensureLinked,
|
|
700
715
|
// Rock-solid sleep/wake: this is the HOSTED container bridge, the only
|
|
701
716
|
// process that knows which agent PTYs are actually running. The backend
|
|
702
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",
|