@wenrwa/marketplace-cli 0.6.4 → 0.6.6

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.
@@ -54,6 +54,21 @@ describe('agent list', () => {
54
54
  spy.mockRestore();
55
55
  });
56
56
  });
57
+ describe('agent logs', () => {
58
+ // sio-07: `agent logs` streams via the SAME connectWebSocket() helper
59
+ // dashboard.ts uses, which is what carries the authenticated handshake
60
+ // (auth: { token }, path '/marketplace') — exercised directly in
61
+ // websocket.test.ts. This just pins that `agent logs` still wires through
62
+ // that one shared call site rather than opening its own socket.
63
+ it('streams via the shared authenticated websocket helper', async () => {
64
+ const { connectWebSocket } = require('../utils/websocket');
65
+ const logSpy = jest.spyOn(console, 'log').mockImplementation();
66
+ await runCommand('logs', 'wallet-abc-12345678');
67
+ expect(connectWebSocket).toHaveBeenCalledTimes(1);
68
+ expect(connectWebSocket).toHaveBeenCalledWith(expect.any(Function), { channel: 'agent', filters: { agentWallet: 'wallet-abc-12345678' } });
69
+ logSpy.mockRestore();
70
+ });
71
+ });
57
72
  describe('agent status', () => {
58
73
  it('displays agent details', async () => {
59
74
  mockClient.get.mockImplementation((path) => {
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ /**
40
+ * rival-10 — a LOCAL agent's live file touches reach a ranked rival, like a
41
+ * hosted agent's. What leaves the laptop: agent id + file paths, only for a
42
+ * workspace the server says is a live ranked match this user owns. Nothing
43
+ * else, and nothing at all for any other workspace.
44
+ */
45
+ const ws_1 = __importDefault(require("ws"));
46
+ const fs = __importStar(require("fs"));
47
+ const os = __importStar(require("os"));
48
+ const path = __importStar(require("path"));
49
+ const server_1 = require("../bridge/server");
50
+ const sessionManager_1 = require("../bridge/sessionManager");
51
+ const rivalTouchForwarder_1 = require("../bridge/rivalTouchForwarder");
52
+ const apiClient_1 = require("../bridge/apiClient");
53
+ const MATCH_WS = 'bbbbbbbb-0000-4000-8000-00000000000b';
54
+ const OTHER_WS = 'dddddddd-0000-4000-8000-00000000000d';
55
+ const CHECKOUT = '/Users/bob/wenrwa-workspaces/m-11111111-b';
56
+ function harness(statusOf, opts = {}) {
57
+ let now = 1_000_000;
58
+ const asked = [];
59
+ const sent = [];
60
+ const fwd = (0, rivalTouchForwarder_1.createRivalTouchForwarder)({
61
+ status: async (ws) => {
62
+ asked.push(ws);
63
+ const s = statusOf[ws] ?? { live: false, recheckMs: 600_000 };
64
+ if (s instanceof Error)
65
+ throw s;
66
+ return s;
67
+ },
68
+ send: async (workspaceId, touches) => {
69
+ if (opts.sendFails)
70
+ throw new Error('409 RANKED_RIVAL_CLOSED');
71
+ sent.push({ workspaceId, touches });
72
+ },
73
+ now: () => now,
74
+ scheduleInterval: () => 'h',
75
+ cancelInterval: () => undefined,
76
+ });
77
+ return { fwd, asked, sent, advance: (ms) => void (now += ms) };
78
+ }
79
+ describe('forwardablePaths — only paths, and only inside the checkout', () => {
80
+ it('relativises an absolute path under the checkout; keeps relative paths', () => {
81
+ expect((0, rivalTouchForwarder_1.forwardablePaths)([`${CHECKOUT}/src/planner.ts`, 'src/b.ts'], CHECKOUT)).toEqual(['src/planner.ts', 'src/b.ts']);
82
+ });
83
+ it('drops a path outside the checkout, climbing out, non-strings, duplicates', () => {
84
+ expect((0, rivalTouchForwarder_1.forwardablePaths)(['/etc/hosts', `${CHECKOUT}/../x.ts`, '../up.ts', 42, null, 'a.ts', 'a.ts'], CHECKOUT)).toEqual(['a.ts']);
85
+ expect((0, rivalTouchForwarder_1.forwardablePaths)('src/a.ts', CHECKOUT)).toEqual([]);
86
+ });
87
+ it('no known checkout: an absolute path is kept for the server to place (or drop)', () => {
88
+ expect((0, rivalTouchForwarder_1.forwardablePaths)(['/home/x/m-b/src/a.ts'])).toEqual(['/home/x/m-b/src/a.ts']);
89
+ });
90
+ it('caps paths per touch', () => {
91
+ expect((0, rivalTouchForwarder_1.forwardablePaths)(Array.from({ length: 40 }, (_, i) => `f${i}.ts`))).toHaveLength(20);
92
+ });
93
+ });
94
+ describe('createRivalTouchForwarder', () => {
95
+ it('a live match workspace: the touch goes up as agent id + paths — nothing else', async () => {
96
+ const h = harness({ [MATCH_WS]: { live: true, recheckMs: 15_000 } });
97
+ h.fwd.touch({
98
+ workspaceId: MATCH_WS,
99
+ agentId: 'agent-1',
100
+ filePaths: [`${CHECKOUT}/src/planner.ts`],
101
+ cwd: CHECKOUT,
102
+ // A caller passing more than it should: never forwarded.
103
+ ...{ thinking: 'Editing planner.ts', tool: 'Edit', content: 'secret code' },
104
+ });
105
+ await h.fwd.flush();
106
+ expect(h.asked).toEqual([MATCH_WS]);
107
+ expect(h.sent).toEqual([{ workspaceId: MATCH_WS, touches: [{ agentId: 'agent-1', filePaths: ['src/planner.ts'] }] }]);
108
+ const json = JSON.stringify(h.sent);
109
+ for (const w of ['Editing', 'Edit"', 'secret code', '/Users/bob'])
110
+ expect(json).not.toContain(w);
111
+ });
112
+ it('any other workspace: asked once (workspace id only), then nothing sent — and not asked again until recheck', async () => {
113
+ const h = harness({ [OTHER_WS]: { live: false, recheckMs: 600_000 } });
114
+ h.fwd.touch({ workspaceId: OTHER_WS, agentId: 'agent-1', filePaths: ['src/a.ts'] });
115
+ await h.fwd.flush();
116
+ h.fwd.touch({ workspaceId: OTHER_WS, agentId: 'agent-1', filePaths: ['src/b.ts'] });
117
+ await h.fwd.flush();
118
+ expect(h.sent).toEqual([]);
119
+ expect(h.asked).toEqual([OTHER_WS]);
120
+ });
121
+ it('before GO the server says "ask again soon"; touches then are dropped, the ones after GO fly', async () => {
122
+ const status = { [MATCH_WS]: { live: false, recheckMs: 5_000 } };
123
+ const h = harness(status);
124
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['early.ts'] });
125
+ await h.fwd.flush();
126
+ expect(h.sent).toEqual([]);
127
+ status[MATCH_WS] = { live: true, recheckMs: 15_000 };
128
+ h.advance(5_001);
129
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['late.ts'] });
130
+ await h.fwd.flush();
131
+ expect(h.sent.flatMap((s) => s.touches)).toEqual([{ agentId: 'a', filePaths: ['late.ts'] }]);
132
+ });
133
+ it('the match ends: the next status says no, and forwarding stops', async () => {
134
+ const status = { [MATCH_WS]: { live: true, recheckMs: 15_000 } };
135
+ const h = harness(status);
136
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['one.ts'] });
137
+ await h.fwd.flush();
138
+ status[MATCH_WS] = { live: false, recheckMs: 600_000 };
139
+ h.advance(15_001);
140
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['two.ts'] });
141
+ await h.fwd.flush();
142
+ expect(h.sent.flatMap((s) => s.touches.flatMap((t) => t.filePaths))).toEqual(['one.ts']);
143
+ });
144
+ it('a status failure is "not live": nothing is sent', async () => {
145
+ const h = harness({ [MATCH_WS]: new Error('offline') });
146
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['x.ts'] });
147
+ await h.fwd.flush();
148
+ expect(h.sent).toEqual([]);
149
+ });
150
+ it('a refused POST stops forwarding for that workspace until it is asked again', async () => {
151
+ const h = harness({ [MATCH_WS]: { live: true, recheckMs: 15_000 } }, { sendFails: true });
152
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['x.ts'] });
153
+ await h.fwd.flush();
154
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['y.ts'] });
155
+ await h.fwd.flush();
156
+ expect(h.asked).toEqual([MATCH_WS]); // backed off: y.ts was dropped, not re-asked at once
157
+ });
158
+ it('a touch with no forwardable path, or no workspace, leaves nothing and asks nothing', async () => {
159
+ const h = harness({ [MATCH_WS]: { live: true, recheckMs: 15_000 } });
160
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: ['/etc/hosts'], cwd: CHECKOUT });
161
+ h.fwd.touch({ workspaceId: '', agentId: 'a', filePaths: ['x.ts'] });
162
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: undefined });
163
+ await h.fwd.flush();
164
+ expect(h.asked).toEqual([]);
165
+ expect(h.sent).toEqual([]);
166
+ });
167
+ it('batches: many touches in one flush go in requests of at most 50', async () => {
168
+ const h = harness({ [MATCH_WS]: { live: true, recheckMs: 15_000 } });
169
+ for (let i = 0; i < 120; i++)
170
+ h.fwd.touch({ workspaceId: MATCH_WS, agentId: 'a', filePaths: [`f${i}.ts`] });
171
+ await h.fwd.flush();
172
+ expect(h.sent.map((s) => s.touches.length)).toEqual([50, 50, 20]);
173
+ });
174
+ });
175
+ describe('apiClient — the two calls, over the Connect Token', () => {
176
+ it('status sends the workspace id only; POST sends { touches: [{ agentId, filePaths }] } only', async () => {
177
+ const calls = [];
178
+ const fetchImpl = async (url, init) => {
179
+ calls.push({ url, init });
180
+ return { ok: true, status: 200, json: async () => ({ success: true, data: { live: true, recheckMs: 15_000 } }) };
181
+ };
182
+ const api = { apiUrl: 'https://api.example/api/v1', connectToken: 'wm_ct_x', fetchImpl };
183
+ await expect((0, apiClient_1.getRivalTouchStatus)(api, MATCH_WS)).resolves.toEqual({ live: true, recheckMs: 15_000 });
184
+ await (0, apiClient_1.postRivalTouches)(api, MATCH_WS, [{ agentId: 'a', filePaths: ['src/x.ts'], thinking: 'no' }]);
185
+ expect(calls[0].url).toBe(`https://api.example/api/v1/ranked/workspaces/${MATCH_WS}/touches`);
186
+ expect(calls[0].init.method).toBe('GET');
187
+ expect(calls[0].init.body).toBeUndefined();
188
+ expect(calls[0].init.headers.Authorization).toBe('Bearer wm_ct_x');
189
+ expect(calls[1].init.method).toBe('POST');
190
+ expect(JSON.parse(calls[1].init.body)).toEqual({ touches: [{ agentId: 'a', filePaths: ['src/x.ts'] }] });
191
+ });
192
+ });
193
+ // ── the loopback server: which records reach the forwarder ──────────────────
194
+ class FakePty {
195
+ write() { }
196
+ resize() { }
197
+ kill() { }
198
+ onData() { }
199
+ onExit() { }
200
+ }
201
+ const ORIGIN = 'https://app.hiveop.io';
202
+ const TOKEN = 'session-token-123';
203
+ describe('bridge server → forwardRivalTouch', () => {
204
+ it("a local workspace agent's tailed record hands on workspace + agent + paths (never thinking/tool); a record for any other session hands on nothing", async () => {
205
+ const file = fs.mkdtempSync(path.join(os.tmpdir(), 'rival10-activity-')) + '/activity.jsonl';
206
+ fs.writeFileSync(file, '');
207
+ const tick = { fn: null };
208
+ const forwarded = [];
209
+ const handle = await (0, server_1.startBridgeServer)({
210
+ sessionToken: TOKEN,
211
+ allowedOrigin: ORIGIN,
212
+ sessions: new sessionManager_1.SessionManager({ spawn: () => new FakePty() }),
213
+ resolveLocalAgentCwd: () => CHECKOUT,
214
+ activityFile: file,
215
+ hiveOpSettingsPath: '/Users/t/.wen-market/claude-hooks-settings.json',
216
+ activityScheduleInterval: (cb) => { tick.fn = cb; return 'h'; },
217
+ activityCancelInterval: () => { },
218
+ forwardRivalTouch: (t) => forwarded.push(t),
219
+ });
220
+ try {
221
+ const ws = new ws_1.default(`ws://127.0.0.1:${handle.port}`, { headers: { origin: ORIGIN } });
222
+ const frames = [];
223
+ ws.on('message', (raw) => frames.push(JSON.parse(String(raw))));
224
+ await new Promise((r) => ws.on('open', r));
225
+ ws.send(JSON.stringify({ v: 1, token: TOKEN }));
226
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, agentId: 'agent-1', workspaceId: MATCH_WS }));
227
+ await waitFor(() => frames.some((f) => f.t === 'opened'));
228
+ fs.appendFileSync(file, [
229
+ { sessionId: 'agent-1', filePaths: [`${CHECKOUT}/src/planner.ts`], thinking: 'Editing planner.ts', tool: 'Edit' },
230
+ { sessionId: 'agent-1', thinking: 'Running npm test', tool: 'Bash' }, // no paths: nothing to forward
231
+ { sessionId: 'crew-member-9', filePaths: ['src/other.ts'] }, // not a loopback agent of a workspace
232
+ ].map((r) => JSON.stringify(r)).join('\n') + '\n');
233
+ tick.fn?.();
234
+ await waitFor(() => frames.filter((f) => f.t === 'activity').length === 3);
235
+ expect(forwarded).toEqual([
236
+ { workspaceId: MATCH_WS, agentId: 'agent-1', filePaths: [`${CHECKOUT}/src/planner.ts`], cwd: CHECKOUT },
237
+ ]);
238
+ // The pilot's own browser still gets the full frame, unchanged.
239
+ expect(frames.find((f) => f.t === 'activity')).toEqual({
240
+ t: 'activity',
241
+ sessionId: 'agent-1',
242
+ filePaths: [`${CHECKOUT}/src/planner.ts`],
243
+ thinking: 'Editing planner.ts',
244
+ });
245
+ // Closed: its later records are not forwarded.
246
+ ws.send(JSON.stringify({ t: 'close', sessionId: 'agent-1' }));
247
+ await new Promise((r) => setTimeout(r, 30));
248
+ fs.appendFileSync(file, JSON.stringify({ sessionId: 'agent-1', filePaths: ['src/late.ts'] }) + '\n');
249
+ tick.fn?.();
250
+ await new Promise((r) => setTimeout(r, 30));
251
+ expect(forwarded).toHaveLength(1);
252
+ ws.close();
253
+ }
254
+ finally {
255
+ await handle.close();
256
+ }
257
+ });
258
+ });
259
+ async function waitFor(cond, ms = 2_000) {
260
+ const until = Date.now() + ms;
261
+ while (!cond()) {
262
+ if (Date.now() > until)
263
+ throw new Error('timed out');
264
+ await new Promise((r) => setTimeout(r, 5));
265
+ }
266
+ }
@@ -4,10 +4,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const chalk_1 = __importDefault(require("chalk"));
7
+ const events_1 = require("events");
8
+ // sio-07 (docs/prd/authenticated-sockets.md): the CLI is a headless client —
9
+ // it must authenticate the Socket.IO handshake with `auth: { token }`
10
+ // (never the retired `walletPubkey` field) against the server's
11
+ // `/marketplace` path (backend/src/server.ts), and surface a clear message
12
+ // on refusal. connectWebSocket() is the single call site both
13
+ // `dashboard.ts` and `agent.ts` (`agent logs`) share, so exercising it here
14
+ // covers the handshake contract for both commands.
15
+ const TEST_API_KEY = 'wm_sk_' + 'a'.repeat(64);
16
+ const mockIo = jest.fn();
17
+ jest.mock('socket.io-client', () => ({
18
+ io: (...args) => mockIo(...args),
19
+ }));
20
+ jest.mock('../utils/config', () => ({
21
+ requireConfig: jest.fn().mockReturnValue({
22
+ apiUrl: 'https://api.wenrwa.com/api/v1/api/marketplace',
23
+ apiKey: 'wm_sk_' + 'a'.repeat(64),
24
+ }),
25
+ }));
7
26
  const websocket_1 = require("../utils/websocket");
