@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.
@@ -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
+ });
@@ -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,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) => openSession(cwd ?? undefined, env))
1266
+ .then((cwd) => {
1267
+ openSession(cwd ?? undefined, env);
1268
+ // lr-03 — after the PTY: a linked clone never delays the terminal.
1269
+ if (cwd && Array.isArray(linkedRepos) && linkedRepos.length > 0 && opts.ensureLinkedRepos) {
1270
+ Promise.resolve()
1271
+ .then(() => opts.ensureLinkedRepos({ workspaceId, linkedRepos }))
1272
+ .catch((err) => console.error(`[bridge] linked repos for ${workspaceId}: ${err instanceof Error ? err.message : String(err)}`));
1273
+ }
1274
+ })
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
@@ -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,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.13",
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",