@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.
@@ -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('protocol=https\nhost=github.com\n\n');
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
- export type GitCredentialFetcher = (workspaceId: string) => Promise<GitCredential>;
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 cached = this.cache.get(workspaceId);
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(workspaceId);
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(workspaceId, cred);
60
+ this.cache.set(key, cred);
60
61
  return cred;
61
62
  })
62
- .finally(() => this.inFlight.delete(workspaceId));
63
- this.inFlight.set(workspaceId, request);
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
- opts.broker.get(workspaceId).then((cred) => reply(200, `username=x-access-token\npassword=${cred.token}\n`), (err) => {
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) => openSession(cwd ?? undefined, env))
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`:
@@ -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
- const ensureWorkspaceCheckout = (args) => (0, repoCheckout_1.ensureWorkspaceRepo)({ workspaceId: args.workspaceId, repoUrl: args.repoUrl }, {
624
- 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,
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.13",
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",