27
+ class FakeSocket extends events_1.EventEmitter {
28
+ disconnect = jest.fn();
29
+ }
8
30
  beforeAll(() => {
9
31
  chalk_1.default.level = 0;
10
32
  });
33
+ beforeEach(() => {
34
+ mockIo.mockReset();
35
+ });
11
36
  function makeEvent(event, data, timestamp) {
12
37
  return { event, data, timestamp: timestamp || '2024-06-15T14:30:00.000Z' };
13
38
  }
@@ -59,3 +84,71 @@ describe('formatEventJson()', () => {
59
84
  expect(result.event).toBe('test');
60
85
  });
61
86
  });
87
+ describe('connectWebSocket() handshake (sio-07)', () => {
88
+ it('authenticates with auth.token = the API key', () => {
89
+ const fake = new FakeSocket();
90
+ mockIo.mockReturnValue(fake);
91
+ (0, websocket_1.connectWebSocket)(() => { });
92
+ expect(mockIo).toHaveBeenCalledTimes(1);
93
+ const [, opts] = mockIo.mock.calls[0];
94
+ expect(opts.auth).toEqual({ token: TEST_API_KEY });
95
+ });
96
+ it('never sends the retired walletPubkey field', () => {
97
+ const fake = new FakeSocket();
98
+ mockIo.mockReturnValue(fake);
99
+ (0, websocket_1.connectWebSocket)(() => { });
100
+ const [, opts] = mockIo.mock.calls[0];
101
+ expect(opts.auth).not.toHaveProperty('walletPubkey');
102
+ expect(JSON.stringify(opts)).not.toContain('walletPubkey');
103
+ });
104
+ it('connects on the server\'s /marketplace Socket.IO path', () => {
105
+ const fake = new FakeSocket();
106
+ mockIo.mockReturnValue(fake);
107
+ (0, websocket_1.connectWebSocket)(() => { });
108
+ const [, opts] = mockIo.mock.calls[0];
109
+ expect(opts.path).toBe('/marketplace');
110
+ });
111
+ it('shows a clear message and tells the user to log in again on CREDENTIAL_INVALID', () => {
112
+ const fake = new FakeSocket();
113
+ mockIo.mockReturnValue(fake);
114
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
115
+ (0, websocket_1.connectWebSocket)(() => { });
116
+ const err = new Error('invalid credential');
117
+ err.data = { code: 'CREDENTIAL_INVALID' };
118
+ fake.emit('connect_error', err);
119
+ const printed = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
120
+ expect(printed).toMatch(/wen-market auth login/);
121
+ errorSpy.mockRestore();
122
+ });
123
+ it('shows a clear message and tells the user to log in again on SESSION_REVOKED', () => {
124
+ const fake = new FakeSocket();
125
+ mockIo.mockReturnValue(fake);
126
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
127
+ (0, websocket_1.connectWebSocket)(() => { });
128
+ const err = new Error('session revoked');
129
+ err.data = { code: 'SESSION_REVOKED' };
130
+ fake.emit('connect_error', err);
131
+ const printed = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
132
+ expect(printed).toMatch(/wen-market auth login/);
133
+ errorSpy.mockRestore();
134
+ });
135
+ it('falls back to a generic message for other connect errors', () => {
136
+ const fake = new FakeSocket();
137
+ mockIo.mockReturnValue(fake);
138
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
139
+ (0, websocket_1.connectWebSocket)(() => { });
140
+ fake.emit('connect_error', new Error('ECONNREFUSED'));
141
+ const printed = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
142
+ expect(printed).toContain('ECONNREFUSED');
143
+ expect(printed).not.toMatch(/wen-market auth login/);
144
+ errorSpy.mockRestore();
145
+ });
146
+ it('subscribes to the requested channel on connect, unaffected by the auth change', () => {
147
+ const fake = new FakeSocket();
148
+ mockIo.mockReturnValue(fake);
149
+ const emitSpy = jest.spyOn(fake, 'emit');
150
+ (0, websocket_1.connectWebSocket)(() => { }, { channel: 'workspace', filters: { workspaceId: 'ws1' } });
151
+ fake.emit('connect');
152
+ expect(emitSpy).toHaveBeenCalledWith('subscribe', { channel: 'workspace', workspaceId: 'ws1' });
153
+ });
154
+ });
@@ -107,3 +107,17 @@ export declare function createLocalWorkspace(opts: BridgeApiOpts, params: {
107
107
  export declare function deleteWorkspace(opts: BridgeApiOpts, workspaceId: string): Promise<{
108
108
  success: boolean;
109
109
  }>;
110
+ /**
111
+ * rival-10 — should this bridge forward its agents' file touches in
112
+ * `workspaceId`? Sends the workspace id only. `live` is true only for a ranked
113
+ * match workspace this user owns while the match is in play.
114
+ */
115
+ export declare function getRivalTouchStatus(opts: BridgeApiOpts, workspaceId: string): Promise<{
116
+ live: boolean;
117
+ recheckMs: number;
118
+ }>;
119
+ /** rival-10 — forward live touches: agent id + file paths per touch, nothing else. */
120
+ export declare function postRivalTouches(opts: BridgeApiOpts, workspaceId: string, touches: Array<{
121
+ agentId: string;
122
+ filePaths: string[];
123
+ }>): Promise<void>;
@@ -8,6 +8,8 @@ exports.listDevices = listDevices;
8
8
  exports.revokeDevice = revokeDevice;
9
9
  exports.createLocalWorkspace = createLocalWorkspace;
10
10
  exports.deleteWorkspace = deleteWorkspace;
11
+ exports.getRivalTouchStatus = getRivalTouchStatus;
12
+ exports.postRivalTouches = postRivalTouches;
11
13
  class BridgeApiError extends Error {
12
14
  code;
13
15
  status;
@@ -96,3 +98,18 @@ function createLocalWorkspace(opts, params) {
96
98
  function deleteWorkspace(opts, workspaceId) {
97
99
  return bridgeRequest(opts, 'DELETE', `/workspaces/${workspaceId}`);
98
100
  }
101
+ /**
102
+ * rival-10 — should this bridge forward its agents' file touches in
103
+ * `workspaceId`? Sends the workspace id only. `live` is true only for a ranked
104
+ * match workspace this user owns while the match is in play.
105
+ */
106
+ async function getRivalTouchStatus(opts, workspaceId) {
107
+ const json = await bridgeRequest(opts, 'GET', `/ranked/workspaces/${encodeURIComponent(workspaceId)}/touches`);
108
+ return { live: json.data?.live === true, recheckMs: Number(json.data?.recheckMs) || 30_000 };
109
+ }
110
+ /** rival-10 — forward live touches: agent id + file paths per touch, nothing else. */
111
+ async function postRivalTouches(opts, workspaceId, touches) {
112
+ await bridgeRequest(opts, 'POST', `/ranked/workspaces/${encodeURIComponent(workspaceId)}/touches`, {
113
+ touches: touches.map((t) => ({ agentId: t.agentId, filePaths: t.filePaths })),
114
+ });
115
+ }
@@ -0,0 +1,48 @@
1
+ export interface ForwardedTouch {
2
+ agentId: string;
3
+ filePaths: string[];
4
+ }
5
+ export interface LiveTouchStatus {
6
+ live: boolean;
7
+ recheckMs: number;
8
+ }
9
+ export interface RivalTouchForwarderOpts {
10
+ /** GET /ranked/workspaces/:id/touches — sends the workspace id only. */
11
+ status(workspaceId: string): Promise<LiveTouchStatus>;
12
+ /** POST /ranked/workspaces/:id/touches. */
13
+ send(workspaceId: string, touches: ForwardedTouch[]): Promise<void>;
14
+ now?: () => number;
15
+ flushMs?: number;
16
+ scheduleInterval?: (cb: () => void, ms: number) => unknown;
17
+ cancelInterval?: (handle: unknown) => void;
18
+ }
19
+ export interface RivalTouchForwarder {
20
+ /** One activity record of a local agent. `cwd` is the agent's checkout, when known. */
21
+ touch(t: {
22
+ workspaceId: string;
23
+ agentId: string;
24
+ filePaths?: unknown;
25
+ cwd?: string;
26
+ }): void;
27
+ /** Runs one flush now (tests; the interval does the same). */
28
+ flush(): Promise<void>;
29
+ close(): void;
30
+ }
31
+ /** Mirrors the server's limits (backend LOCAL_TOUCH_LIMITS). */
32
+ export declare const FORWARD_LIMITS: {
33
+ readonly touchesPerRequest: 50;
34
+ readonly pathsPerTouch: 20;
35
+ readonly pathLength: 1024;
36
+ readonly agentIdLength: 128;
37
+ /** Pending touches kept per workspace between flushes; beyond this they are dropped. */
38
+ readonly pendingPerWorkspace: 200;
39
+ };
40
+ /**
41
+ * The paths that may leave the laptop for one touch. Relative paths are kept
42
+ * (normalised); an absolute path is made relative to `cwd` when it lies
43
+ * inside it, and dropped when it lies outside. With no known `cwd` an
44
+ * absolute path is kept as is (the server maps it onto the repo tree or drops it).
45
+ * `..` anywhere → dropped.
46
+ */
47
+ export declare function forwardablePaths(raw: unknown, cwd?: string): string[];
48
+ export declare function createRivalTouchForwarder(opts: RivalTouchForwarderOpts): RivalTouchForwarder;
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FORWARD_LIMITS = void 0;
37
+ exports.forwardablePaths = forwardablePaths;
38
+ exports.createRivalTouchForwarder = createRivalTouchForwarder;
39
+ /**
40
+ * rival-10 — the LOCAL lane's live file touches, forwarded to a ranked rival.
41
+ *
42
+ * A hosted player's agents fly drones on their opponent's scene the moment
43
+ * they touch a file (the relay sees their activity). A local player's touches
44
+ * only ever went loopback → their own browser, so their opponent saw nothing
45
+ * until a commit. This closes that gap (two-lane rule): during a LIVE ranked
46
+ * match, the bridge forwards its agents' touches over the Connect Token it
47
+ * already holds — the same authenticated channel as device pairing — to
48
+ * POST /ranked/workspaces/:workspaceId/touches { touches: [{ agentId, filePaths }] }
49
+ * which feeds them into the same rival feed as a hosted agent's.
50
+ *
51
+ * WHAT LEAVES THE LAPTOP, and when:
52
+ * - only the agent id and file PATHS — never thinking cues, tool names,
53
+ * content, prompts or anything else on the activity record;
54
+ * - a path under the agent's checkout goes up repo-relative (no home
55
+ * directory); a path OUTSIDE a known checkout is dropped here, not sent;
56
+ * - only for a workspace the server has just said is a live ranked match
57
+ * this user owns (GET /ranked/workspaces/:id/touches → { live, recheckMs }).
58
+ * Asking sends the workspace id only. Until the answer is `live: true`,
59
+ * touches are dropped, never queued for later — every other workspace
60
+ * (the overwhelming majority) sends nothing, ever.
61
+ * The server checks ownership and liveness again on every POST.
62
+ *
63
+ * Batched per workspace every `flushMs` (default 500 ms, so a drone flies
64
+ * well inside ~2 s). Never throws into the caller: it sits on the activity
65
+ * tail, and a forwarding failure must never touch the pilot's own terminals.
66
+ */
67
+ const path = __importStar(require("path"));
68
+ /** Mirrors the server's limits (backend LOCAL_TOUCH_LIMITS). */
69
+ exports.FORWARD_LIMITS = {
70
+ touchesPerRequest: 50,
71
+ pathsPerTouch: 20,
72
+ pathLength: 1024,
73
+ agentIdLength: 128,
74
+ /** Pending touches kept per workspace between flushes; beyond this they are dropped. */
75
+ pendingPerWorkspace: 200,
76
+ };
77
+ /** After a failed status read or a refused POST: do not ask again for this long. */
78
+ const BACKOFF_MS = 30_000;
79
+ /** Never trust a server-sent recheck beyond this (a finished match, a non-match workspace). */
80
+ const MAX_RECHECK_MS = 10 * 60_000;
81
+ /**
82
+ * The paths that may leave the laptop for one touch. Relative paths are kept
83
+ * (normalised); an absolute path is made relative to `cwd` when it lies
84
+ * inside it, and dropped when it lies outside. With no known `cwd` an
85
+ * absolute path is kept as is (the server maps it onto the repo tree or drops it).
86
+ * `..` anywhere → dropped.
87
+ */
88
+ function forwardablePaths(raw, cwd) {
89
+ if (!Array.isArray(raw))
90
+ return [];
91
+ const out = [];
92
+ for (const p of raw) {
93
+ if (typeof p !== 'string')
94
+ continue;
95
+ const s = p.trim();
96
+ if (!s || s.length > exports.FORWARD_LIMITS.pathLength)
97
+ continue;
98
+ let rel;
99
+ if (path.isAbsolute(s)) {
100
+ if (cwd) {
101
+ const r = path.relative(cwd, s);
102
+ if (!r || r.startsWith('..') || path.isAbsolute(r))
103
+ continue; // outside the checkout: stays here
104
+ rel = r;
105
+ }
106
+ else {
107
+ rel = s;
108
+ }
109
+ }
110
+ else {
111
+ rel = s;
112
+ }
113
+ rel = rel.replace(/\\/g, '/');
114
+ if (rel.split('/').includes('..'))
115
+ continue;
116
+ if (!out.includes(rel))
117
+ out.push(rel);
118
+ if (out.length >= exports.FORWARD_LIMITS.pathsPerTouch)
119
+ break;
120
+ }
121
+ return out;
122
+ }
123
+ function createRivalTouchForwarder(opts) {
124
+ const now = opts.now ?? Date.now;
125
+ const scheduleInterval = opts.scheduleInterval ?? ((cb, ms) => setInterval(cb, ms));
126
+ const cancelInterval = opts.cancelInterval ?? ((h) => clearInterval(h));
127
+ /** Per workspace: the last answer, valid until `until`. */
128
+ const known = new Map();
129
+ const asking = new Map();
130
+ const pending = new Map();
131
+ let flushing = false;
132
+ const ask = (workspaceId) => {
133
+ const inFlight = asking.get(workspaceId);
134
+ if (inFlight)
135
+ return inFlight;
136
+ const p = opts
137
+ .status(workspaceId)
138
+ .then((s) => {
139
+ const recheck = Math.min(MAX_RECHECK_MS, Math.max(1_000, Number(s?.recheckMs) || BACKOFF_MS));
140
+ known.set(workspaceId, { live: s?.live === true, until: now() + recheck });
141
+ })
142
+ .catch(() => {
143
+ known.set(workspaceId, { live: false, until: now() + BACKOFF_MS });
144
+ })
145
+ .finally(() => asking.delete(workspaceId));
146
+ asking.set(workspaceId, p);
147
+ return p;
148
+ };
149
+ const touch = (t) => {
150
+ try {
151
+ if (!t || typeof t.workspaceId !== 'string' || !t.workspaceId)
152
+ return;
153
+ if (typeof t.agentId !== 'string' || !t.agentId || t.agentId.length > exports.FORWARD_LIMITS.agentIdLength)
154
+ return;
155
+ const filePaths = forwardablePaths(t.filePaths, t.cwd);
156
+ if (filePaths.length === 0)
157
+ return;
158
+ const k = known.get(t.workspaceId);
159
+ if (k && now() < k.until) {
160
+ if (!k.live)
161
+ return; // not a live match of ours: nothing is kept, nothing leaves
162
+ }
163
+ else {
164
+ // Unknown or stale: ask (workspace id only). This touch waits for the answer in `pending`
165
+ // and is dropped at flush unless the answer is live.
166
+ void ask(t.workspaceId);
167
+ }
168
+ const q = pending.get(t.workspaceId) ?? [];
169
+ if (q.length >= exports.FORWARD_LIMITS.pendingPerWorkspace)
170
+ return;
171
+ q.push({ agentId: t.agentId, filePaths });
172
+ pending.set(t.workspaceId, q);
173
+ }
174
+ catch {
175
+ // never into the activity tail
176
+ }
177
+ };
178
+ const flush = async () => {
179
+ if (flushing)
180
+ return;
181
+ flushing = true;
182
+ try {
183
+ for (const workspaceId of [...pending.keys()]) {
184
+ const inFlight = asking.get(workspaceId);
185
+ if (inFlight)
186
+ await inFlight;
187
+ const k = known.get(workspaceId);
188
+ const q = pending.get(workspaceId) ?? [];
189
+ pending.delete(workspaceId);
190
+ if (!k || !k.live || q.length === 0)
191
+ continue; // dropped, never sent
192
+ for (let i = 0; i < q.length; i += exports.FORWARD_LIMITS.touchesPerRequest) {
193
+ try {
194
+ await opts.send(workspaceId, q.slice(i, i + exports.FORWARD_LIMITS.touchesPerRequest));
195
+ }
196
+ catch {
197
+ // Refused (match over, not ours) or unreachable: stop, and ask again before sending more.
198
+ known.set(workspaceId, { live: false, until: now() + BACKOFF_MS });
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ finally {
205
+ flushing = false;
206
+ }
207
+ };
208
+ const handle = scheduleInterval(() => void flush(), opts.flushMs ?? 500);
209
+ handle?.unref?.();
210
+ return { touch, flush, close: () => cancelInterval(handle) };
211
+ }
@@ -226,6 +226,19 @@ export interface BridgeServerOpts {
226
226
  * bridge.ts) — see the 'open' handler's agentId branch.
227
227
  */
228
228
  hiveOpSettingsPath?: string;
229
+ /**
230
+ * rival-10 — every file touch of a LOCAL WORKSPACE AGENT (an `open` with
231
+ * agentId + workspaceId), handed on for the ranked rival feed. The hook
232
+ * decides whether anything leaves the laptop (rivalTouchForwarder.ts: only
233
+ * during a live ranked match this user owns, only paths). It receives the
234
+ * record's paths only — never its thinking cue or tool. Absent → nothing.
235
+ */
236
+ forwardRivalTouch?: (t: {
237
+ workspaceId: string;
238
+ agentId: string;
239
+ filePaths: string[];
240
+ cwd?: string;
241
+ }) => void;
229
242
  /** Injectable timer seam for the activity tail (tests). */
230
243
  activityScheduleInterval?: (cb: () => void, ms: number) => unknown;
231
244
  /** See activityScheduleInterval. */
@@ -92,6 +92,10 @@ function startBridgeServer(opts) {
92
92
  // never invoked for it, however many ticks fire. Cleaned up on `close`,
93
93
  // mirroring devServerWatchers's lifetime above.
94
94
  const changesWorkspaceOf = new Map();
95
+ // rival-10 — sessionId (== agentId) → the workspaceId its `open` named, for
96
+ // EVERY local workspace agent (resolved folder or not): the rival forwarder
97
+ // needs the workspace to ask whether it is a live match. Cleaned up on `close`.
98
+ const agentWorkspaceOf = new Map();
95
99
  // tchp-12 — the ACTIVE, REGISTERED local-workspace sessions worth polling
96
100
  // for working-tree changes: every changesWorkspaceOf entry whose session is
97
101
  // still LIVE per the shared SessionManager (a `close` frame — or this
@@ -530,6 +534,21 @@ function startBridgeServer(opts) {
530
534
  // anchor / reportChanges off) silently drops the touch — see
531
535
  // SessionTouchTracker#touch's doc comment.
532
536
  touchTracker.touch(rec.sessionId, rec.tool, rec.filePaths, opts.sessions.cwdOf(rec.sessionId));
537
+ // rival-10 — paths only (no thinking, no tool); the forwarder decides if they leave the laptop.
538
+ const rivalWorkspaceId = agentWorkspaceOf.get(rec.sessionId);
539
+ if (opts.forwardRivalTouch && rivalWorkspaceId && rec.filePaths && rec.filePaths.length > 0) {
540
+ try {
541
+ opts.forwardRivalTouch({
542
+ workspaceId: rivalWorkspaceId,
543
+ agentId: rec.sessionId,
544
+ filePaths: [...rec.filePaths],
545
+ cwd: opts.sessions.cwdOf(rec.sessionId),
546
+ });
547
+ }
548
+ catch {
549
+ // never let forwarding break the pilot's own activity stream
550
+ }
551
+ }
533
552
  for (const client of wss.clients) {
534
553
  send(client, {
535
554
  t: 'activity',
@@ -667,6 +686,10 @@ function startBridgeServer(opts) {
667
686
  // the same agentId (SessionManager#open supersedes) overwrites
668
687
  // this entry with whatever the fresh open resolves, so a
669
688
  // session never polls against a stale/prior workspaceId.
689
+ if (frame.workspaceId)
690
+ agentWorkspaceOf.set(agentId, frame.workspaceId);
691
+ else
692
+ agentWorkspaceOf.delete(agentId);
670
693
  if (cwd && frame.workspaceId) {
671
694
  changesWorkspaceOf.set(agentId, frame.workspaceId);
672
695
  // tchp-21 — same scoping as the line above: only a REGISTERED
@@ -871,6 +894,7 @@ function startBridgeServer(opts) {
871
894
  opts.sessions.close(frame.sessionId);
872
895
  devServerWatchers.delete(frame.sessionId);
873
896
  changesWorkspaceOf.delete(frame.sessionId); // tchp-12 — no orphan polling for a closed session
897
+ agentWorkspaceOf.delete(frame.sessionId); // rival-10
874
898
  maybeReleaseSessionAnchor(frame.sessionId, closedCwd);
875
899
  break;
876
900
  }
@@ -80,6 +80,7 @@ const config_2 = require("../utils/config");
80
80
  const deviceRegistration_1 = require("../bridge/deviceRegistration");
81
81
  const pair_1 = require("./pair");
82
82
  const apiClient_1 = require("../bridge/apiClient");
83
+ const rivalTouchForwarder_1 = require("../bridge/rivalTouchForwarder");
83
84
  const folderRegistry_1 = require("../bridge/folderRegistry");
84
85
  const localPathValidation_1 = require("../bridge/localPathValidation");
85
86
  const localPathValidationFs_1 = require("../bridge/localPathValidationFs");
@@ -990,7 +991,16 @@ exports.bridgeCommand = new commander_1.Command('bridge')
990
991
  // other: the pane could not watch a member, and reaping one would leave
991
992
  // the other resident.
992
993
  const localSessions = new sessionManager_1.SessionManager((0, realPty_1.realPtyFactory)());
994
+ // rival-10 — a local agent's live file touches reach a ranked rival, like a hosted agent's. Over the
995
+ // Connect Token this bridge already holds (no new connection); only paths, only while the server says
996
+ // the workspace is a live ranked match this user owns — every other workspace sends nothing.
997
+ const rivalApi = { apiUrl: bridgeApiUrl(), connectToken: config.connectToken };
998
+ const rivalTouches = (0, rivalTouchForwarder_1.createRivalTouchForwarder)({
999
+ status: (workspaceId) => (0, apiClient_1.getRivalTouchStatus)(rivalApi, workspaceId),
1000
+ send: (workspaceId, touches) => (0, apiClient_1.postRivalTouches)(rivalApi, workspaceId, touches),
1001
+ });
993
1002
  const handle = await (0, server_1.startBridgeServer)({
1003
+ forwardRivalTouch: rivalTouches.touch,
994
1004
  sessionToken,
995
1005
  allowedOrigin: opts.origin,
996
1006
  sessions: localSessions,
@@ -10,12 +10,32 @@ const socket_io_client_1 = require("socket.io-client");
10
10
  const chalk_1 = __importDefault(require("chalk"));
11
11
  const config_1 = require("./config");
12
12
  const format_1 = require("./format");
13
+ // sio-07 (docs/prd/authenticated-sockets.md): headless clients (the CLI)
14
+ // authenticate the Socket.IO handshake with `auth: { token }` — the API key
15
+ // (`wm_sk_…`) — against the server's dedicated `/marketplace` path
16
+ // (backend/src/server.ts). The legacy `auth: { walletPubkey }` field proved
17
+ // nothing (public keys are public) and is retired; the server's
18
+ // socket-auth middleware (backend/src/middleware/socket-auth.middleware.ts)
19
+ // refuses bad/revoked credentials with a `connect_error` carrying
20
+ // `err.data.code` of CREDENTIAL_INVALID or SESSION_REVOKED.
21
+ const AUTH_LOGIN_HINT = 'Run `wen-market auth login` to sign in again.';
22
+ function connectRefusalMessage(err) {
23
+ const code = err?.data?.code;
24
+ if (code === 'CREDENTIAL_INVALID') {
25
+ return `Your API key is invalid or expired. ${AUTH_LOGIN_HINT}`;
26
+ }
27
+ if (code === 'SESSION_REVOKED') {
28
+ return `Your session was revoked. ${AUTH_LOGIN_HINT}`;
29
+ }
30
+ return `WebSocket error: ${err?.message}`;
31
+ }
13
32
  function connectWebSocket(onEvent, options = {}) {
14
33
  const config = (0, config_1.requireConfig)();
15
34
  const wsUrl = config.apiUrl.replace('/api/marketplace', '');
16
35
  const socket = (0, socket_io_client_1.io)(wsUrl, {
36
+ path: '/marketplace',
17
37
  transports: ['websocket'],
18
- auth: { walletPubkey: config.apiKey },
38
+ auth: { token: config.apiKey },
19
39
  });
20
40
  socket.on('connect', () => {
21
41
  if (options.channel) {
@@ -29,7 +49,7 @@ function connectWebSocket(onEvent, options = {}) {
29
49
  onEvent(event);
30
50
  });
31
51
  socket.on('connect_error', (err) => {
32
- console.error(chalk_1.default.red(`WebSocket error: ${err.message}`));
52
+ console.error(chalk_1.default.red(connectRefusalMessage(err)));
33
53
  });
34
54
  return socket;
35
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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",