@sphyr/cli 2.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +167 -0
- package/index.js +89 -0
- package/package.json +31 -0
- package/src/commands/guard.js +145 -0
- package/src/commands/init.js +484 -0
- package/src/commands/login.js +91 -0
- package/src/commands/verify.js +34 -0
- package/src/lib/ide-clients.js +411 -0
- package/src/lib/url-guard.js +59 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Sphyr
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* init.js — sphyr CLI init using RFC 8628 Device Authorization Grant.
|
|
6
|
+
*
|
|
7
|
+
* Flow (D-02 order — read BEFORE auth):
|
|
8
|
+
* 1. Detect installed IDE clients, multiselect
|
|
9
|
+
* 2. Read each selected IDE's mcpServers config (selectAndPrepareIdes)
|
|
10
|
+
* 3. Preview what will be wrapped; D-02 early-exit if nothing to wrap
|
|
11
|
+
* 4. POST /v1/device_auth to obtain device_code, user_code, verification_uri
|
|
12
|
+
* 5. Display user_code and verification_uri; best-effort open browser (TTY only)
|
|
13
|
+
* 6. Poll /v1/device_auth/token until approved, expired, or denied
|
|
14
|
+
* 7. Write a single sphyr entry per IDE via replaceWithGuardEntry (configureIdeClients)
|
|
15
|
+
*
|
|
16
|
+
* Security: Credentials are delivered in a JSON POST response body — never in
|
|
17
|
+
* URL query parameters. RFC 8252 §8.2 prohibits credentials in URI query
|
|
18
|
+
* components (browser history exposure). RFC 8628 device flow is the only
|
|
19
|
+
* supported authentication method.
|
|
20
|
+
*
|
|
21
|
+
* The loopback OAuth flow has been removed. It delivered api_key and
|
|
22
|
+
* hmac_secret as URL query parameters, violating RFC 8252 §8.2. Device flow
|
|
23
|
+
* works in all environments including headless/CI/SSH.
|
|
24
|
+
*
|
|
25
|
+
* Threat mitigations (T-128-05 through T-128-09):
|
|
26
|
+
* T-128-05: D-09 parseDownstreamConfig rejects path-traversal/null bytes (skip-and-warn)
|
|
27
|
+
* T-128-06: JSON.stringify(validated) is the only SPHYR_DOWNSTREAM path (no concatenation)
|
|
28
|
+
* T-128-07: isSphyrEntry in selectAndPrepareIdes filters circular-wrap candidates
|
|
29
|
+
* T-128-08: selectAndPrepareIdes has NO file-write side effects; writes gated by auth
|
|
30
|
+
* T-128-09: readMcpServersForIde D-01 merge; wrappedKeys algorithm prevents delete-of-nonexistent
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as p from '@clack/prompts';
|
|
34
|
+
import open from 'open';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import { IDE_CLIENTS, detectInstalledClients, isSphyrEntry, readMcpServersForIde, replaceWithGuardEntry, buildServerEntry } from '../lib/ide-clients.js';
|
|
37
|
+
import { parseDownstreamConfig } from '@sphyr/sdk/guard-config';
|
|
38
|
+
import { requireTrustedEndpoint } from '../lib/url-guard.js';
|
|
39
|
+
|
|
40
|
+
const DEFAULT_API_BASE = 'https://api.sphyr.io';
|
|
41
|
+
// W-10: the device flow sends/receives the credential against this base —
|
|
42
|
+
// validate at module load so a poisoned env var fails loudly before any
|
|
43
|
+
// network or prompt activity.
|
|
44
|
+
const API_BASE = requireTrustedEndpoint(process.env.SPHYR_API_URL || DEFAULT_API_BASE, 'SPHYR_API_URL', DEFAULT_API_BASE);
|
|
45
|
+
|
|
46
|
+
const BANNER = `
|
|
47
|
+
███████╗██████╗ ██╗ ██╗██╗ ██╗██████╗
|
|
48
|
+
██╔════╝██╔══██╗██║ ██║╚██╗ ██╔╝██╔══██╗
|
|
49
|
+
███████╗██████╔╝███████║ ╚████╔╝ ██████╔╝
|
|
50
|
+
╚════██║██╔═══╝ ██╔══██║ ╚██╔╝ ██╔══██╗
|
|
51
|
+
███████║██║ ██║ ██║ ██║ ██║ ██║
|
|
52
|
+
╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
|
|
53
|
+
Agent Guard — signs every MCP request automatically
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
function sleep(ms) {
|
|
57
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* RFC 8628 §3.5 polling endpoint client (with RFC 6585 §4 transport-layer rate-limit handling).
|
|
62
|
+
* polling at base interval, +5s on slow_down. HTTP 429 sleeps for Retry-After seconds without
|
|
63
|
+
* changing the protocol-layer interval state (per-IP rate limits are transport-layer; the
|
|
64
|
+
* per-device_code interval is protocol-layer; they are semantically distinct).
|
|
65
|
+
*
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {string} opts.tokenUrl - Full URL for the token polling endpoint
|
|
68
|
+
* @param {string} opts.deviceCode - Device code received from the device authorization endpoint
|
|
69
|
+
* @param {number} [opts.interval=5] - Initial polling interval in seconds (RFC 8628 §3.5)
|
|
70
|
+
* @param {number} [opts.expiresIn=900] - Device code expiry in seconds (used as deadline)
|
|
71
|
+
* @returns {Promise<{credential: string}>}
|
|
72
|
+
*
|
|
73
|
+
* Implementation note: The returned promise has a pre-attached no-op .catch() to prevent
|
|
74
|
+
* Node.js unhandled-rejection warnings in tests where the rejection is handled after a
|
|
75
|
+
* vi.advanceTimersByTimeAsync() call. The caller's own
|
|
76
|
+
* .catch()/.rejects handler still receives the rejection normally.
|
|
77
|
+
*/
|
|
78
|
+
export function pollForToken({ tokenUrl, deviceCode, interval = 5, expiresIn = 900 }) {
|
|
79
|
+
const promise = (async () => {
|
|
80
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
81
|
+
let currentInterval = interval;
|
|
82
|
+
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
const res = await fetch(tokenUrl, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// RFC 6585 §4 transport-layer rate limit: per-IP throttling from the server.
|
|
91
|
+
// Distinct from RFC 8628 slow_down (which is per-device_code protocol pacing).
|
|
92
|
+
// The CLI sleeps for the server-specified Retry-After duration and retries
|
|
93
|
+
// WITHOUT altering its per-device_code interval state. NAT/VPN-collateral clients
|
|
94
|
+
// get a clear transport-level retry signal rather than a protocol-pacing signal
|
|
95
|
+
// that wouldn't help (the IP-level limit doesn't ease as the CLI's per-device_code
|
|
96
|
+
// interval grows).
|
|
97
|
+
if (res.status === 429) {
|
|
98
|
+
const retryAfterHeader = res.headers.get('Retry-After');
|
|
99
|
+
const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 60;
|
|
100
|
+
const sleepMs =
|
|
101
|
+
Number.isFinite(retryAfterSec) && retryAfterSec > 0
|
|
102
|
+
? Math.min(retryAfterSec * 1000, 60_000)
|
|
103
|
+
: 60_000;
|
|
104
|
+
await sleep(sleepMs);
|
|
105
|
+
continue; // interval unchanged — this is transport-layer, not protocol-layer
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const body = await res.json();
|
|
109
|
+
if (res.ok) {
|
|
110
|
+
return body; // { credential }
|
|
111
|
+
}
|
|
112
|
+
if (body.error === 'slow_down') {
|
|
113
|
+
currentInterval += 5;
|
|
114
|
+
await sleep(currentInterval * 1000);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (body.error === 'authorization_pending') {
|
|
118
|
+
await sleep(currentInterval * 1000);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (body.error === 'expired_token') {
|
|
122
|
+
throw new Error('expired');
|
|
123
|
+
}
|
|
124
|
+
if (body.error === 'access_denied') {
|
|
125
|
+
throw new Error('denied');
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Device flow error: ${body.error ?? 'unknown'}`);
|
|
128
|
+
}
|
|
129
|
+
throw new Error('timeout');
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
// Pre-attach no-op .catch() so the promise is never "unhandled" when rejection occurs
|
|
133
|
+
// between creation and the caller's await/catch.
|
|
134
|
+
promise.catch(() => {});
|
|
135
|
+
return promise;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Phase 1 of runInit: detect IDE clients, multiselect, read each IDE's mcpServers,
|
|
140
|
+
* run D-09 pre-validation retry loop, compute wrappedKeys, emit preview lines,
|
|
141
|
+
* and apply D-02 early-exit predicate.
|
|
142
|
+
*
|
|
143
|
+
* Returns a Map<clientId, { client, readResult, validated, wrappedKeys, rawEntryCount }>
|
|
144
|
+
* if init should proceed to auth, or null if D-02 early-exit fired.
|
|
145
|
+
*
|
|
146
|
+
* NO FILE WRITES — T-128-08: all writes are gated by auth; this phase is read-only.
|
|
147
|
+
*
|
|
148
|
+
* @returns {Promise<Map<string, object>|null>}
|
|
149
|
+
*/
|
|
150
|
+
// eslint-disable-next-line max-lines-per-function -- D-02 prepare phase: detect → multiselect → read → D-09 retry → preview → early-exit; sequential invariants break if split
|
|
151
|
+
export async function selectAndPrepareIdes() {
|
|
152
|
+
// Step 1: Detect installed clients and multiselect
|
|
153
|
+
const detected = detectInstalledClients();
|
|
154
|
+
const availableClients = IDE_CLIENTS.filter((c) => c.configPath() !== null);
|
|
155
|
+
|
|
156
|
+
if (availableClients.length === 0) {
|
|
157
|
+
p.log.warn('No supported IDE clients detected on this system.');
|
|
158
|
+
p.outro('Run sphyr init again after installing an IDE client.');
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const options = availableClients.map((c) => ({
|
|
163
|
+
value: c.id,
|
|
164
|
+
label: c.name,
|
|
165
|
+
hint: detected.has(c.id) ? 'detected' : 'not detected',
|
|
166
|
+
}));
|
|
167
|
+
const initialValues = availableClients.filter((c) => detected.has(c.id)).map((c) => c.id);
|
|
168
|
+
|
|
169
|
+
const selected = await p.multiselect({
|
|
170
|
+
message: 'Select IDE clients to configure:',
|
|
171
|
+
options,
|
|
172
|
+
initialValues,
|
|
173
|
+
required: false,
|
|
174
|
+
});
|
|
175
|
+
if (p.isCancel(selected)) {
|
|
176
|
+
p.cancel('Cancelled.');
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (selected.length === 0) {
|
|
181
|
+
p.log.info('No clients selected. Skipping config writing.');
|
|
182
|
+
p.outro('Run sphyr init again when ready.');
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Step 2: Read each IDE's mcpServers and build ideReadResults Map
|
|
187
|
+
/** @type {Map<string, { client: object, readResult: object, validated: object[], wrappedKeys: string[], rawEntryCount: number }>} */
|
|
188
|
+
const ideReadResults = new Map();
|
|
189
|
+
|
|
190
|
+
for (const clientId of selected) {
|
|
191
|
+
const client = IDE_CLIENTS.find((c) => c.id === clientId);
|
|
192
|
+
if (!client) continue;
|
|
193
|
+
|
|
194
|
+
// Read the raw config to compute rawEntryCount for D-02 predicate
|
|
195
|
+
const configPath = client.configPath();
|
|
196
|
+
let rawMcpServers = {};
|
|
197
|
+
if (configPath) {
|
|
198
|
+
try {
|
|
199
|
+
if (fs.existsSync(configPath)) {
|
|
200
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
201
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
202
|
+
rawMcpServers = raw[client.topLevelKey] || {};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// Non-fatal — rawMcpServers stays {}; readMcpServersForIde will emit a warning
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const rawEntryCount = Object.keys(rawMcpServers).length;
|
|
210
|
+
|
|
211
|
+
// Call readMcpServersForIde which performs D-01 merge inside (Plan 01).
|
|
212
|
+
// readResult.all is the D-01 merged set — already performed inside readMcpServersForIde
|
|
213
|
+
// (Plan 01); existingWrapped + newIndividual combined, new wins on name collision.
|
|
214
|
+
const readResult = readMcpServersForIde(client);
|
|
215
|
+
|
|
216
|
+
// Surface warnings from readMcpServersForIde (e.g., malformed existing SPHYR_DOWNSTREAM)
|
|
217
|
+
for (const warning of readResult.warnings) {
|
|
218
|
+
p.log.warn(warning);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// D-04 exclusion logging: emit per-entry warn for any raw mcpServers key that was
|
|
222
|
+
// excluded by isSphyrEntry. These are the "circular wrapping" entries.
|
|
223
|
+
for (const [key, entry] of Object.entries(rawMcpServers)) {
|
|
224
|
+
if (isSphyrEntry(key, entry)) {
|
|
225
|
+
p.log.warn(`[sphyr-guard-init] Skipping '${key}' — would create circular wrapping.`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// D-09 retry loop: call parseDownstreamConfig; on field-level error, splice out the
|
|
230
|
+
// failing entry by index and retry until clean or empty.
|
|
231
|
+
let candidates = [...readResult.all]; // copy to avoid mutating readResult.all
|
|
232
|
+
let validated = [];
|
|
233
|
+
// eslint-disable-next-line no-constant-condition -- exit via break
|
|
234
|
+
while (true) {
|
|
235
|
+
if (candidates.length === 0) {
|
|
236
|
+
validated = [];
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
validated = parseDownstreamConfig(JSON.stringify(candidates));
|
|
241
|
+
break; // success
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Try to extract the failing entry index from the error message
|
|
244
|
+
const match = err.message.match(/SPHYR_DOWNSTREAM\[(\d+)\]/);
|
|
245
|
+
if (!match) {
|
|
246
|
+
// Unexpected error shape (e.g., "must contain at most 50 server configs") — rethrow
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
const idx = parseInt(match[1], 10);
|
|
250
|
+
p.log.warn(`[sphyr-guard-init] Skipping invalid entry: ${err.message}`);
|
|
251
|
+
candidates.splice(idx, 1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Compute wrappedKeys: original mcpServers keys whose mapped DownstreamServerConfig
|
|
256
|
+
// entry appears in `validated` by name.
|
|
257
|
+
// wrappedKeys = original mcpServers keys whose mapped DownstreamServerConfig entry
|
|
258
|
+
// appears in `validated` by name. Used by replaceWithGuardEntry to know which entries to delete.
|
|
259
|
+
// Re-run entries that were previously only in legacy SPHYR_DOWNSTREAM (not in current mcpServers)
|
|
260
|
+
// are NOT in wrappedKeys — they don't need to be deleted from mcpServers (they never lived there).
|
|
261
|
+
const wrappedKeys = Object.entries(rawMcpServers)
|
|
262
|
+
.filter(([key, entry]) => !isSphyrEntry(key, entry))
|
|
263
|
+
.map(([key]) => key)
|
|
264
|
+
.filter((key) => validated.some((v) => v.name === key));
|
|
265
|
+
|
|
266
|
+
ideReadResults.set(clientId, { client, readResult, validated, wrappedKeys, rawEntryCount });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Step 3: Emit preview lines per IDE
|
|
270
|
+
for (const { client, readResult, validated } of ideReadResults.values()) {
|
|
271
|
+
if (validated.length > 0) {
|
|
272
|
+
// Distinguish existing vs new entries (D-01 preview per CONTEXT.md D-02)
|
|
273
|
+
const existingNames = new Set(readResult.existingWrapped.map((e) => e.name));
|
|
274
|
+
const parts = validated.map((v) => `${v.name} (${existingNames.has(v.name) ? 'existing' : 'new'})`);
|
|
275
|
+
p.log.info(`[sphyr-guard-init] ${client.name}: will wrap ${parts.join(', ')}`);
|
|
276
|
+
} else {
|
|
277
|
+
// D-03 per-IDE note: empty config proceeds to auth with SPHYR_DOWNSTREAM=[]
|
|
278
|
+
p.log.info(
|
|
279
|
+
`[sphyr-guard-init] No downstream servers found in ${client.name} — sphyr configured without downstreams. Add servers to SPHYR_DOWNSTREAM to wrap them later.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Step 4: D-02 early-exit predicate
|
|
285
|
+
// Reconciled with D-03 (Cursor review MEDIUM):
|
|
286
|
+
// allEmpty && anyHadRawEntries → circular-wrap case; exit early (all entries got sphyr-filtered)
|
|
287
|
+
// allEmpty && !anyHadRawEntries → fresh-install case (D-03); proceed to auth with SPHYR_DOWNSTREAM=[]
|
|
288
|
+
// !allEmpty → some IDEs have wrappable servers; proceed normally
|
|
289
|
+
const allEmpty = [...ideReadResults.values()].every((r) => r.validated.length === 0);
|
|
290
|
+
const anyHadRawEntries = [...ideReadResults.values()].some((r) => r.rawEntryCount > 0);
|
|
291
|
+
|
|
292
|
+
if (allEmpty && anyHadRawEntries) {
|
|
293
|
+
p.log.warn(
|
|
294
|
+
'[sphyr-guard-init] No wrappable servers found in any selected IDE (existing entries were all sphyr-prefixed or invalid). Add servers to your MCP config and re-run.',
|
|
295
|
+
);
|
|
296
|
+
p.outro('Nothing to configure.');
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Return map for auth + write phases (fresh-install case also returns here per D-03)
|
|
301
|
+
return ideReadResults;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Write sphyr-guard entries for each IDE using the pre-computed read results.
|
|
306
|
+
* Accepts ideReadResults from selectAndPrepareIdes() — performs WRITES ONLY.
|
|
307
|
+
*
|
|
308
|
+
* @param {object} credentials - { credential } from runDeviceFlow
|
|
309
|
+
* @param {Map<string, { client: object, validated: object[], wrappedKeys: string[] }>} ideReadResults
|
|
310
|
+
*/
|
|
311
|
+
export async function configureIdeClients(credentials, ideReadResults) {
|
|
312
|
+
const { credential } = credentials;
|
|
313
|
+
|
|
314
|
+
const results = [];
|
|
315
|
+
for (const ideData of ideReadResults.values()) {
|
|
316
|
+
const { client, validated, wrappedKeys } = ideData;
|
|
317
|
+
const configPath = client.configPath();
|
|
318
|
+
if (!configPath) continue;
|
|
319
|
+
|
|
320
|
+
const downstreamJson = JSON.stringify(validated);
|
|
321
|
+
const entry = buildServerEntry(client, credential, downstreamJson);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
replaceWithGuardEntry(configPath, client.topLevelKey, entry, wrappedKeys);
|
|
325
|
+
results.push({ client: client.name, path: configPath, status: 'written' });
|
|
326
|
+
} catch (err) {
|
|
327
|
+
results.push({ client: client.name, path: configPath, status: 'failed', error: err.message });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Per-client summary
|
|
332
|
+
const writtenCount = results.filter((r) => r.status === 'written').length;
|
|
333
|
+
const failedCount = results.filter((r) => r.status === 'failed').length;
|
|
334
|
+
|
|
335
|
+
for (const r of results) {
|
|
336
|
+
if (r.status === 'written') {
|
|
337
|
+
p.log.success(`${r.client} configured`);
|
|
338
|
+
} else {
|
|
339
|
+
p.log.error(`${r.client}: ${r.error}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (writtenCount > 0) {
|
|
344
|
+
p.outro(
|
|
345
|
+
[
|
|
346
|
+
`Sphyr is active — every MCP request will be signed automatically.`,
|
|
347
|
+
``,
|
|
348
|
+
` Next: restart your IDE client${writtenCount > 1 ? 's' : ''} to activate.`,
|
|
349
|
+
` Re-run sphyr init to add IDE clients or rotate your API key.`,
|
|
350
|
+
` Manage usage and billing → console.sphyr.io`,
|
|
351
|
+
].join('\n'),
|
|
352
|
+
);
|
|
353
|
+
} else {
|
|
354
|
+
p.outro(
|
|
355
|
+
failedCount > 0
|
|
356
|
+
? `Setup failed for all clients. Re-run sphyr init to try again.`
|
|
357
|
+
: `No clients configured. Re-run sphyr init when ready.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* RFC 8628 Device Authorization Grant — CLI side.
|
|
364
|
+
* The only supported auth method for `sphyr init`.
|
|
365
|
+
*
|
|
366
|
+
* Internal function called by runInit AFTER selectAndPrepareIdes().
|
|
367
|
+
* Returns credentials { credential } on success; process.exit(1) on failure.
|
|
368
|
+
*
|
|
369
|
+
* @param {object} [opts]
|
|
370
|
+
* @param {string} [opts.apiBase] - API base URL override (for testing)
|
|
371
|
+
* @returns {Promise<{credential: string}>}
|
|
372
|
+
*
|
|
373
|
+
* Implementation note: The returned promise has a pre-attached no-op .catch() to prevent
|
|
374
|
+
* unhandled-rejection warnings in tests where cleanup (.catch) is attached asynchronously
|
|
375
|
+
* after the promise may have already settled.
|
|
376
|
+
*/
|
|
377
|
+
export function runDeviceFlow({ apiBase } = {}) {
|
|
378
|
+
const promise = (async () => {
|
|
379
|
+
const effectiveApiBase = apiBase || API_BASE;
|
|
380
|
+
|
|
381
|
+
process.stdout.write(BANNER + '\n');
|
|
382
|
+
p.intro('Device authorization — sign in on a separate device');
|
|
383
|
+
|
|
384
|
+
// Step 1: initiate
|
|
385
|
+
let initBody;
|
|
386
|
+
try {
|
|
387
|
+
const res = await fetch(`${effectiveApiBase}/v1/device_auth`, {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({}),
|
|
391
|
+
});
|
|
392
|
+
if (!res.ok) {
|
|
393
|
+
p.log.error(`Failed to initiate device flow: HTTP ${res.status}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
initBody = await res.json();
|
|
397
|
+
} catch (err) {
|
|
398
|
+
p.log.error(`Network error: ${err.message}`);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } = initBody;
|
|
403
|
+
|
|
404
|
+
// Per D-01: prefer verification_uri_complete (pre-filled user_code) when provided.
|
|
405
|
+
// Fall back to verification_uri so the user_code display line remains the headless fallback.
|
|
406
|
+
const urlToOpen = verification_uri_complete ?? verification_uri;
|
|
407
|
+
|
|
408
|
+
// Step 2: display code + URL BEFORE entering poll loop (RESEARCH anti-pattern guard)
|
|
409
|
+
p.log.message(` Authorization code: ${user_code}`, { symbol: '◆' });
|
|
410
|
+
p.log.step(`Open: ${urlToOpen}`);
|
|
411
|
+
|
|
412
|
+
// Step 3: best-effort browser launch (only if TTY — headless environments have no DISPLAY)
|
|
413
|
+
if (process.stdout.isTTY) {
|
|
414
|
+
try {
|
|
415
|
+
await open(urlToOpen);
|
|
416
|
+
} catch {
|
|
417
|
+
// Silent — the URL is already displayed for manual navigation
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Step 4: poll
|
|
422
|
+
const s = p.spinner();
|
|
423
|
+
s.start('Waiting for authorization (15 min timeout)...');
|
|
424
|
+
let credentials;
|
|
425
|
+
try {
|
|
426
|
+
credentials = await pollForToken({
|
|
427
|
+
tokenUrl: `${effectiveApiBase}/v1/device_auth/token`,
|
|
428
|
+
deviceCode: device_code,
|
|
429
|
+
interval: interval || 5,
|
|
430
|
+
expiresIn: expires_in || 900,
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
s.stop('Authorization failed.');
|
|
434
|
+
if (err.message === 'expired') {
|
|
435
|
+
p.log.error('Authorization timed out. Run `sphyr init` again to retry.');
|
|
436
|
+
} else if (err.message === 'denied') {
|
|
437
|
+
p.log.error('Authorization was denied.');
|
|
438
|
+
} else if (err.message === 'timeout') {
|
|
439
|
+
p.log.error('Authorization timed out (15 minutes). Run `sphyr init` again to retry.');
|
|
440
|
+
} else {
|
|
441
|
+
p.log.error(`Device flow error: ${err.message}`);
|
|
442
|
+
}
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
s.stop('Authorized.');
|
|
446
|
+
|
|
447
|
+
return credentials;
|
|
448
|
+
})();
|
|
449
|
+
|
|
450
|
+
// Pre-attach no-op .catch() so the promise is never "unhandled" when rejection occurs
|
|
451
|
+
// between creation and the caller's await/catch.
|
|
452
|
+
promise.catch(() => {});
|
|
453
|
+
return promise;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function runInit() {
|
|
457
|
+
// --loopback is no longer a supported flow. Emit a deprecation warning and
|
|
458
|
+
// fall through to device flow. The loopback flow stored credentials in URL
|
|
459
|
+
// query parameters (?api_key=&hmac_secret=), violating RFC 8252 §8.2.
|
|
460
|
+
if (process.argv.includes('--loopback')) {
|
|
461
|
+
p.log.warn(
|
|
462
|
+
'The --loopback flag is deprecated and has been removed. ' +
|
|
463
|
+
'Device authorization (RFC 8628) is now the only supported auth method for `sphyr init`. ' +
|
|
464
|
+
'Continuing with device flow...',
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// D-02 flow order: read-before-auth (STRUCTURAL REQUIREMENT)
|
|
469
|
+
// Step 1: Read IDE configs, preview, early-exit if nothing wrappable
|
|
470
|
+
const ideReadResults = await selectAndPrepareIdes();
|
|
471
|
+
|
|
472
|
+
// D-02 early-exit: selectAndPrepareIdes returns null when all IDEs are empty
|
|
473
|
+
// AND at least one had raw entries (all-sphyr-filtered or all-invalid case).
|
|
474
|
+
// If null, we exit before auth begins — no device flow initiated.
|
|
475
|
+
if (ideReadResults === null) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Step 2: Auth (runs AFTER read phase per D-02)
|
|
480
|
+
const credentials = await runDeviceFlow();
|
|
481
|
+
|
|
482
|
+
// Step 3: Write IDE configs using pre-computed read results
|
|
483
|
+
await configureIdeClients(credentials, ideReadResults);
|
|
484
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Sphyr
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* login.js — `sphyr login`
|
|
6
|
+
*
|
|
7
|
+
* Authenticates via the RFC 8628 device flow (reusing runDeviceFlow from init.js) and
|
|
8
|
+
* writes the resulting credentials to ~/.sphyr/config.json with 0600 permissions.
|
|
9
|
+
*
|
|
10
|
+
* The SDKs (SphyrClient / auto_instrument / autoInstrument) auto-load this file when no
|
|
11
|
+
* constructor args and no SPHYR_* env vars are present — so `sphyr login` plus one
|
|
12
|
+
* line of code is the whole setup, with zero manual copy-paste of secrets.
|
|
13
|
+
*
|
|
14
|
+
* Security: the file holds the credential (sphyr_v1_<keyId>.<signingSecret>), so the
|
|
15
|
+
* directory is created 0700 and the file written 0600 (chmod re-applied in case the file
|
|
16
|
+
* pre-existed with looser perms).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import * as p from '@clack/prompts';
|
|
23
|
+
import { runDeviceFlow } from './init.js';
|
|
24
|
+
import { requireTrustedEndpoint } from '../lib/url-guard.js';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_MCP_URL = 'https://api.sphyr.io/mcp';
|
|
27
|
+
|
|
28
|
+
export async function runLogin() {
|
|
29
|
+
// Honor HOME/USERPROFILE before the passwd DB so the write location matches where the
|
|
30
|
+
// SDK reads (and respects an explicit HOME override).
|
|
31
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
32
|
+
const dir = path.join(home, '.sphyr');
|
|
33
|
+
const configPath = path.join(dir, 'config.json');
|
|
34
|
+
|
|
35
|
+
// REVIEWS Finding 7: detect legacy { api_key, hmac_secret } config and surface a clear
|
|
36
|
+
// re-auth migration message. The old two-secret format cannot be losslessly converted to
|
|
37
|
+
// a sphyr_v1_<keyId>.<signingSecret> credential (the signing-secret derivation is
|
|
38
|
+
// server-side), so re-authentication is required. A silent shim is not feasible without
|
|
39
|
+
// a server round-trip — the detection + message is the correct UX.
|
|
40
|
+
// We do NOT crash and do NOT delete the old config; the device flow will overwrite it.
|
|
41
|
+
try {
|
|
42
|
+
if (fs.existsSync(configPath)) {
|
|
43
|
+
const existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
44
|
+
if (
|
|
45
|
+
typeof existing.api_key === 'string' && existing.api_key.length > 0 &&
|
|
46
|
+
typeof existing.hmac_secret === 'string' && existing.hmac_secret.length > 0 &&
|
|
47
|
+
!existing.credential
|
|
48
|
+
) {
|
|
49
|
+
p.log.warn(
|
|
50
|
+
'Your ~/.sphyr/config.json uses the old two-secret format. ' +
|
|
51
|
+
'Run `sphyr login` to re-authenticate with the new single-credential format.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// If we cannot read/parse the existing config, proceed silently — login will overwrite it.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const credentials = await runDeviceFlow();
|
|
60
|
+
const { credential } = credentials;
|
|
61
|
+
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
63
|
+
const payload = {
|
|
64
|
+
credential,
|
|
65
|
+
// W-10: validated before persisting — this value is auto-loaded by the SDK
|
|
66
|
+
// for all future credentialed traffic, so an http:// or attacker-host
|
|
67
|
+
// override must fail loudly here, not silently survive into config.json.
|
|
68
|
+
mcp_url: requireTrustedEndpoint(process.env.SPHYR_MCP_URL || DEFAULT_MCP_URL, 'SPHYR_MCP_URL', DEFAULT_MCP_URL),
|
|
69
|
+
};
|
|
70
|
+
fs.writeFileSync(configPath, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 });
|
|
71
|
+
// writeFileSync's `mode` is ignored when the file already exists on some platforms —
|
|
72
|
+
// re-apply 0600 explicitly so a pre-existing looser-permission file is tightened.
|
|
73
|
+
try {
|
|
74
|
+
fs.chmodSync(configPath, 0o600);
|
|
75
|
+
} catch {
|
|
76
|
+
/* best-effort — non-POSIX filesystems may not support chmod */
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
p.log.success(`Credentials saved to ${configPath} (0600).`);
|
|
80
|
+
p.outro(
|
|
81
|
+
[
|
|
82
|
+
'You are signed in. The SDK picks these up automatically — no keys to copy:',
|
|
83
|
+
'',
|
|
84
|
+
' Python: from sphyr_sdk import auto_instrument; auto_instrument()',
|
|
85
|
+
' Node: import { autoInstrument } from "@sphyr/sdk"; autoInstrument()',
|
|
86
|
+
'',
|
|
87
|
+
'auto_instrument() reads ~/.sphyr/config.json when no arguments or SPHYR_* env',
|
|
88
|
+
'vars are set. Manage usage and billing → console.sphyr.io',
|
|
89
|
+
].join('\n'),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Sphyr
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* sphyr guard verify
|
|
6
|
+
*
|
|
7
|
+
* Delegates to the SDK's sphyr-mcp verify subcommand via a version-pinned npx
|
|
8
|
+
* invocation, inheriting stdio and propagating the exit code.
|
|
9
|
+
*
|
|
10
|
+
* Using the SDK's verify (not a reimplementation) ensures a single source of
|
|
11
|
+
* truth for the credential-resolution → handshake → agent_guard_up probe logic.
|
|
12
|
+
* The SDK version is pinned using the same SDK_VERSION value that buildServerEntry
|
|
13
|
+
* in ide-clients.js uses for generated IDE configs (D-17 supply-chain requirement).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { SDK_VERSION } from '../lib/ide-clients.js';
|
|
18
|
+
|
|
19
|
+
export function runVerify() {
|
|
20
|
+
const child = spawn(
|
|
21
|
+
'npx',
|
|
22
|
+
['-y', '-p', `@sphyr/sdk@${SDK_VERSION}`, 'sphyr-mcp', 'verify', ...process.argv.slice(3)],
|
|
23
|
+
{ stdio: 'inherit' },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
child.on('error', (err) => {
|
|
27
|
+
process.stderr.write(`sphyr guard verify: failed to start — ${err.message}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
child.on('close', (code) => {
|
|
32
|
+
process.exit(code ?? 1);
|
|
33
|
+
});
|
|
34
|
+
}
|