troxy-cli 1.9.0 → 1.11.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/bin/troxy.js +16 -2
- package/package.json +1 -1
- package/src/api.js +6 -0
- package/src/init.js +106 -6
- package/src/mcp-server.js +76 -0
- package/src/tests/agents-file.test.js +133 -0
package/bin/troxy.js
CHANGED
|
@@ -457,8 +457,21 @@ switch (command) {
|
|
|
457
457
|
process.exit(1);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
// Refresh the hosted-agent instructions regardless of whether the package
|
|
461
|
+
// moved. For a cloud agent the package is beside the point: what goes stale
|
|
462
|
+
// is its picture of which checkpoints exist, and that lives on the server.
|
|
463
|
+
// This is a no-op on a machine with an MCP client, which gets a fresh tool
|
|
464
|
+
// list from the MCP handshake every session anyway.
|
|
465
|
+
const { refreshHostedInstructions } = await import('../src/init.js');
|
|
466
|
+
const refresh = async () => {
|
|
467
|
+
try {
|
|
468
|
+
return await refreshHostedInstructions((loadConfig() || {}).apiKey);
|
|
469
|
+
} catch { return false; }
|
|
470
|
+
};
|
|
471
|
+
|
|
460
472
|
if (current === latest) {
|
|
461
|
-
console.log(`already up to date (${current})
|
|
473
|
+
console.log(`already up to date (${current})`);
|
|
474
|
+
if (!await refresh()) console.log('');
|
|
462
475
|
break;
|
|
463
476
|
}
|
|
464
477
|
|
|
@@ -480,7 +493,8 @@ switch (command) {
|
|
|
480
493
|
}
|
|
481
494
|
// Also refresh the npx cache so MCP servers (e.g. OpenClaw) pick up the new version
|
|
482
495
|
try { execSync(`npx --yes troxy-cli@${latest} --version`, { stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
483
|
-
console.log(`✓\n\n Updated to ${latest}. Restart your terminal and any MCP clients to use the new version
|
|
496
|
+
console.log(`✓\n\n Updated to ${latest}. Restart your terminal and any MCP clients to use the new version.`);
|
|
497
|
+
if (!await refresh()) console.log('');
|
|
484
498
|
} catch (err) {
|
|
485
499
|
const stderr = err.stderr?.toString() || err.message || '';
|
|
486
500
|
if (stderr.includes('EACCES') || stderr.includes('permission')) {
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -73,6 +73,7 @@ export const api = {
|
|
|
73
73
|
evaluate: (body, apiKey) => request('POST', '/evaluate', { apiKey, body }),
|
|
74
74
|
evaluateEmail: (body, apiKey) => request('POST', '/evaluate/email', { apiKey, body }),
|
|
75
75
|
evaluateLogin: (body, apiKey) => request('POST', '/evaluate/login', { apiKey, body }),
|
|
76
|
+
evaluateAction: (body, apiKey) => request('POST', '/evaluate/action', { apiKey, body }),
|
|
76
77
|
selfRevoke: (apiKey) => request('POST', '/mcp/self-revoke', { apiKey }),
|
|
77
78
|
confirmPayment: (auditId, body, apiKey) => request('POST', `/payments/${auditId}/confirm`, { apiKey, body }),
|
|
78
79
|
waitApprovalStatus: (token) => request('GET', `/approvals/${encodeURIComponent(token)}/wait`),
|
|
@@ -83,6 +84,11 @@ export const api = {
|
|
|
83
84
|
// MCP status (agent API key — no login needed)
|
|
84
85
|
mcpStatus: (apiKey) => request('GET', '/mcp/status', { apiKey }),
|
|
85
86
|
|
|
87
|
+
// Setup instructions for an agent with no MCP client to configure. Fetched
|
|
88
|
+
// rather than hardcoded so a new checkpoint reaches agents without an npm
|
|
89
|
+
// release — a cloud agent runs init once and never upgrades the package.
|
|
90
|
+
agentInstructions: (apiKey) => request('GET', '/agent/instructions', { apiKey }),
|
|
91
|
+
|
|
86
92
|
// MCP pause / resume / rename via JWT (requires troxy login)
|
|
87
93
|
pauseToken: (jwt, id) => request('POST', `/tokens/${id}/pause`, { jwt }),
|
|
88
94
|
resumeToken: (jwt, id) => request('POST', `/tokens/${id}/resume`, { jwt }),
|
package/src/init.js
CHANGED
|
@@ -12,7 +12,10 @@ import { evaluatePayment, api } from './api.js';
|
|
|
12
12
|
// one immediately, so every real consumer — which holds the key via ENV, not config.json —
|
|
13
13
|
// kept presenting the now-revoked key until the user manually re-ran init. Non-interactive
|
|
14
14
|
// (no prompts), so it's safe to call from rotate-key with the existing agent name.
|
|
15
|
-
|
|
15
|
+
// True when this machine has something that can host an MCP server. When it
|
|
16
|
+
// does not, we are almost certainly inside a hosted agent's own sandbox, and
|
|
17
|
+
// the setup it needs is instructions rather than config files.
|
|
18
|
+
export function detectMcpClients() {
|
|
16
19
|
const platform = process.platform;
|
|
17
20
|
const detected = MCP_CLIENTS.filter(c => {
|
|
18
21
|
const p = c.path[platform] ?? c.path.linux;
|
|
@@ -25,11 +28,36 @@ export async function reprovisionKeyConsumers(key, agentName) {
|
|
|
25
28
|
hasOpenClaw = true;
|
|
26
29
|
} catch {}
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
return { detected, hasOpenClaw, none: detected.length === 0 && !hasOpenClaw };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `troxy update` on a hosted agent. The npm package is beside the point there:
|
|
35
|
+
// what goes stale is the agent's picture of which checkpoints exist, and that
|
|
36
|
+
// lives on the server. So this re-fetches and reprints even when the package
|
|
37
|
+
// itself is already current, which is the common case for an agent that runs
|
|
38
|
+
// npx and never installs anything.
|
|
39
|
+
export async function refreshHostedInstructions(key) {
|
|
40
|
+
if (!key || !detectMcpClients().none) return false;
|
|
41
|
+
await printHostedAgentSetup(key);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function reprovisionKeyConsumers(key, agentName) {
|
|
46
|
+
const platform = process.platform;
|
|
47
|
+
const { detected, hasOpenClaw, none } = detectMcpClients();
|
|
48
|
+
|
|
49
|
+
if (none) {
|
|
50
|
+
// No MCP client on this machine almost always means a hosted agent running
|
|
51
|
+
// init inside its own sandbox. This branch used to print the mcpServers
|
|
52
|
+
// JSON and say "add this to your MCP client's config" — advice such an
|
|
53
|
+
// agent cannot act on: it has no config file, and it cannot reconfigure the
|
|
54
|
+
// runtime it lives inside. A real Base44 agent hit exactly this, ended up
|
|
55
|
+
// registered and named in the dashboard but with zero tools, and fell back
|
|
56
|
+
// to hand-written curl against whichever endpoints it found in the docs.
|
|
57
|
+
//
|
|
58
|
+
// What it CAN do is read this output, because it just ran the command. So
|
|
59
|
+
// print the instructions instead. One command, no copy-paste, no docs.
|
|
60
|
+
await printHostedAgentSetup(key);
|
|
33
61
|
} else {
|
|
34
62
|
console.log('\n MCP clients found:');
|
|
35
63
|
for (const client of detected) {
|
|
@@ -67,6 +95,78 @@ export async function reprovisionKeyConsumers(key, agentName) {
|
|
|
67
95
|
}
|
|
68
96
|
}
|
|
69
97
|
|
|
98
|
+
const AGENTS_START = '<!-- troxy:start -->';
|
|
99
|
+
const AGENTS_END = '<!-- troxy:end -->';
|
|
100
|
+
|
|
101
|
+
// The endpoint deliberately does not echo the caller's key back, so the live
|
|
102
|
+
// key is substituted here, on the machine that already has it. Split/join
|
|
103
|
+
// rather than a regex because a key is user-controlled input and would other-
|
|
104
|
+
// wise need escaping to avoid being read as a pattern.
|
|
105
|
+
export function buildHostedInstructions(data, key) {
|
|
106
|
+
const text = String((data && data.instructions) || '');
|
|
107
|
+
if (!text.trim()) throw new Error('empty instructions');
|
|
108
|
+
const placeholder = (data && data.key_placeholder) || 'YOUR_TROXY_KEY';
|
|
109
|
+
return text.split(placeholder).join(key);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Print the setup an agent with no MCP client needs, and persist it where
|
|
113
|
+
// agent runtimes look. Two delivery paths on purpose, because neither is
|
|
114
|
+
// reliable alone: stdout reaches the agent right now but dies with the session,
|
|
115
|
+
// AGENTS.md survives but only matters on platforms that read it.
|
|
116
|
+
async function printHostedAgentSetup(key) {
|
|
117
|
+
console.log('\n No MCP client on this machine, so this looks like a hosted agent.');
|
|
118
|
+
console.log(' Nothing to install. The instructions below are the whole setup.\n');
|
|
119
|
+
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
text = buildHostedInstructions(await api.agentInstructions(key), key);
|
|
123
|
+
} catch {
|
|
124
|
+
console.log(' Could not reach the Troxy API to fetch them.');
|
|
125
|
+
console.log(' Get the block from dash.troxy.io → API Keys → Cloud agents,');
|
|
126
|
+
console.log(' or re-run this command once you are back online.\n');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rule = ' ' + '─'.repeat(66);
|
|
131
|
+
console.log(rule);
|
|
132
|
+
console.log(text.split('\n').map(l => ' ' + l).join('\n'));
|
|
133
|
+
console.log(rule);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const written = writeAgentsFile(process.cwd(), text);
|
|
137
|
+
console.log(`\n Saved to ${written}`);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.log(`\n Could not write AGENTS.md (${err.message})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('\n If you are an AI agent reading this: save the block above to your');
|
|
143
|
+
console.log(' own persistent instructions or memory, so it survives a new session.');
|
|
144
|
+
console.log(' Then follow it before you pay, email, log in, or delete anything.\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Idempotent, and never clobbers instructions the user wrote themselves: the
|
|
148
|
+
// Troxy section lives between markers, so re-running init (or rotate-key, which
|
|
149
|
+
// changes the key inside the block) replaces only that span.
|
|
150
|
+
export function writeAgentsFile(dir, text) {
|
|
151
|
+
const file = path.join(dir, 'AGENTS.md');
|
|
152
|
+
const block = `${AGENTS_START}\n${text}\n${AGENTS_END}`;
|
|
153
|
+
let existing = '';
|
|
154
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch {}
|
|
155
|
+
|
|
156
|
+
let next;
|
|
157
|
+
if (existing.includes(AGENTS_START) && existing.includes(AGENTS_END)) {
|
|
158
|
+
const head = existing.slice(0, existing.indexOf(AGENTS_START));
|
|
159
|
+
const tail = existing.slice(existing.indexOf(AGENTS_END) + AGENTS_END.length);
|
|
160
|
+
next = head + block + tail;
|
|
161
|
+
} else if (existing.trim()) {
|
|
162
|
+
next = existing.replace(/\s*$/, '') + '\n\n' + block + '\n';
|
|
163
|
+
} else {
|
|
164
|
+
next = block + '\n';
|
|
165
|
+
}
|
|
166
|
+
fs.writeFileSync(file, next);
|
|
167
|
+
return file;
|
|
168
|
+
}
|
|
169
|
+
|
|
70
170
|
function prompt(question) {
|
|
71
171
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
72
172
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
package/src/mcp-server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { evaluatePayment, api } from './api.js';
|
|
|
9
9
|
const confirmPayment = (auditId, body, apiKey) => api.confirmPayment(auditId, body, apiKey);
|
|
10
10
|
const evaluateEmail = (body, apiKey) => api.evaluateEmail(body, apiKey);
|
|
11
11
|
const evaluateLogin = (body, apiKey) => api.evaluateLogin(body, apiKey);
|
|
12
|
+
const evaluateAction = (body, apiKey) => api.evaluateAction(body, apiKey);
|
|
12
13
|
|
|
13
14
|
export async function runMcp() {
|
|
14
15
|
const config = loadConfig();
|
|
@@ -42,6 +43,7 @@ export async function runMcp() {
|
|
|
42
43
|
'When calling evaluate_payment, use the merchant\'s domain name as merchant_name when available (e.g. "amazon.com", "indigobloom.co.il"); this ensures consistent matching against policies. ' +
|
|
43
44
|
'After every ALLOW or NOTIFY decision, attempt the payment, then call confirm_payment with the audit_id and the outcome (success/failed/cancelled). Always include a reason when the charge fails. ' +
|
|
44
45
|
'You MUST also call the evaluate_email tool before sending any email on the user\'s behalf. Always include the full plaintext "body" argument, exactly as you intend to send it — Troxy policies inspect the body for secrets and confidential content, and cannot do so if it is omitted, summarized, or truncated. When the email goes to more than one person (a reply-all, a group, a bulk send), also pass every To/Cc/Bcc address in "recipients" so mass-email policies can apply. Never send an email without first receiving an ALLOW or NOTIFY decision. If BLOCK, do not send it. If ESCALATE, call wait_for_payment_approval with the returned approval_token (same wait mechanism payments use), then call evaluate_email again with the approval_token once approved. ' +
|
|
46
|
+
'You MUST also call the evaluate_action tool before any destructive or hard-to-undo action on the user\'s behalf: deleting files or records, dropping or truncating a table, force-pushing or deleting a branch, destroying infrastructure, revoking access, or overwriting data. Give the verb, what it affects, how many items, and whether it can be undone. Never carry the action out without first receiving an ALLOW or NOTIFY decision. ' +
|
|
45
47
|
'You MUST also call the evaluate_login tool before logging into or signing up for any website on the user\'s behalf, giving the site\'s domain and whether it is a login or a signup. Never submit login credentials or a signup form without first receiving an ALLOW or NOTIFY decision. If BLOCK, do not proceed. If ESCALATE, call wait_for_payment_approval with the returned approval_token, then call evaluate_login again with the approval_token once approved.',
|
|
46
48
|
},
|
|
47
49
|
{ capabilities: { tools: {} } },
|
|
@@ -181,6 +183,49 @@ export async function runMcp() {
|
|
|
181
183
|
},
|
|
182
184
|
},
|
|
183
185
|
},
|
|
186
|
+
{
|
|
187
|
+
name: 'evaluate_action',
|
|
188
|
+
description:
|
|
189
|
+
'Evaluate whether a destructive or hard-to-undo action should be allowed, blocked, or ' +
|
|
190
|
+
'escalated based on your Troxy policies. Call this BEFORE deleting, overwriting, dropping, ' +
|
|
191
|
+
'truncating, force-pushing, destroying, or revoking anything on the user\'s behalf.',
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
required: ['action_verb'],
|
|
195
|
+
properties: {
|
|
196
|
+
action_verb: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
description:
|
|
199
|
+
'What you are about to do, one lowercase word: delete, overwrite, drop, truncate, ' +
|
|
200
|
+
'force_push, destroy, revoke, deploy, share, upload, or export.',
|
|
201
|
+
},
|
|
202
|
+
resource: {
|
|
203
|
+
type: 'string',
|
|
204
|
+
description:
|
|
205
|
+
'What it affects — a path, table, bucket, repo or branch, e.g. "/var/data", ' +
|
|
206
|
+
'"users", "refs/heads/main". Policies can narrow a rule to a specific target.',
|
|
207
|
+
},
|
|
208
|
+
item_count: {
|
|
209
|
+
type: 'number',
|
|
210
|
+
description:
|
|
211
|
+
'How many things the action affects (files, rows, records). Send the real number ' +
|
|
212
|
+
'whenever you know it — bulk-action policies depend on it. Defaults to 1.',
|
|
213
|
+
},
|
|
214
|
+
reversible: {
|
|
215
|
+
type: 'boolean',
|
|
216
|
+
description:
|
|
217
|
+
'True only if this can be straightforwardly undone (e.g. a soft delete, a revert). ' +
|
|
218
|
+
'If you are unsure, omit it or send false: an undeclared action is treated as ' +
|
|
219
|
+
'irreversible so it is not waved through by mistake.',
|
|
220
|
+
},
|
|
221
|
+
agent: { type: 'string', description: 'Name of the agent (optional)' },
|
|
222
|
+
approval_token: {
|
|
223
|
+
type: 'string',
|
|
224
|
+
description: 'Approval token from a previous ESCALATE response. Include this to proceed after the user has approved.',
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
184
229
|
{
|
|
185
230
|
name: 'evaluate_login',
|
|
186
231
|
description:
|
|
@@ -319,6 +364,37 @@ export async function runMcp() {
|
|
|
319
364
|
};
|
|
320
365
|
}
|
|
321
366
|
|
|
367
|
+
if (toolName === 'evaluate_action') {
|
|
368
|
+
if (agentName && !args.agent) args.agent = agentName;
|
|
369
|
+
let result;
|
|
370
|
+
try {
|
|
371
|
+
result = await evaluateAction(args, apiKey);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { content: [{ type: 'text', text: `Troxy error: ${err.message}` }], isError: true };
|
|
374
|
+
}
|
|
375
|
+
if (result.error) {
|
|
376
|
+
return { content: [{ type: 'text', text: `Troxy error: ${result.error}` }], isError: true };
|
|
377
|
+
}
|
|
378
|
+
const { decision, reason, audit_id, approval_token } = result;
|
|
379
|
+
const what = `${args.action_verb}${args.resource ? ` on ${args.resource}` : ''}`;
|
|
380
|
+
let actionText;
|
|
381
|
+
switch (decision) {
|
|
382
|
+
case 'ALLOW':
|
|
383
|
+
case 'NOTIFY':
|
|
384
|
+
actionText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may proceed. (audit: ${audit_id})`;
|
|
385
|
+
break;
|
|
386
|
+
case 'BLOCK':
|
|
387
|
+
actionText = `✗ Blocked: ${what}.${reason ? ` ${reason}` : ''} Do not proceed. (audit: ${audit_id})`;
|
|
388
|
+
break;
|
|
389
|
+
case 'ESCALATE':
|
|
390
|
+
actionText = `⏳ ${what} requires human approval; a request has been sent to the account owner.\n\nApproval token: ${approval_token}\n\nNow call wait_for_payment_approval(approval_token="${approval_token}") to automatically detect approval, then call evaluate_action again with the same arguments PLUS this approval_token. Do not proceed until it returns approved.`;
|
|
391
|
+
break;
|
|
392
|
+
default:
|
|
393
|
+
actionText = JSON.stringify(result);
|
|
394
|
+
}
|
|
395
|
+
return { content: [{ type: 'text', text: actionText }], isError: decision === 'BLOCK' };
|
|
396
|
+
}
|
|
397
|
+
|
|
322
398
|
if (toolName === 'evaluate_login') {
|
|
323
399
|
if (agentName && !args.agent) args.agent = agentName;
|
|
324
400
|
let result;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { writeAgentsFile, buildHostedInstructions, refreshHostedInstructions, detectMcpClients } from '../init.js';
|
|
8
|
+
|
|
9
|
+
// `troxy init` on a hosted agent writes the Troxy setup into AGENTS.md, which
|
|
10
|
+
// on most agent platforms is a file the user also writes their own rules into.
|
|
11
|
+
// So the write has to be surgical: re-running init (or rotate-key, which
|
|
12
|
+
// changes the key inside the block) must replace only Troxy's section and leave
|
|
13
|
+
// everything else byte-for-byte intact.
|
|
14
|
+
|
|
15
|
+
let dir;
|
|
16
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-agents-')); });
|
|
17
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
18
|
+
|
|
19
|
+
const read = () => fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf8');
|
|
20
|
+
|
|
21
|
+
describe('writeAgentsFile', () => {
|
|
22
|
+
it('creates the file when there is none', () => {
|
|
23
|
+
writeAgentsFile(dir, '## Troxy\nkey ONE');
|
|
24
|
+
assert.match(read(), /## Troxy\nkey ONE/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('wraps the block in markers so it can be found again', () => {
|
|
28
|
+
writeAgentsFile(dir, 'BLOCK');
|
|
29
|
+
const out = read();
|
|
30
|
+
assert.ok(out.includes('<!-- troxy:start -->'));
|
|
31
|
+
assert.ok(out.includes('<!-- troxy:end -->'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('appends to a file the user already wrote, without touching their content', () => {
|
|
35
|
+
const theirs = '# My agent rules\n\nAlways answer in English.\n';
|
|
36
|
+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), theirs);
|
|
37
|
+
writeAgentsFile(dir, 'BLOCK');
|
|
38
|
+
const out = read();
|
|
39
|
+
assert.ok(out.startsWith('# My agent rules'));
|
|
40
|
+
assert.ok(out.includes('Always answer in English.'));
|
|
41
|
+
assert.ok(out.includes('BLOCK'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('replaces its own block on a re-run instead of stacking duplicates', () => {
|
|
45
|
+
writeAgentsFile(dir, 'key ONE');
|
|
46
|
+
writeAgentsFile(dir, 'key TWO');
|
|
47
|
+
const out = read();
|
|
48
|
+
assert.equal(out.split('<!-- troxy:start -->').length - 1, 1);
|
|
49
|
+
assert.ok(out.includes('key TWO'));
|
|
50
|
+
assert.ok(!out.includes('key ONE'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps the user content around the block when replacing', () => {
|
|
54
|
+
// The rotate-key case: a stale key must be swapped out without collateral.
|
|
55
|
+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), 'BEFORE\n');
|
|
56
|
+
writeAgentsFile(dir, 'key ONE');
|
|
57
|
+
fs.appendFileSync(path.join(dir, 'AGENTS.md'), '\nAFTER\n');
|
|
58
|
+
writeAgentsFile(dir, 'key TWO');
|
|
59
|
+
const out = read();
|
|
60
|
+
assert.ok(out.includes('BEFORE'));
|
|
61
|
+
assert.ok(out.includes('AFTER'));
|
|
62
|
+
assert.ok(out.includes('key TWO'));
|
|
63
|
+
assert.ok(!out.includes('key ONE'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not add a second block to a file that only has whitespace', () => {
|
|
67
|
+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), ' \n\n');
|
|
68
|
+
writeAgentsFile(dir, 'BLOCK');
|
|
69
|
+
assert.equal(read().split('<!-- troxy:start -->').length - 1, 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns the path it wrote, so init can report it', () => {
|
|
73
|
+
const written = writeAgentsFile(dir, 'BLOCK');
|
|
74
|
+
assert.equal(written, path.join(dir, 'AGENTS.md'));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('buildHostedInstructions', () => {
|
|
79
|
+
const KEY = 'txy-live-key-123';
|
|
80
|
+
|
|
81
|
+
it('substitutes the live key for the placeholder', () => {
|
|
82
|
+
const out = buildHostedInstructions(
|
|
83
|
+
{ instructions: 'Authorization: Bearer YOUR_TROXY_KEY', key_placeholder: 'YOUR_TROXY_KEY' }, KEY);
|
|
84
|
+
assert.equal(out, `Authorization: Bearer ${KEY}`);
|
|
85
|
+
assert.ok(!out.includes('YOUR_TROXY_KEY'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('substitutes every occurrence, not just the first', () => {
|
|
89
|
+
const out = buildHostedInstructions(
|
|
90
|
+
{ instructions: 'X YOUR_TROXY_KEY Y YOUR_TROXY_KEY', key_placeholder: 'YOUR_TROXY_KEY' }, KEY);
|
|
91
|
+
assert.equal(out.split(KEY).length - 1, 2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('falls back to the known placeholder if the server omits it', () => {
|
|
95
|
+
const out = buildHostedInstructions({ instructions: 'Bearer YOUR_TROXY_KEY' }, KEY);
|
|
96
|
+
assert.ok(out.includes(KEY));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('treats a key containing regex characters literally', () => {
|
|
100
|
+
// Keys are opaque server-generated strings, but a replace built on a regex
|
|
101
|
+
// would corrupt the output the day that stops being true.
|
|
102
|
+
const weird = 'txy-a$&b.*c';
|
|
103
|
+
const out = buildHostedInstructions(
|
|
104
|
+
{ instructions: 'Bearer YOUR_TROXY_KEY', key_placeholder: 'YOUR_TROXY_KEY' }, weird);
|
|
105
|
+
assert.equal(out, `Bearer ${weird}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects an empty payload rather than printing a blank setup', () => {
|
|
109
|
+
// Better to fall through to the "could not reach the API" path than to hand
|
|
110
|
+
// an agent an empty block it will treat as complete.
|
|
111
|
+
assert.throws(() => buildHostedInstructions({ instructions: ' ' }, KEY));
|
|
112
|
+
assert.throws(() => buildHostedInstructions({}, KEY));
|
|
113
|
+
assert.throws(() => buildHostedInstructions(null, KEY));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('refreshHostedInstructions', () => {
|
|
118
|
+
it('does nothing without a saved key', async () => {
|
|
119
|
+
// `troxy update` runs on machines that never ran init. It must not reach
|
|
120
|
+
// for the network, or print a setup block, with no key to authenticate.
|
|
121
|
+
assert.equal(await refreshHostedInstructions(undefined), false);
|
|
122
|
+
assert.equal(await refreshHostedInstructions(''), false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('reports whether this machine can host an MCP server', () => {
|
|
126
|
+
// The signal that decides between "patch config files" and "print
|
|
127
|
+
// instructions". Shape only — the answer is environment-dependent.
|
|
128
|
+
const r = detectMcpClients();
|
|
129
|
+
assert.ok(Array.isArray(r.detected));
|
|
130
|
+
assert.equal(typeof r.hasOpenClaw, 'boolean');
|
|
131
|
+
assert.equal(r.none, r.detected.length === 0 && !r.hasOpenClaw);
|
|
132
|
+
});
|
|
133
|
+
});
|