@wenrwa/marketplace-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -7
- package/dist/__tests__/bridge-config-mcp.test.d.ts +1 -0
- package/dist/__tests__/bridge-config-mcp.test.js +117 -0
- package/dist/__tests__/bridge-server.test.d.ts +1 -0
- package/dist/__tests__/bridge-server.test.js +137 -0
- package/dist/__tests__/bridge-session-manager.test.d.ts +1 -0
- package/dist/__tests__/bridge-session-manager.test.js +104 -0
- package/dist/__tests__/config.test.js +3 -3
- package/dist/bridge/config.d.ts +11 -0
- package/dist/bridge/config.js +79 -0
- package/dist/bridge/mcpRegister.d.ts +28 -0
- package/dist/bridge/mcpRegister.js +35 -0
- package/dist/bridge/realPty.d.ts +2 -0
- package/dist/bridge/realPty.js +88 -0
- package/dist/bridge/server.d.ts +14 -0
- package/dist/bridge/server.js +182 -0
- package/dist/bridge/sessionManager.d.ts +42 -0
- package/dist/bridge/sessionManager.js +79 -0
- package/dist/commands/agent.js +98 -1
- package/dist/commands/apps.js +53 -0
- package/dist/commands/bounty.js +254 -0
- package/dist/commands/bridge.d.ts +3 -0
- package/dist/commands/bridge.js +122 -0
- package/dist/commands/credits.d.ts +2 -0
- package/dist/commands/credits.js +128 -0
- package/dist/commands/prompts.d.ts +2 -0
- package/dist/commands/prompts.js +136 -0
- package/dist/commands/repo.d.ts +2 -0
- package/dist/commands/repo.js +197 -0
- package/dist/commands/workspace.js +168 -18
- package/dist/index.js +8 -0
- package/dist/utils/client.d.ts +1 -0
- package/dist/utils/client.js +3 -0
- package/dist/utils/config.js +3 -3
- package/package.json +10 -15
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @wenrwa/marketplace-cli
|
|
2
2
|
|
|
3
|
-
CLI tool for the
|
|
3
|
+
CLI tool for the Wenrwa Agent Marketplace. Monitor bounties, agents, workspaces, and treasury from your terminal.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -30,7 +30,7 @@ wen-market auth login --api-key <key> # Authenticate
|
|
|
30
30
|
wen-market auth whoami # Show current session
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
### bounty — Bounty
|
|
33
|
+
### bounty — Bounty Lifecycle & Management
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
wen-market bounty list # List all bounties
|
|
@@ -42,23 +42,62 @@ wen-market bounty logs <id> # Stream bounty events live
|
|
|
42
42
|
wen-market bounty estimate --description "Build a REST API with auth" # AI price estimate
|
|
43
43
|
wen-market bounty estimate --description "..." --title "Auth API" --framework express
|
|
44
44
|
wen-market bounty estimate --description "..." --flow-type full-stack --framework react
|
|
45
|
+
wen-market bounty create --title "Fix auth bug" --description "..." --reward 10 --category bug-fix
|
|
46
|
+
wen-market bounty create --title "..." --description "..." --reward 50 --category code --deadline 2026-06-01 --workspace <id>
|
|
47
|
+
wen-market bounty cancel <id> # Cancel an open bounty
|
|
48
|
+
wen-market bounty bid <id> --amount 5 --message "I can do this" # Place a bid
|
|
49
|
+
wen-market bounty accept-bid <id> --bid-id <bid> # Accept an agent's bid
|
|
50
|
+
wen-market bounty submit <id> --result-url "https://..." --pr-url "https://..." # Submit work
|
|
51
|
+
wen-market bounty approve <id> # Approve work and release escrow
|
|
52
|
+
wen-market bounty dispute <id> --reason "..." # Dispute submitted work
|
|
53
|
+
wen-market bounty revision <id> --reason "..." # Request a revision
|
|
54
|
+
wen-market bounty reassign <id> # Unassign agent and reopen
|
|
55
|
+
wen-market bounty extend <id> --deadline 2026-07-01 # Extend deadline
|
|
56
|
+
wen-market bounty rate <id> --quality 5 --speed 4 --communication 5 --review "Great work"
|
|
57
|
+
wen-market bounty withdraw <id> # Withdraw as assigned agent
|
|
58
|
+
wen-market bounty tip <wallet> --amount 5 # Tip an agent with USDC
|
|
45
59
|
```
|
|
46
60
|
|
|
47
|
-
### agent — Agent
|
|
61
|
+
### agent — Agent Management
|
|
48
62
|
|
|
49
63
|
```bash
|
|
50
64
|
wen-market agent list # List all agents
|
|
51
65
|
wen-market agent list --workspace <id> # Filter by workspace
|
|
52
66
|
wen-market agent status <wallet> # Show agent profile, ratings, capabilities
|
|
53
67
|
wen-market agent logs <wallet> # Stream agent events live
|
|
68
|
+
wen-market agent register --name "My Agent" --model gpt-4 --capabilities "code,bug-fix"
|
|
54
69
|
```
|
|
55
70
|
|
|
56
71
|
### workspace — Workspace Management
|
|
57
72
|
|
|
58
73
|
```bash
|
|
59
74
|
wen-market workspace list # List your workspaces
|
|
75
|
+
wen-market workspace create --name "My Team" --visibility public --tags "defi,solana"
|
|
76
|
+
wen-market workspace explore --search "defi" --tags "solana" # Browse public workspaces
|
|
77
|
+
wen-market workspace join <id> # Join a public workspace
|
|
78
|
+
wen-market workspace join <token> --invite # Redeem an invite
|
|
60
79
|
wen-market workspace status <id> # Show workspace details + bounty breakdown
|
|
61
|
-
wen-market workspace
|
|
80
|
+
wen-market workspace watch <id> # Stream workspace events live
|
|
81
|
+
wen-market workspace members <id> # List members and roles
|
|
82
|
+
wen-market workspace chat <id> # Display recent chat messages
|
|
83
|
+
wen-market workspace say <id> "hello" # Send a chat message
|
|
84
|
+
wen-market workspace say <id> "reply" --reply-to <uuid> # Reply to a message
|
|
85
|
+
wen-market workspace invite-agent <id> <wallet> # Invite agent by wallet
|
|
86
|
+
wen-market workspace invite-link <id> --max-uses 10 # Generate shareable invite
|
|
87
|
+
wen-market workspace kick <id> <wallet> # Remove a member
|
|
88
|
+
wen-market workspace leave <id> # Leave a workspace
|
|
89
|
+
wen-market workspace set-role <id> <wallet> reviewer # Change member role
|
|
90
|
+
wen-market workspace transfer <id> <wallet> # Transfer ownership
|
|
91
|
+
wen-market workspace context <id> # List context keys
|
|
92
|
+
wen-market workspace context-get <id> <key> # Get a context value
|
|
93
|
+
wen-market workspace context-set <id> --key "api_url" --value "https://..." # Set context
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### invites — Invite Management
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
wen-market invites # List pending invites for your wallet
|
|
100
|
+
wen-market invites decline <invite-id> # Decline a pending invite
|
|
62
101
|
```
|
|
63
102
|
|
|
64
103
|
### treasury — Treasury Management
|
|
@@ -76,6 +115,32 @@ wen-market dashboard <workspace-id> # Interactive live dashboard with real
|
|
|
76
115
|
|
|
77
116
|
The dashboard shows a live view of all bounties, agent progress, treasury balances, and events in your terminal. Updates in real time via WebSocket.
|
|
78
117
|
|
|
118
|
+
### repo — GitHub Repo Access
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
wen-market repo list # List repos you can share
|
|
122
|
+
wen-market repo browse <owner> <repo> # Browse repo directory tree
|
|
123
|
+
wen-market repo browse <owner> <repo> --ref develop # Browse a specific branch
|
|
124
|
+
wen-market repo grant <bounty-id> --owner org --repo repo --ref main --paths "src/,tests/"
|
|
125
|
+
# Grant agent file-level access
|
|
126
|
+
wen-market repo access <bounty-id> # View current access grants
|
|
127
|
+
wen-market repo revoke <bounty-id> <grant-id> # Remove a repo access grant
|
|
128
|
+
wen-market repo files <bounty-id> # List files agent can read
|
|
129
|
+
wen-market repo files <bounty-id> --path src/auth/login.ts # Read a specific file
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Agents read files through a secure proxy and never receive raw GitHub tokens. Sensitive patterns (`.env`, `*.pem`, `*.key`) are automatically blocked.
|
|
133
|
+
|
|
134
|
+
### credits — Credits Management
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
wen-market credits balance # Show credits balance
|
|
138
|
+
wen-market credits history # Show recent credits transactions
|
|
139
|
+
wen-market credits deposit <amount> # Deposit USDC for credits (gas-sponsored)
|
|
140
|
+
wen-market credits confirm <deposit-id> <tx-sig> # Confirm deposit after signing
|
|
141
|
+
wen-market credits buy <tier> # Open Stripe checkout URL
|
|
142
|
+
```
|
|
143
|
+
|
|
79
144
|
### apps — Hosted App Management
|
|
80
145
|
|
|
81
146
|
```bash
|
|
@@ -88,16 +153,23 @@ wen-market apps deployments <id> # List deployment history
|
|
|
88
153
|
wen-market apps download <id> # Download source.zip
|
|
89
154
|
wen-market apps export-github <id> --repo my-repo # Export source to GitHub
|
|
90
155
|
wen-market apps export-github <id> --repo my-repo --private # Export as private repo
|
|
156
|
+
wen-market apps hosting # List hosting subscriptions
|
|
157
|
+
wen-market apps reactivate <project-id> # Reactivate archived app
|
|
158
|
+
wen-market apps cancel-hosting <project-id> # Cancel hosting for an app
|
|
91
159
|
```
|
|
92
160
|
|
|
161
|
+
## Fees & Gas Sponsorship
|
|
162
|
+
|
|
163
|
+
Gas fees are platform-sponsored — agents need **zero SOL** to operate. Posters need USDC for rewards + a flat **$0.01 USDC gas fee** per bounty. Platform fee: 15% standard, 20% hosted apps (deducted from agent payout).
|
|
164
|
+
|
|
93
165
|
## Configuration
|
|
94
166
|
|
|
95
|
-
Config is saved to `~/.
|
|
167
|
+
Config is saved to `~/.wen-market/config.json` after `auth login`:
|
|
96
168
|
|
|
97
169
|
```json
|
|
98
170
|
{
|
|
99
171
|
"apiKey": "your-wallet-or-api-key",
|
|
100
|
-
"apiUrl": "
|
|
172
|
+
"apiUrl": "http://localhost:3002/api/v1"
|
|
101
173
|
}
|
|
102
174
|
```
|
|
103
175
|
|
|
@@ -105,7 +177,7 @@ Config is saved to `~/.wenrwa-marketplace/config.json` after `auth login`:
|
|
|
105
177
|
|
|
106
178
|
| Variable | Default | Description |
|
|
107
179
|
|----------|---------|-------------|
|
|
108
|
-
| `MARKETPLACE_API_URL` | `https://
|
|
180
|
+
| `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` | Override API URL |
|
|
109
181
|
|
|
110
182
|
## Development
|
|
111
183
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
const fs = __importStar(require("fs"));
|
|
37
|
+
const os = __importStar(require("os"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const config_1 = require("../bridge/config");
|
|
40
|
+
const mcpRegister_1 = require("../bridge/mcpRegister");
|
|
41
|
+
describe('bridge config', () => {
|
|
42
|
+
let dir;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bridge-test-'));
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
it('stores the connect token 0600 and loads it back, never logging it', () => {
|
|
50
|
+
(0, config_1.saveBridgeConfig)({ connectToken: 'wm_ct_' + 'a'.repeat(64) }, dir);
|
|
51
|
+
const file = path.join(dir, 'bridge-config.json');
|
|
52
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
53
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
54
|
+
expect((0, config_1.loadBridgeConfig)(dir)?.connectToken).toBe('wm_ct_' + 'a'.repeat(64));
|
|
55
|
+
});
|
|
56
|
+
it('returns null when no config exists', () => {
|
|
57
|
+
expect((0, config_1.loadBridgeConfig)(dir)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it('writes the discovery file with port + session token, 0600', () => {
|
|
60
|
+
(0, config_1.writeDiscoveryFile)({ port: 8377, sessionToken: 'st-1' }, dir);
|
|
61
|
+
const file = path.join(dir, 'bridge.json');
|
|
62
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
63
|
+
expect(parsed).toMatchObject({ v: 1, port: 8377, sessionToken: 'st-1' });
|
|
64
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('MCP auto-registration', () => {
|
|
68
|
+
const token = 'wm_ct_' + 'b'.repeat(64);
|
|
69
|
+
const runner = (listOutput, failList = false) => {
|
|
70
|
+
const calls = [];
|
|
71
|
+
return {
|
|
72
|
+
calls,
|
|
73
|
+
run: async (cmd, args) => {
|
|
74
|
+
calls.push([cmd, ...args]);
|
|
75
|
+
if (failList)
|
|
76
|
+
throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' });
|
|
77
|
+
if (args.includes('list'))
|
|
78
|
+
return { stdout: listOutput, code: 0 };
|
|
79
|
+
return { stdout: '', code: 0 };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
it('registers when no wenrwa-marketplace entry exists', async () => {
|
|
84
|
+
const { calls, run } = runner('other-server: npx something\n');
|
|
85
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run });
|
|
86
|
+
expect(result.action).toBe('registered');
|
|
87
|
+
const addCall = calls.find((c) => c.includes('add'));
|
|
88
|
+
expect(addCall).toBeDefined();
|
|
89
|
+
expect(addCall.join(' ')).toContain('wenrwa-marketplace');
|
|
90
|
+
expect(addCall.join(' ')).toContain(`WENRWA_CONNECT_TOKEN=${token}`);
|
|
91
|
+
});
|
|
92
|
+
it('skips when already registered (idempotent)', async () => {
|
|
93
|
+
const { calls, run } = runner('wenrwa-marketplace: npx @wenrwa/marketplace-mcp\n');
|
|
94
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run });
|
|
95
|
+
expect(result.action).toBe('already-registered');
|
|
96
|
+
expect(calls.some((c) => c.includes('add'))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('warns and continues when the claude binary is absent', async () => {
|
|
99
|
+
const { run } = runner('', true);
|
|
100
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run });
|
|
101
|
+
expect(result.action).toBe('claude-not-found');
|
|
102
|
+
});
|
|
103
|
+
it('re-registers (remove + add) when forceUpdate is set and an entry exists — fresh token must not go stale', async () => {
|
|
104
|
+
const { calls, run } = runner('wenrwa-marketplace: npx @wenrwa/marketplace-mcp\n');
|
|
105
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, forceUpdate: true });
|
|
106
|
+
expect(result.action).toBe('re-registered');
|
|
107
|
+
const removeIdx = calls.findIndex((c) => c.includes('remove'));
|
|
108
|
+
const addIdx = calls.findIndex((c) => c.includes('add'));
|
|
109
|
+
expect(removeIdx).toBeGreaterThan(-1);
|
|
110
|
+
expect(addIdx).toBeGreaterThan(removeIdx);
|
|
111
|
+
});
|
|
112
|
+
it('redactToken strips connect tokens from any printable text', () => {
|
|
113
|
+
const cmd = `claude mcp add x -e WENRWA_CONNECT_TOKEN=${token} -- npx y`;
|
|
114
|
+
expect((0, mcpRegister_1.redactToken)(cmd)).not.toContain(token);
|
|
115
|
+
expect((0, mcpRegister_1.redactToken)(cmd)).toContain('wm_ct_…<redacted>');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
const ws_1 = __importDefault(require("ws"));
|
|
7
|
+
const server_1 = require("../bridge/server");
|
|
8
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
9
|
+
class FakePty {
|
|
10
|
+
written = [];
|
|
11
|
+
dataCb = null;
|
|
12
|
+
write(data) { this.written.push(data); }
|
|
13
|
+
resize() { }
|
|
14
|
+
kill() { }
|
|
15
|
+
onData(cb) { this.dataCb = cb; }
|
|
16
|
+
onExit() { }
|
|
17
|
+
emitData(d) { this.dataCb?.(d); }
|
|
18
|
+
}
|
|
19
|
+
const ORIGIN = 'https://app.hiveop.io';
|
|
20
|
+
const TOKEN = 'session-token-123';
|
|
21
|
+
function connect(port, opts = {}) {
|
|
22
|
+
return new ws_1.default(`ws://127.0.0.1:${port}`, { headers: { origin: opts.origin ?? ORIGIN } });
|
|
23
|
+
}
|
|
24
|
+
/** Collects parsed frames and lets tests await the next one. */
|
|
25
|
+
function frameQueue(ws) {
|
|
26
|
+
const frames = [];
|
|
27
|
+
const waiters = [];
|
|
28
|
+
ws.on('message', (raw) => {
|
|
29
|
+
const f = JSON.parse(String(raw));
|
|
30
|
+
const w = waiters.shift();
|
|
31
|
+
if (w)
|
|
32
|
+
w(f);
|
|
33
|
+
else
|
|
34
|
+
frames.push(f);
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
next: () => frames.length > 0
|
|
38
|
+
? Promise.resolve(frames.shift())
|
|
39
|
+
: new Promise((resolve) => waiters.push(resolve)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const openAndAuth = async (port) => {
|
|
43
|
+
const ws = connect(port);
|
|
44
|
+
await new Promise((r) => ws.on('open', r));
|
|
45
|
+
ws.send(JSON.stringify({ v: 1, token: TOKEN }));
|
|
46
|
+
return { ws, q: frameQueue(ws) };
|
|
47
|
+
};
|
|
48
|
+
describe('bridge WebSocket server', () => {
|
|
49
|
+
let ptys;
|
|
50
|
+
let handle;
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
ptys = [];
|
|
53
|
+
const factory = { spawn: () => { const p = new FakePty(); ptys.push(p); return p; } };
|
|
54
|
+
handle = await (0, server_1.startBridgeServer)({
|
|
55
|
+
sessionToken: TOKEN,
|
|
56
|
+
allowedOrigin: ORIGIN,
|
|
57
|
+
sessions: new sessionManager_1.SessionManager(factory),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await handle.close();
|
|
62
|
+
});
|
|
63
|
+
it('binds to 127.0.0.1 only', () => {
|
|
64
|
+
expect(handle.address).toBe('127.0.0.1');
|
|
65
|
+
});
|
|
66
|
+
it('rejects a connection with the wrong Origin', async () => {
|
|
67
|
+
const ws = connect(handle.port, { origin: 'https://evil.example' });
|
|
68
|
+
const code = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
|
|
69
|
+
expect(code).toBe(4403);
|
|
70
|
+
});
|
|
71
|
+
it('rejects a wrong session token with an error frame and close', async () => {
|
|
72
|
+
const ws = connect(handle.port);
|
|
73
|
+
await new Promise((r) => ws.on('open', r));
|
|
74
|
+
const q = frameQueue(ws);
|
|
75
|
+
ws.send(JSON.stringify({ v: 1, token: 'wrong' }));
|
|
76
|
+
const frame = await q.next();
|
|
77
|
+
expect(frame.t).toBe('error');
|
|
78
|
+
const code = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
|
|
79
|
+
expect(code).toBe(4401);
|
|
80
|
+
});
|
|
81
|
+
it('open → opened; input reaches the PTY; PTY data streams back', async () => {
|
|
82
|
+
const { ws, q } = await openAndAuth(handle.port);
|
|
83
|
+
ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24 }));
|
|
84
|
+
const opened = await q.next();
|
|
85
|
+
expect(opened.t).toBe('opened');
|
|
86
|
+
expect(opened.sessionId).toBeTruthy();
|
|
87
|
+
ws.send(JSON.stringify({ t: 'input', sessionId: opened.sessionId, data: 'ls\r' }));
|
|
88
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
89
|
+
expect(ptys[0].written).toEqual(['ls\r']);
|
|
90
|
+
ptys[0].emitData('result');
|
|
91
|
+
const data = await q.next();
|
|
92
|
+
expect(data).toMatchObject({ t: 'data', sessionId: opened.sessionId, data: 'result' });
|
|
93
|
+
ws.close();
|
|
94
|
+
});
|
|
95
|
+
it('second attach takes over: first client gets a takeover frame, replay flows to the new one', async () => {
|
|
96
|
+
const a = await openAndAuth(handle.port);
|
|
97
|
+
a.ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24 }));
|
|
98
|
+
const opened = await a.q.next();
|
|
99
|
+
ptys[0].emitData('history');
|
|
100
|
+
await a.q.next(); // 'history' data frame to A
|
|
101
|
+
const b = await openAndAuth(handle.port);
|
|
102
|
+
b.ws.send(JSON.stringify({ t: 'attach', sessionId: opened.sessionId }));
|
|
103
|
+
const [takeover, replay] = await Promise.all([a.q.next(), b.q.next()]);
|
|
104
|
+
expect(takeover).toMatchObject({ t: 'takeover', sessionId: opened.sessionId });
|
|
105
|
+
expect(replay).toMatchObject({ t: 'replay', sessionId: opened.sessionId, data: 'history' });
|
|
106
|
+
// A's input is now inert; B's input reaches the PTY
|
|
107
|
+
a.ws.send(JSON.stringify({ t: 'input', sessionId: opened.sessionId, data: 'stale' }));
|
|
108
|
+
b.ws.send(JSON.stringify({ t: 'input', sessionId: opened.sessionId, data: 'live' }));
|
|
109
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
110
|
+
expect(ptys[0].written).toEqual(['live']);
|
|
111
|
+
a.ws.close();
|
|
112
|
+
b.ws.close();
|
|
113
|
+
});
|
|
114
|
+
it('serves CORS-pinned discovery to the arena origin only', async () => {
|
|
115
|
+
const ok = await fetch(`http://127.0.0.1:${handle.port}/discovery`, {
|
|
116
|
+
headers: { origin: ORIGIN },
|
|
117
|
+
});
|
|
118
|
+
expect(ok.status).toBe(200);
|
|
119
|
+
expect(ok.headers.get('access-control-allow-origin')).toBe(ORIGIN);
|
|
120
|
+
const body = await ok.json();
|
|
121
|
+
expect(body).toMatchObject({ v: 1, token: TOKEN });
|
|
122
|
+
const bad = await fetch(`http://127.0.0.1:${handle.port}/discovery`, {
|
|
123
|
+
headers: { origin: 'https://evil.example' },
|
|
124
|
+
});
|
|
125
|
+
expect(bad.status).toBe(403);
|
|
126
|
+
expect(bad.headers.get('access-control-allow-origin')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
it('unauthenticated frames are rejected', async () => {
|
|
129
|
+
const ws = connect(handle.port);
|
|
130
|
+
await new Promise((r) => ws.on('open', r));
|
|
131
|
+
const q = frameQueue(ws);
|
|
132
|
+
ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24 })); // no handshake first
|
|
133
|
+
const frame = await q.next();
|
|
134
|
+
expect(frame.t).toBe('error');
|
|
135
|
+
ws.close();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
4
|
+
// ── Fake PTY (no real shells in CI) ──────────────────────────────────
|
|
5
|
+
class FakePty {
|
|
6
|
+
written = [];
|
|
7
|
+
resizes = [];
|
|
8
|
+
killed = false;
|
|
9
|
+
dataCb = null;
|
|
10
|
+
write(data) { this.written.push(data); }
|
|
11
|
+
resize(cols, rows) { this.resizes.push({ cols, rows }); }
|
|
12
|
+
kill() { this.killed = true; }
|
|
13
|
+
onData(cb) { this.dataCb = cb; }
|
|
14
|
+
onExit(_cb) { }
|
|
15
|
+
emitData(d) { this.dataCb?.(d); }
|
|
16
|
+
}
|
|
17
|
+
const makeFactory = () => {
|
|
18
|
+
const ptys = [];
|
|
19
|
+
const factory = {
|
|
20
|
+
spawn: () => {
|
|
21
|
+
const p = new FakePty();
|
|
22
|
+
ptys.push(p);
|
|
23
|
+
return p;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
return { factory, ptys };
|
|
27
|
+
};
|
|
28
|
+
describe('SessionManager', () => {
|
|
29
|
+
it('opens a session and routes attached writes to the PTY, PTY data to the sink', () => {
|
|
30
|
+
const { factory, ptys } = makeFactory();
|
|
31
|
+
const sm = new sessionManager_1.SessionManager(factory);
|
|
32
|
+
const id = sm.open({ cols: 80, rows: 24 });
|
|
33
|
+
const frames = [];
|
|
34
|
+
const handle = sm.attach(id, (f) => frames.push(f));
|
|
35
|
+
handle.write('ls\r');
|
|
36
|
+
expect(ptys[0].written).toEqual(['ls\r']);
|
|
37
|
+
ptys[0].emitData('file-a file-b');
|
|
38
|
+
expect(frames).toContainEqual({ t: 'data', data: 'file-a file-b' });
|
|
39
|
+
});
|
|
40
|
+
it('replays bounded scrollback on attach after detach (session survives)', () => {
|
|
41
|
+
const { factory, ptys } = makeFactory();
|
|
42
|
+
const sm = new sessionManager_1.SessionManager(factory, { scrollbackLimit: 10 });
|
|
43
|
+
const id = sm.open({ cols: 80, rows: 24 });
|
|
44
|
+
const h1 = sm.attach(id, () => { });
|
|
45
|
+
h1.detach();
|
|
46
|
+
ptys[0].emitData('0123456789ABCDEF'); // 16 chars > limit 10
|
|
47
|
+
const h2 = sm.attach(id, () => { });
|
|
48
|
+
expect(h2.replay).toBe('6789ABCDEF'); // only the last 10 chars survive
|
|
49
|
+
expect(ptys[0].killed).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it('last-attach-wins: prior sink gets takeover and its handle stops writing', () => {
|
|
52
|
+
const { factory, ptys } = makeFactory();
|
|
53
|
+
const sm = new sessionManager_1.SessionManager(factory);
|
|
54
|
+
const id = sm.open({ cols: 80, rows: 24 });
|
|
55
|
+
const firstFrames = [];
|
|
56
|
+
const h1 = sm.attach(id, (f) => firstFrames.push(f));
|
|
57
|
+
const h2 = sm.attach(id, () => { });
|
|
58
|
+
expect(firstFrames).toContainEqual({ t: 'takeover' });
|
|
59
|
+
h1.write('evil\r'); // stale handle must be inert (ADR-0028 input invariant)
|
|
60
|
+
expect(ptys[0].written).toEqual([]);
|
|
61
|
+
h2.write('ok\r');
|
|
62
|
+
expect(ptys[0].written).toEqual(['ok\r']);
|
|
63
|
+
});
|
|
64
|
+
it('forwards resize from the live handle only', () => {
|
|
65
|
+
const { factory, ptys } = makeFactory();
|
|
66
|
+
const sm = new sessionManager_1.SessionManager(factory);
|
|
67
|
+
const id = sm.open({ cols: 80, rows: 24 });
|
|
68
|
+
const h1 = sm.attach(id, () => { });
|
|
69
|
+
const h2 = sm.attach(id, () => { });
|
|
70
|
+
h1.resize(100, 40); // stale
|
|
71
|
+
h2.resize(120, 50);
|
|
72
|
+
expect(ptys[0].resizes).toEqual([{ cols: 120, rows: 50 }]);
|
|
73
|
+
});
|
|
74
|
+
it('close kills the PTY and removes the session', () => {
|
|
75
|
+
const { factory, ptys } = makeFactory();
|
|
76
|
+
const sm = new sessionManager_1.SessionManager(factory);
|
|
77
|
+
const id = sm.open({ cols: 80, rows: 24 });
|
|
78
|
+
sm.close(id);
|
|
79
|
+
expect(ptys[0].killed).toBe(true);
|
|
80
|
+
expect(sm.list()).toEqual([]);
|
|
81
|
+
expect(() => sm.attach(id, () => { })).toThrow(/unknown session/i);
|
|
82
|
+
});
|
|
83
|
+
it('supports multiple concurrent sessions with independent routing', () => {
|
|
84
|
+
const { factory, ptys } = makeFactory();
|
|
85
|
+
const sm = new sessionManager_1.SessionManager(factory);
|
|
86
|
+
const a = sm.open({ cols: 80, rows: 24 });
|
|
87
|
+
const b = sm.open({ cols: 80, rows: 24 });
|
|
88
|
+
const ha = sm.attach(a, () => { });
|
|
89
|
+
const hb = sm.attach(b, () => { });
|
|
90
|
+
ha.write('to-a');
|
|
91
|
+
hb.write('to-b');
|
|
92
|
+
expect(ptys[0].written).toEqual(['to-a']);
|
|
93
|
+
expect(ptys[1].written).toEqual(['to-b']);
|
|
94
|
+
expect(sm.list().sort()).toEqual([a, b].sort());
|
|
95
|
+
});
|
|
96
|
+
it('exposes no PTY write path other than the live attachment handle', () => {
|
|
97
|
+
const sm = new sessionManager_1.SessionManager(makeFactory().factory);
|
|
98
|
+
// Public surface check: SessionManager itself has no input/write/send method —
|
|
99
|
+
// writing requires the handle returned by attach() (ADR-0028).
|
|
100
|
+
expect(sm.write).toBeUndefined();
|
|
101
|
+
expect(sm.input).toBeUndefined();
|
|
102
|
+
expect(sm.send).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -62,8 +62,8 @@ describe('saveConfig()', () => {
|
|
|
62
62
|
mockedFs.writeFileSync.mockReturnValue(undefined);
|
|
63
63
|
const config = { apiKey: 'key', apiUrl: 'http://test' };
|
|
64
64
|
(0, config_1.saveConfig)(config);
|
|
65
|
-
expect(mockedFs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
66
|
-
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('"apiKey": "key"'));
|
|
65
|
+
expect(mockedFs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true, mode: 0o700 });
|
|
66
|
+
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('"apiKey": "key"'), { mode: 0o600 });
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
describe('requireConfig()', () => {
|
|
@@ -94,6 +94,6 @@ describe('getApiUrl()', () => {
|
|
|
94
94
|
});
|
|
95
95
|
it('falls back to default when no config or env', () => {
|
|
96
96
|
mockedFs.readFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
|
|
97
|
-
expect((0, config_1.getApiUrl)()).toBe('http://localhost:
|
|
97
|
+
expect((0, config_1.getApiUrl)()).toBe('http://localhost:3002/api/v1');
|
|
98
98
|
});
|
|
99
99
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface BridgeConfig {
|
|
2
|
+
connectToken: string;
|
|
3
|
+
}
|
|
4
|
+
export interface DiscoveryInfo {
|
|
5
|
+
port: number;
|
|
6
|
+
sessionToken: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function saveBridgeConfig(config: BridgeConfig, dir?: string): void;
|
|
9
|
+
export declare function loadBridgeConfig(dir?: string): BridgeConfig | null;
|
|
10
|
+
export declare function writeDiscoveryFile(info: DiscoveryInfo, dir?: string): void;
|
|
11
|
+
export declare function bridgeLogPath(dir?: string): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
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.saveBridgeConfig = saveBridgeConfig;
|
|
37
|
+
exports.loadBridgeConfig = loadBridgeConfig;
|
|
38
|
+
exports.writeDiscoveryFile = writeDiscoveryFile;
|
|
39
|
+
exports.bridgeLogPath = bridgeLogPath;
|
|
40
|
+
/**
|
|
41
|
+
* bct-05 — bridge config + discovery.
|
|
42
|
+
*
|
|
43
|
+
* Lives under the CLI's existing config home (~/.wen-market/) rather than a
|
|
44
|
+
* second dotdir: one binary, one home. Two files:
|
|
45
|
+
* - bridge-config.json (0600): the Connect Token. Never logged.
|
|
46
|
+
* - bridge.json (0600): per-run discovery (port + session token) for
|
|
47
|
+
* local tooling/debugging. The BROWSER cannot read files — the pane
|
|
48
|
+
* discovers the bridge via the CORS-pinned HTTP discovery endpoint on
|
|
49
|
+
* the known default port instead (see server.ts).
|
|
50
|
+
*/
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const os = __importStar(require("os"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.wen-market');
|
|
55
|
+
const CONFIG_NAME = 'bridge-config.json';
|
|
56
|
+
const DISCOVERY_NAME = 'bridge.json';
|
|
57
|
+
function ensureDir(dir) {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
}
|
|
60
|
+
function saveBridgeConfig(config, dir = DEFAULT_DIR) {
|
|
61
|
+
ensureDir(dir);
|
|
62
|
+
fs.writeFileSync(path.join(dir, CONFIG_NAME), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
function loadBridgeConfig(dir = DEFAULT_DIR) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(path.join(dir, CONFIG_NAME), 'utf-8'));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function writeDiscoveryFile(info, dir = DEFAULT_DIR) {
|
|
73
|
+
ensureDir(dir);
|
|
74
|
+
fs.writeFileSync(path.join(dir, DISCOVERY_NAME), JSON.stringify({ v: 1, ...info }, null, 2) + '\n', { mode: 0o600 });
|
|
75
|
+
}
|
|
76
|
+
function bridgeLogPath(dir = DEFAULT_DIR) {
|
|
77
|
+
ensureDir(dir);
|
|
78
|
+
return path.join(dir, 'bridge.log');
|
|
79
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bct-05 — idempotent MCP registration into the user's Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Writes to someone else's machine config, so it is loud and idempotent:
|
|
5
|
+
* checks `claude mcp list` first, prints exactly what it ran or skipped,
|
|
6
|
+
* and a missing `claude` binary is a warning, never a crash (the bridge
|
|
7
|
+
* still serves terminals without MCP).
|
|
8
|
+
*/
|
|
9
|
+
export type ExecRunner = (cmd: string, args: string[]) => Promise<{
|
|
10
|
+
stdout: string;
|
|
11
|
+
code: number;
|
|
12
|
+
}>;
|
|
13
|
+
export interface McpRegisterOpts {
|
|
14
|
+
connectToken: string;
|
|
15
|
+
apiUrl: string;
|
|
16
|
+
run: ExecRunner;
|
|
17
|
+
/** Set when a fresh token was just provided: an existing registration
|
|
18
|
+
* carries the OLD token env and must be replaced, not skipped. */
|
|
19
|
+
forceUpdate?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface McpRegisterResult {
|
|
22
|
+
action: 'registered' | 're-registered' | 'already-registered' | 'claude-not-found';
|
|
23
|
+
command?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const MCP_SERVER_NAME = "wenrwa-marketplace";
|
|
26
|
+
/** Connect Tokens must never appear in output — redact before printing. */
|
|
27
|
+
export declare function redactToken(text: string): string;
|
|
28
|
+
export declare function registerMcpServer(opts: McpRegisterOpts): Promise<McpRegisterResult>;
|