@wenrwa/marketplace-cli 0.8.12 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * a9be9720 — a hosted agent can push for as long as its session lives.
8
+ *
9
+ * WENRWA_GIT_TOKEN went onto a PTY's env once, at open, and a GitHub App
10
+ * installation token lives an hour: every agent session older than that held
11
+ * a dead credential ("remote: Invalid username or token") and no retry helped.
12
+ *
13
+ * Now `git-credential-wenrwa get` asks the bridge, over a 0600 unix socket,
14
+ * for a credential for $WENRWA_WORKSPACE_ID. The bridge caches it until shortly
15
+ * before it expires and otherwise asks the relay (`git-token`) for a fresh one.
16
+ * The injected env token stays as the fallback when the bridge cannot answer.
17
+ *
18
+ * Drives the REAL relay client against a fake relay, the REAL socket server,
19
+ * and the REAL shell helper from the box image.
20
+ */
21
+ const child_process_1 = require("child_process");
22
+ const fs_1 = __importDefault(require("fs"));
23
+ const os_1 = __importDefault(require("os"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const ws_1 = require("ws");
26
+ const relayClient_1 = require("../bridge/relayClient");
27
+ const gitTokenBroker_1 = require("../bridge/gitTokenBroker");
28
+ const sessionManager_1 = require("../bridge/sessionManager");
29
+ class FakePty {
30
+ opts;
31
+ constructor(opts) {
32
+ this.opts = opts;
33
+ }
34
+ write() { }
35
+ resize() { }
36
+ kill() { }
37
+ onData() { }
38
+ onExit() { }
39
+ }
40
+ const makeFactory = () => ({ spawn: (o) => new FakePty(o) });
41
+ const HELPER = path_1.default.resolve(__dirname, '../../../infra/terminal-image/git-credential-wenrwa');
42
+ function startFakeRelay() {
43
+ return new Promise((resolve) => {
44
+ const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
45
+ const queue = [];
46
+ const waiters = [];
47
+ wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
48
+ w(ws);
49
+ else
50
+ queue.push(ws); });
51
+ wss.on('listening', () => {
52
+ const addr = wss.address();
53
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
54
+ resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
55
+ });
56
+ });
57
+ }
58
+ function closeRelay(wss) {
59
+ return new Promise((resolve, reject) => {
60
+ for (const client of wss.clients)
61
+ client.terminate();
62
+ wss.close((err) => (err ? reject(err) : resolve()));
63
+ });
64
+ }
65
+ /** The next frame of type `t` the bridge sends (it sends reports too). */
66
+ function nextOfType(ws, t) {
67
+ return new Promise((resolve) => {
68
+ const onMsg = (raw) => {
69
+ const f = JSON.parse(String(raw));
70
+ if (f.t !== t)
71
+ return;
72
+ ws.off('message', onMsg);
73
+ resolve(f);
74
+ };
75
+ ws.on('message', onMsg);
76
+ });
77
+ }
78
+ const inMinutes = (m, from = Date.now()) => new Date(from + m * 60_000).toISOString();
79
+ describe('relay client — requestGitToken (a9be9720)', () => {
80
+ it('asks the relay with a git-token frame and resolves with its answer', async () => {
81
+ const relay = await startFakeRelay();
82
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
83
+ try {
84
+ const ws = await relay.nextConnection();
85
+ ws.send(JSON.stringify({ t: 'noop' })); // the relay accepting us (any frame)
86
+ await new Promise((r) => setTimeout(r, 30));
87
+ const asked = nextOfType(ws, 'git-token');
88
+ const pending = handle.requestGitToken('ws-1');
89
+ const req = await asked;
90
+ expect(req).toEqual({ t: 'git-token', requestId: expect.any(String), workspaceId: 'ws-1' });
91
+ 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
+ await expect(pending).resolves.toEqual({ token: 'ghs_new', repoFullName: 'acme/w', expiresAt: '2026-10-24T13:00:00.000Z' });
93
+ }
94
+ finally {
95
+ handle.close();
96
+ await closeRelay(relay.wss);
97
+ }
98
+ });
99
+ it('a refusal rejects with the relay\'s code and sentence', async () => {
100
+ const relay = await startFakeRelay();
101
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
102
+ try {
103
+ const ws = await relay.nextConnection();
104
+ ws.send(JSON.stringify({ t: 'noop' }));
105
+ await new Promise((r) => setTimeout(r, 30));
106
+ const asked = nextOfType(ws, 'git-token');
107
+ const pending = handle.requestGitToken('ws-1');
108
+ const req = await asked;
109
+ ws.send(JSON.stringify({ t: 'git-token', requestId: req.requestId, ok: false, code: 'WORKSPACE_ACCESS_DENIED', message: 'Not a member of this workspace' }));
110
+ await expect(pending).rejects.toMatchObject({ code: 'WORKSPACE_ACCESS_DENIED', message: 'Not a member of this workspace' });
111
+ }
112
+ finally {
113
+ handle.close();
114
+ await closeRelay(relay.wss);
115
+ }
116
+ });
117
+ it('no relay connection → rejects at once (the helper then falls back), never waits out a timeout', async () => {
118
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: 'ws://127.0.0.1:9', relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
119
+ try {
120
+ const t0 = Date.now();
121
+ await expect(handle.requestGitToken('ws-1')).rejects.toMatchObject({ code: 'OFFLINE' });
122
+ expect(Date.now() - t0).toBeLessThan(500);
123
+ }
124
+ finally {
125
+ handle.close();
126
+ }
127
+ });
128
+ });
129
+ describe('GitTokenBroker — one fresh credential per workspace, cached until shortly before it expires', () => {
130
+ it('reuses a credential while it has more than 5 minutes left, then asks again', async () => {
131
+ let now = Date.parse('2026-10-24T12:00:00.000Z');
132
+ let calls = 0;
133
+ const fetch = jest.fn(async () => ({ token: `t${++calls}`, repoFullName: 'acme/w', expiresAt: inMinutes(15, now) }));
134
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch, () => now);
135
+ expect((await broker.get('ws-1')).token).toBe('t1');
136
+ now += 9 * 60_000; // 6 minutes left
137
+ expect((await broker.get('ws-1')).token).toBe('t1');
138
+ now += 2 * 60_000; // 4 minutes left — refresh
139
+ expect((await broker.get('ws-1')).token).toBe('t2');
140
+ expect(fetch).toHaveBeenCalledTimes(2);
141
+ });
142
+ it('workspaces never share a credential', async () => {
143
+ const fetch = jest.fn(async (ws) => ({ token: `tok-${ws}`, repoFullName: ws, expiresAt: inMinutes(15) }));
144
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
145
+ expect((await broker.get('ws-A')).token).toBe('tok-ws-A');
146
+ expect((await broker.get('ws-B')).token).toBe('tok-ws-B');
147
+ });
148
+ it('a burst of git calls makes ONE request', async () => {
149
+ let release;
150
+ const gate = new Promise((r) => { release = r; });
151
+ const fetch = jest.fn(async () => { await gate; return { token: 't', repoFullName: 'r', expiresAt: inMinutes(15) }; });
152
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
153
+ const all = Promise.all([broker.get('ws-1'), broker.get('ws-1'), broker.get('ws-1')]);
154
+ release();
155
+ await all;
156
+ expect(fetch).toHaveBeenCalledTimes(1);
157
+ });
158
+ it('a failure is not cached: the next call asks again', async () => {
159
+ const fetch = jest
160
+ .fn()
161
+ .mockRejectedValueOnce(Object.assign(new Error('offline'), { code: 'OFFLINE' }))
162
+ .mockResolvedValueOnce({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) });
163
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
164
+ await expect(broker.get('ws-1')).rejects.toMatchObject({ code: 'OFFLINE' });
165
+ expect((await broker.get('ws-1')).token).toBe('t');
166
+ });
167
+ });
168
+ describe('git token socket + the box\'s git-credential-wenrwa helper', () => {
169
+ let dir;
170
+ let sock;
171
+ let server = null;
172
+ beforeEach(() => {
173
+ // Short path: unix socket paths are limited to ~104 bytes on macOS.
174
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'gtk-'));
175
+ sock = path_1.default.join(dir, 'git.sock');
176
+ });
177
+ afterEach(async () => {
178
+ await server?.close();
179
+ server = null;
180
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
181
+ });
182
+ const runHelper = (env) => new Promise((resolve) => {
183
+ const child = (0, child_process_1.execFile)('sh', [HELPER, 'get'], { env: { PATH: process.env.PATH ?? '', ...env } }, (err, stdout, stderr) => {
184
+ resolve({ code: err ? err.code ?? 1 : 0, stdout, stderr });
185
+ });
186
+ child.stdin?.end('protocol=https\nhost=github.com\n\n');
187
+ });
188
+ it('the socket is owner-only (0600)', async () => {
189
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(async () => ({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) })) });
190
+ expect(fs_1.default.statSync(sock).mode & 0o777).toBe(0o600);
191
+ });
192
+ it('the helper prints the bridge\'s FRESH credential, not the stale one on its env', async () => {
193
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({
194
+ path: sock,
195
+ broker: new gitTokenBroker_1.GitTokenBroker(async (ws) => ({ token: `fresh-for-${ws}`, repoFullName: 'acme/w', expiresAt: inMinutes(15) })),
196
+ });
197
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'stale' });
198
+ expect(out.code).toBe(0);
199
+ expect(out.stdout).toBe('username=x-access-token\npassword=fresh-for-ws-1\n');
200
+ });
201
+ it('bridge cannot mint (offline, refused) → the helper falls back to the injected token', async () => {
202
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({
203
+ path: sock,
204
+ broker: new gitTokenBroker_1.GitTokenBroker(async () => { throw Object.assign(new Error('offline'), { code: 'OFFLINE' }); }),
205
+ });
206
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'injected' });
207
+ expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
208
+ });
209
+ it('no bridge socket at all (an older bridge) → the injected token, exactly as before', async () => {
210
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: path_1.default.join(dir, 'missing.sock'), WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'injected' });
211
+ expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
212
+ });
213
+ it('a request without a workspace is refused (the bridge never guesses one)', async () => {
214
+ const fetch = jest.fn();
215
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(fetch) });
216
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_GIT_TOKEN: 'injected' });
217
+ expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
218
+ expect(fetch).not.toHaveBeenCalled();
219
+ });
220
+ });
221
+ describe('assembleSessionEnv — a PTY knows where to ask', () => {
222
+ it('carries the socket path beside the injected token', () => {
223
+ const env = (0, relayClient_1.assembleSessionEnv)({
224
+ workspaceId: 'ws-1',
225
+ gitCredential: { token: 't', repoFullName: 'acme/w', expiresAt: inMinutes(15) },
226
+ gitTokenSocket: '/home/wenrwa/.wenrwa/git.sock',
227
+ });
228
+ expect(env.WENRWA_GIT_TOKEN_SOCKET).toBe('/home/wenrwa/.wenrwa/git.sock');
229
+ expect(env.WENRWA_GIT_TOKEN).toBe('t');
230
+ });
231
+ });
@@ -0,0 +1,28 @@
1
+ export interface GitCredential {
2
+ token: string;
3
+ repoFullName: string;
4
+ expiresAt: string;
5
+ }
6
+ export type GitCredentialFetcher = (workspaceId: string) => Promise<GitCredential>;
7
+ /** Ask again once a cached credential has less than this left. */
8
+ export declare const REFRESH_BEFORE_EXPIRY_MS: number;
9
+ export declare class GitTokenBroker {
10
+ private fetch;
11
+ private now;
12
+ private cache;
13
+ private inFlight;
14
+ constructor(fetch: GitCredentialFetcher, now?: () => number);
15
+ get(workspaceId: string): Promise<GitCredential>;
16
+ }
17
+ export interface GitTokenSocketHandle {
18
+ path: string;
19
+ close(): Promise<void>;
20
+ }
21
+ /**
22
+ * Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
23
+ * socket file left by a previous bridge.
24
+ */
25
+ export declare function startGitTokenSocket(opts: {
26
+ path: string;
27
+ broker: GitTokenBroker;
28
+ }): Promise<GitTokenSocketHandle>;
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GitTokenBroker = exports.REFRESH_BEFORE_EXPIRY_MS = void 0;
7
+ exports.startGitTokenSocket = startGitTokenSocket;
8
+ /**
9
+ * a9be9720 — fresh git credentials for a box's agents, for as long as they run.
10
+ *
11
+ * The relay puts a workspace's git token on each PTY's env once, at open
12
+ * (WENRWA_GIT_TOKEN). It is a GitHub App installation token and lives an
13
+ * hour; agent sessions live for days. So every session older than that held a
14
+ * dead credential and could not push — "remote: Invalid username or token",
15
+ * and no retry helped.
16
+ *
17
+ * Now `git-credential-wenrwa get` (infra/terminal-image) asks THIS bridge,
18
+ * over a unix socket, for a credential for $WENRWA_WORKSPACE_ID:
19
+ *
20
+ * GET /git-credential?workspace=<id> → 200 "username=…\npassword=…\n"
21
+ *
22
+ * The answer is already in git's credential-helper format, so the shell helper
23
+ * only has to print it. The bridge keeps each workspace's credential in memory
24
+ * until shortly before it expires, and otherwise asks the relay for a fresh
25
+ * one (`git-token`, relayClient.requestGitToken).
26
+ *
27
+ * Never on disk, never logged. The socket is 0600 and lives in the bridge
28
+ * user's home: every process that can reach it is the same user whose PTYs
29
+ * already carry these tokens on their env.
30
+ *
31
+ * Any failure answers non-2xx with a one-line reason, and the helper falls back
32
+ * to the injected token — so a bridge that cannot mint is never worse than
33
+ * before.
34
+ */
35
+ const fs_1 = __importDefault(require("fs"));
36
+ const http_1 = __importDefault(require("http"));
37
+ const path_1 = __importDefault(require("path"));
38
+ /** Ask again once a cached credential has less than this left. */
39
+ exports.REFRESH_BEFORE_EXPIRY_MS = 5 * 60_000;
40
+ class GitTokenBroker {
41
+ fetch;
42
+ now;
43
+ cache = new Map();
44
+ inFlight = new Map();
45
+ constructor(fetch, now = Date.now) {
46
+ this.fetch = fetch;
47
+ this.now = now;
48
+ }
49
+ async get(workspaceId) {
50
+ const cached = this.cache.get(workspaceId);
51
+ if (cached && Date.parse(cached.expiresAt) - this.now() > exports.REFRESH_BEFORE_EXPIRY_MS)
52
+ return cached;
53
+ // A burst of git calls (fetch + push, a submodule walk) makes ONE request.
54
+ const pending = this.inFlight.get(workspaceId);
55
+ if (pending)
56
+ return pending;
57
+ const request = this.fetch(workspaceId)
58
+ .then((cred) => {
59
+ this.cache.set(workspaceId, cred);
60
+ return cred;
61
+ })
62
+ .finally(() => this.inFlight.delete(workspaceId));
63
+ this.inFlight.set(workspaceId, request);
64
+ return request;
65
+ }
66
+ }
67
+ exports.GitTokenBroker = GitTokenBroker;
68
+ /** Workspace ids are UUIDs; anything else is refused before it is used. */
69
+ const WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/;
70
+ /**
71
+ * Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
72
+ * socket file left by a previous bridge.
73
+ */
74
+ async function startGitTokenSocket(opts) {
75
+ fs_1.default.mkdirSync(path_1.default.dirname(opts.path), { recursive: true, mode: 0o700 });
76
+ try {
77
+ fs_1.default.unlinkSync(opts.path);
78
+ }
79
+ catch {
80
+ /* no previous socket */
81
+ }
82
+ const server = http_1.default.createServer((req, res) => {
83
+ const reply = (status, body) => {
84
+ res.writeHead(status, { 'content-type': 'text/plain', 'cache-control': 'no-store' });
85
+ res.end(body);
86
+ };
87
+ const url = new URL(req.url ?? '/', 'http://bridge');
88
+ if (req.method !== 'GET' || url.pathname !== '/git-credential') {
89
+ reply(404, 'not found\n');
90
+ return;
91
+ }
92
+ const workspaceId = url.searchParams.get('workspace') ?? '';
93
+ if (!WORKSPACE_ID.test(workspaceId)) {
94
+ reply(400, 'no workspace: WENRWA_WORKSPACE_ID is not set in this environment\n');
95
+ return;
96
+ }
97
+ opts.broker.get(workspaceId).then((cred) => reply(200, `username=x-access-token\npassword=${cred.token}\n`), (err) => {
98
+ const e = err;
99
+ const code = typeof e?.code === 'string' ? e.code : 'GIT_TOKEN_FAILED';
100
+ const message = typeof e?.message === 'string' ? e.message : 'could not get a git token';
101
+ reply(502, `${code}: ${message}\n`);
102
+ });
103
+ });
104
+ await new Promise((resolve, reject) => {
105
+ server.once('error', reject);
106
+ server.listen(opts.path, () => {
107
+ server.off('error', reject);
108
+ resolve();
109
+ });
110
+ });
111
+ fs_1.default.chmodSync(opts.path, 0o600);
112
+ return {
113
+ path: opts.path,
114
+ close: () => new Promise((resolve) => {
115
+ server.close(() => resolve());
116
+ try {
117
+ fs_1.default.unlinkSync(opts.path);
118
+ }
119
+ catch {
120
+ /* already gone */
121
+ }
122
+ }),
123
+ };
124
+ }
@@ -77,6 +77,8 @@ export declare function assembleSessionEnv(frame: {
77
77
  };
78
78
  env?: Record<string, string>;
79
79
  sessionId?: string;
80
+ /** a9be9720 — where git-credential-wenrwa asks this bridge for a fresh token. */
81
+ gitTokenSocket?: string;
80
82
  }): Record<string, string>;
81
83
  /** Minimal surface relayClient needs from a socket — lets tests inject fakes if ever needed. */
82
84
  export interface WebSocketLike {
@@ -103,6 +105,10 @@ export interface BackoffOpts {
103
105
  jitter?: () => number;
104
106
  }
105
107
  export interface RelayClientOpts {
108
+ /** a9be9720 — the unix socket this bridge serves fresh git credentials on
109
+ * (gitTokenBroker.ts). Put on every workspace PTY's env. Absent → the
110
+ * helper uses the token injected at open, as before. */
111
+ gitTokenSocket?: string;
106
112
  relayUrl: string;
107
113
  relayToken: string;
108
114
  sessions: SessionManager;
@@ -716,6 +722,16 @@ export interface RelayClientHandle {
716
722
  /** Stop reconnecting and close the current socket. Sessions are untouched — they live in SessionManager. */
717
723
  close(): void;
718
724
  isConnected(): boolean;
725
+ /**
726
+ * a9be9720 — ask the relay for a fresh git credential for one workspace.
727
+ * Rejects at once (code OFFLINE) with no relay connection, and with the
728
+ * relay's own code/sentence on a refusal; bounded, never hangs.
729
+ */
730
+ requestGitToken(workspaceId: string): Promise<{
731
+ token: string;
732
+ repoFullName: string;
733
+ expiresAt: string;
734
+ }>;
719
735
  /**
720
736
  * stc-05 → stc-06 — free caches + idle preview slots before a disk grows.
721
737
  * Behind a kill switch (storageCleanupDeps.autoCleanupEnabled, default
@@ -189,6 +189,10 @@ function assembleSessionEnv(frame) {
189
189
  env.WENRWA_GIT_TOKEN = frame.gitCredential.token;
190
190
  env.WENRWA_GIT_REPO = frame.gitCredential.repoFullName;
191
191
  }
192
+ // a9be9720 — the token above is an hour-lived GitHub token injected once;
193
+ // the helper asks the bridge here first and keeps it only as a fallback.
194
+ if (frame.gitTokenSocket)
195
+ env.WENRWA_GIT_TOKEN_SOCKET = frame.gitTokenSocket;
192
196
  // grad-06 — the commit identity. Nothing set one, so `git commit` failed in
193
197
  // every hosted terminal with "Author identity unknown": a container has no
194
198
  // domain in its hostname, so git refuses to auto-detect an address. Both
@@ -245,6 +249,8 @@ function describeDialError(err) {
245
249
  }
246
250
  return String(err);
247
251
  }
252
+ /** a9be9720 — how long a `git-token` request may go unanswered. `git` waits on it. */
253
+ const GIT_TOKEN_REQUEST_TIMEOUT_MS = 10_000;
248
254
  function send(ws, frame) {
249
255
  if (ws.readyState === ws_1.WebSocket.OPEN)
250
256
  ws.send(JSON.stringify(frame));
@@ -491,6 +497,32 @@ function startRelayClient(opts) {
491
497
  let stopped = false;
492
498
  let ws = null;
493
499
  let connected = false;
500
+ // a9be9720 — outstanding git-token requests, by requestId.
501
+ const pendingGitTokens = new Map();
502
+ let gitTokenSeq = 0;
503
+ const gitTokenError = (code, message) => Object.assign(new Error(message), { code });
504
+ const failPendingGitTokens = (code, message) => {
505
+ for (const [id, p] of pendingGitTokens) {
506
+ clearTimeout(p.timer);
507
+ pendingGitTokens.delete(id);
508
+ p.reject(gitTokenError(code, message));
509
+ }
510
+ };
511
+ const requestGitToken = (workspaceId) => new Promise((resolve, reject) => {
512
+ const socket = ws;
513
+ if (!connected || !socket || socket.readyState !== ws_1.WebSocket.OPEN) {
514
+ reject(gitTokenError('OFFLINE', 'The bridge is not connected to HiveOp'));
515
+ return;
516
+ }
517
+ const requestId = `gt-${++gitTokenSeq}-${Date.now().toString(36)}`;
518
+ const timer = setTimeout(() => {
519
+ pendingGitTokens.delete(requestId);
520
+ reject(gitTokenError('TIMEOUT', 'HiveOp did not answer the git token request'));
521
+ }, GIT_TOKEN_REQUEST_TIMEOUT_MS);
522
+ timer.unref?.();
523
+ pendingGitTokens.set(requestId, { resolve, reject, timer });
524
+ send(socket, { t: 'git-token', requestId, workspaceId });
525
+ });
494
526
  let attempt = 0;
495
527
  let reconnectHandle = null;
496
528
  let healthHandle = null;
@@ -1050,6 +1082,20 @@ function startRelayClient(opts) {
1050
1082
  }
1051
1083
  try {
1052
1084
  switch (frame.t) {
1085
+ case 'git-token': {
1086
+ const p = pendingGitTokens.get(frame.requestId);
1087
+ if (!p)
1088
+ break;
1089
+ pendingGitTokens.delete(frame.requestId);
1090
+ clearTimeout(p.timer);
1091
+ if (frame.ok && frame.token && frame.repoFullName && frame.expiresAt) {
1092
+ p.resolve({ token: frame.token, repoFullName: frame.repoFullName, expiresAt: frame.expiresAt });
1093
+ }
1094
+ else {
1095
+ p.reject(gitTokenError(frame.code ?? 'GIT_TOKEN_FAILED', frame.message ?? 'HiveOp could not mint a git token'));
1096
+ }
1097
+ break;
1098
+ }
1053
1099
  case 'open': {
1054
1100
  // Capture into locals so the async .then closures below keep the
1055
1101
  // discriminated-union narrowing TS would otherwise drop.
@@ -1231,7 +1277,7 @@ function startRelayClient(opts) {
1231
1277
  // shared container. autolaunch.sh reads HT_AUTONOMOUS from the
1232
1278
  // PTY's own env (claude vs claude --dangerously-skip-permissions),
1233
1279
  // NOT a machine-wide var.
1234
- const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, gitIdentity, env: injectedEnv, sessionId: relaySessionId });
1280
+ const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, gitIdentity, env: injectedEnv, sessionId: relaySessionId, gitTokenSocket: opts.gitTokenSocket });
1235
1281
  if (opts.resolveWorkspaceCwd) {
1236
1282
  opts
1237
1283
  .resolveWorkspaceCwd({ workspaceId, repoUrl, gitCredential })
@@ -1986,6 +2032,7 @@ function startRelayClient(opts) {
1986
2032
  });
1987
2033
  const onDown = (info) => {
1988
2034
  connected = false;
2035
+ failPendingGitTokens('OFFLINE', 'The bridge lost its HiveOp connection');
1989
2036
  stopHeartbeat();
1990
2037
  // Detach (not close) every live handle: the sessions/PTYs survive the
1991
2038
  // drop in the SessionManager, scrollback keeps accumulating, and the
@@ -2184,9 +2231,11 @@ function startRelayClient(opts) {
2184
2231
  if (hookActivityHandle !== null)
2185
2232
  cancelInterval(hookActivityHandle);
2186
2233
  opts.companionForwarder?.stop(); // bws-08 — close every forwarded listener on bridge shutdown
2234
+ failPendingGitTokens('OFFLINE', 'The bridge is shutting down');
2187
2235
  ws?.close();
2188
2236
  },
2189
2237
  isConnected: () => connected,
2238
+ requestGitToken,
2190
2239
  runAutomaticCleanup,
2191
2240
  };
2192
2241
  }
@@ -64,6 +64,7 @@ const chalk_1 = __importDefault(require("chalk"));
64
64
  const sessionManager_1 = require("../bridge/sessionManager");
65
65
  const server_1 = require("../bridge/server");
66
66
  const relayClient_1 = require("../bridge/relayClient");
67
+ const gitTokenBroker_1 = require("../bridge/gitTokenBroker");
67
68
  const browserDriver_1 = require("../bridge/browserDriver");
68
69
  const browserHost_1 = require("../bridge/browserHost");
69
70
  const browserIdle_1 = require("../bridge/browserIdle");
@@ -682,10 +683,20 @@ exports.bridgeCommand = new commander_1.Command('bridge')
682
683
  });
683
684
  // cvt-07: the box's one shared Chromium, created lazily by the first tab.
684
685
  let browserHost;
685
- (0, relayClient_1.startRelayClient)({
686
+ // a9be9720 — agents' git credentials stay fresh past the hour the
687
+ // injected one lives: git-credential-wenrwa asks this socket, the broker
688
+ // asks the relay. Hosted lane only — a laptop pushes with its own git.
689
+ const gitTokenSocket = process.env.WENRWA_GIT_TOKEN_SOCKET || path.join(os.homedir(), '.wenrwa', 'git-token.sock');
690
+ let relayClient = null;
691
+ const gitTokenBroker = new gitTokenBroker_1.GitTokenBroker((workspaceId) => relayClient
692
+ ? relayClient.requestGitToken(workspaceId)
693
+ : Promise.reject(Object.assign(new Error('The bridge is starting'), { code: 'OFFLINE' })));
694
+ (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)}`));
695
+ relayClient = (0, relayClient_1.startRelayClient)({
686
696
  relayUrl: relayMode.relayUrl,
687
697
  relayToken: relayMode.relayToken,
688
698
  sessions,
699
+ gitTokenSocket,
689
700
  // Rock-solid sleep/wake: this is the HOSTED container bridge, the only
690
701
  // process that knows which agent PTYs are actually running. The backend
691
702
  // reconciles claude_code_agents.lifecycle_state against this report —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
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",