@yolo-labs/yolobridge 0.1.0 → 0.2.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/dist/atomic-write.js +297 -0
- package/dist/attach-cmd.js +74 -3
- package/dist/cli.js +285 -44
- package/dist/device-auth.js +10 -9
- package/dist/git-safety.js +151 -0
- package/dist/local-mcp-config.js +877 -0
- package/dist/local-mcp-trust.js +371 -0
- package/dist/login-cmd.js +12 -4
- package/dist/mcp-proxy.js +590 -0
- package/package.json +1 -1
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-trusts the `yolo-studio` MCP server `local-mcp-config.ts` writes into
|
|
3
|
+
* `.mcp.json`, by writing a companion project-scoped
|
|
4
|
+
* `.claude/settings.local.json` entry in the same `yolo-bridge attach`
|
|
5
|
+
* spawn `cwd`.
|
|
6
|
+
*
|
|
7
|
+
* Without this, Claude Code shows "New MCP server found in this project:
|
|
8
|
+
* yolo-studio" and every one of its tool calls requires manual approval --
|
|
9
|
+
* defeating the point of `send_to_tile` reaching a session that then just
|
|
10
|
+
* sits there waiting on an interactive prompt nobody's watching for.
|
|
11
|
+
*
|
|
12
|
+
* Two SEPARATE approval layers, both need covering (confirmed against
|
|
13
|
+
* current Claude Code docs, not assumed):
|
|
14
|
+
* 1. Server discovery/trust ("New MCP server found") -- `enabledMcpjsonServers`.
|
|
15
|
+
* 2. Per-tool-call approval -- `permissions.allow` with an
|
|
16
|
+
* `mcp__<server>__<tool>` pattern (`*` wildcard for "every tool this
|
|
17
|
+
* server exposes"; Claude Code's own docs, matched independently
|
|
18
|
+
* against a Codex review finding 2026-08-24 -- see below).
|
|
19
|
+
*
|
|
20
|
+
* Deliberately narrow, same reasoning as `local-mcp-config.ts`: this adds
|
|
21
|
+
* ONLY `enabledMcpjsonServers: ["yolo-studio"]` and
|
|
22
|
+
* `permissions.allow: ["mcp__yolo-studio__*"]` -- never
|
|
23
|
+
* `enableAllProjectMcpServers` (would trust future/unrelated MCP servers
|
|
24
|
+
* too) and never `--dangerously-skip-permissions`-equivalent blanket rules.
|
|
25
|
+
* Every other key in an existing settings file (the user's own permissions,
|
|
26
|
+
* hooks, etc.) is preserved untouched, same "only touch what we own"
|
|
27
|
+
* discipline as the `.mcp.json` writer.
|
|
28
|
+
*
|
|
29
|
+
* Targets `settings.local.json`, NOT the shared `settings.json` (Codex
|
|
30
|
+
* review, 2026-08-24, round 14 — this repo's OWN root already has a
|
|
31
|
+
* tracked `.claude/settings.json`): the shared file is meant to be
|
|
32
|
+
* committed and shared across a team, but this grant is per-attach,
|
|
33
|
+
* ephemeral daemon state — if written there, a spawned coding agent
|
|
34
|
+
* running with YOLO-mode autonomy could commit/push it, and a SIGKILL or
|
|
35
|
+
* reboot before cleanup would leave it in a file every OTHER checkout of
|
|
36
|
+
* the repo inherits, auto-trusting a future `yolo-studio` server
|
|
37
|
+
* definition with no prompt. `settings.local.json` is Claude Code's own
|
|
38
|
+
* sanctioned personal/machine-local settings layer (merged with
|
|
39
|
+
* `settings.json`), CONVENTIONALLY git-ignored.
|
|
40
|
+
*
|
|
41
|
+
* "Conventionally" is not "guaranteed" (Codex review, 2026-08-24, round 15):
|
|
42
|
+
* that assumption doesn't hold universally, and disproved itself in the one
|
|
43
|
+
* place it would matter most — this repo's OWN root ALREADY tracks
|
|
44
|
+
* `.claude/settings.local.json` (verified with `git ls-files`, not assumed).
|
|
45
|
+
* Trusting any specific filename's reputation is therefore the wrong check.
|
|
46
|
+
* `writeLocalMcpTrust` now asks git directly (`git check-ignore`) whether
|
|
47
|
+
* THIS path, in THIS `cwd`, is actually safe from being swept into a
|
|
48
|
+
* commit, and refuses to write anything (falling back to `ok: false`,
|
|
49
|
+
* the same degraded "manual approval required" path an unparseable file
|
|
50
|
+
* already takes) if it isn't — never writing INTO a tracked-or-trackable
|
|
51
|
+
* file is the only way to make the "never persisted where Git can see it"
|
|
52
|
+
* guarantee actually hold, regardless of which filename convention this
|
|
53
|
+
* particular checkout happens to follow.
|
|
54
|
+
*
|
|
55
|
+
* KNOWN GOTCHA, not fixable from here: project-scoped settings are ignored
|
|
56
|
+
* in an UNTRUSTED folder until the user interactively trusts the workspace
|
|
57
|
+
* once (Claude Code v2.1.196+). `yolo-bridge attach` spawns into the
|
|
58
|
+
* directory the command itself was run from -- normally the user's own
|
|
59
|
+
* long-lived working directory, already trusted from ordinary use, not a
|
|
60
|
+
* fresh temp dir -- so this shouldn't bite in the common case, but a truly
|
|
61
|
+
* first-ever `attach` in a brand-new directory would still see one manual
|
|
62
|
+
* trust prompt before this takes effect.
|
|
63
|
+
*/
|
|
64
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
65
|
+
import { join, dirname, basename } from 'node:path';
|
|
66
|
+
import { atomicWriteFileSync, unlinkWriteTarget, resolveWriteTarget } from './atomic-write.js';
|
|
67
|
+
import { randomUUID } from 'node:crypto';
|
|
68
|
+
import { riskyToCommit, ensureTempSiblingExcluded } from './git-safety.js';
|
|
69
|
+
/** Same identity as local-mcp-config.ts's SERVER_NAME -- kept as an
|
|
70
|
+
* independent constant (not imported) since these two modules are meant to
|
|
71
|
+
* be usable/testable independently of each other. */
|
|
72
|
+
const SERVER_NAME = 'yolo-studio';
|
|
73
|
+
// Shell-glob wildcard, NOT regex -- Claude Code's permission matching treats
|
|
74
|
+
// `*` as "any tool from this server" (docs.claude.com/en/docs/claude-code/
|
|
75
|
+
// permissions, "tool name wildcards"). A `.*` here (as this constant
|
|
76
|
+
// originally read) requires a literal dot before the wildcard, which no real
|
|
77
|
+
// `mcp__yolo-studio__<tool>` id has -- so it silently matched NOTHING, and
|
|
78
|
+
// pre-trust never actually worked: every tool call still prompted for manual
|
|
79
|
+
// approval. Found by Codex review (2026-08-24), confirmed independently
|
|
80
|
+
// against current docs before fixing (not just taken on faith).
|
|
81
|
+
const TOOL_PATTERN = `mcp__${SERVER_NAME}__*`;
|
|
82
|
+
function settingsPath(cwd) {
|
|
83
|
+
return join(cwd, '.claude', 'settings.local.json');
|
|
84
|
+
}
|
|
85
|
+
function readSettings(path) {
|
|
86
|
+
if (!existsSync(path))
|
|
87
|
+
return {};
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error(`existing ${path} is not valid JSON`);
|
|
94
|
+
}
|
|
95
|
+
// Same refuse-rather-than-clobber fix as local-mcp-config.ts's
|
|
96
|
+
// readConfig (Codex review, 2026-08-24) — a JSON array root passed the
|
|
97
|
+
// old truthy-and-typeof-object check and would have been cast straight
|
|
98
|
+
// into Record<string, unknown>.
|
|
99
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
100
|
+
throw new Error(`existing ${path} is not a JSON object`);
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
function asStringArray(value) {
|
|
105
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Top-level marker key recording exactly which entries THIS module added,
|
|
109
|
+
* so a LATER attach (after a crash) can tell "this is ours to reclaim" with
|
|
110
|
+
* certainty instead of guessing (Codex review, 2026-08-24, round 7 —
|
|
111
|
+
* mirrors `local-mcp-config.ts`'s `OWNERSHIP_MARKER`, same reasoning: a
|
|
112
|
+
* content-only heuristic can't distinguish our own leftover state from the
|
|
113
|
+
* operator's genuinely independent choice, since both look identical once
|
|
114
|
+
* written). Claude Code's settings schema tolerates unrelated top-level
|
|
115
|
+
* keys the same way `.mcp.json` does — this module already preserves the
|
|
116
|
+
* operator's own such keys untouched.
|
|
117
|
+
*/
|
|
118
|
+
const OWNERSHIP_MARKER = '_yolobridge';
|
|
119
|
+
/**
|
|
120
|
+
* True only when `raw` has EXACTLY this module's own marker shape — no
|
|
121
|
+
* unexpected keys, and every present field correctly typed (Codex review,
|
|
122
|
+
* 2026-08-24, round 30): the previous check only confirmed "some object,"
|
|
123
|
+
* blindly casting it as `OwnershipMarker` — an operator with their OWN
|
|
124
|
+
* unrelated `_yolobridge` top-level key (however unlikely a name collision
|
|
125
|
+
* this is) would have it treated as this module's own record, letting
|
|
126
|
+
* `writeLocalMcpTrust` OVERWRITE it unconditionally and `removeLocalMcpTrust`
|
|
127
|
+
* later DELETE it outright, destroying data this module never owned. Same
|
|
128
|
+
* "present-but-invalid is refused" convention this file already applies to
|
|
129
|
+
* `enabledMcpjsonServers`/`permissions` — this is the one spot that skipped
|
|
130
|
+
* it.
|
|
131
|
+
*/
|
|
132
|
+
function looksLikeOurOwnMarker(raw) {
|
|
133
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
134
|
+
return false;
|
|
135
|
+
const obj = raw;
|
|
136
|
+
const allowedKeys = new Set(['attachId', 'enabledServerEntry', 'permissionEntry']);
|
|
137
|
+
for (const key of Object.keys(obj)) {
|
|
138
|
+
if (!allowedKeys.has(key))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if ('attachId' in obj && typeof obj.attachId !== 'string')
|
|
142
|
+
return false;
|
|
143
|
+
if ('enabledServerEntry' in obj && typeof obj.enabledServerEntry !== 'boolean')
|
|
144
|
+
return false;
|
|
145
|
+
if ('permissionEntry' in obj && typeof obj.permissionEntry !== 'boolean')
|
|
146
|
+
return false;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
function readOwnershipMarker(settings) {
|
|
150
|
+
const raw = settings[OWNERSHIP_MARKER];
|
|
151
|
+
if (!looksLikeOurOwnMarker(raw))
|
|
152
|
+
return {};
|
|
153
|
+
return raw;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Adds the `yolo-studio` MCP-trust entries to `.claude/settings.local.json`.
|
|
157
|
+
* Returns `ok: false` (does nothing further) if an existing settings file
|
|
158
|
+
* can't be parsed, rather than overwriting a file the user hand-authored --
|
|
159
|
+
* matches `writeLocalMcpConfig`'s own refusal behavior exactly. Also
|
|
160
|
+
* returns `ok: false` if `riskyToCommit` confirms this exact path is NOT
|
|
161
|
+
* git-ignored inside a real repo at `cwd` (Codex review, 2026-08-24,
|
|
162
|
+
* round 15) -- degrading to "every tool call needs manual approval" is
|
|
163
|
+
* strictly preferable to writing an auto-trust grant somewhere a commit
|
|
164
|
+
* could publish it.
|
|
165
|
+
*
|
|
166
|
+
* Tracks which entries it ACTUALLY inserted vs. which were already present
|
|
167
|
+
* (Codex review, 2026-08-24): if the operator had already trusted this
|
|
168
|
+
* server themselves before ever running `attach`, that's their own
|
|
169
|
+
* standing choice, not something this attach owns -- `removeLocalMcpTrust`
|
|
170
|
+
* must be told which entries to remove rather than unconditionally
|
|
171
|
+
* stripping by value, or a detach would revoke trust the operator granted
|
|
172
|
+
* independently.
|
|
173
|
+
*/
|
|
174
|
+
export function writeLocalMcpTrust(cwd) {
|
|
175
|
+
const path = settingsPath(cwd);
|
|
176
|
+
if (riskyToCommit(cwd, path)) {
|
|
177
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
178
|
+
}
|
|
179
|
+
// The DESTINATION is confirmed safe above, but `atomicWriteFileSync`'s own
|
|
180
|
+
// `.tmp-*` temp sibling has a DIFFERENT literal name an exact-match
|
|
181
|
+
// `.gitignore` entry doesn't cover (Codex review, 2026-08-24, round 25) —
|
|
182
|
+
// see `ensureTempSiblingExcluded`'s own doc comment. Derived from
|
|
183
|
+
// `resolveWriteTarget`, not the lexical path (Codex review, 2026-08-24,
|
|
184
|
+
// round 27) — see local-mcp-config.ts's identical fix for why.
|
|
185
|
+
// `?? path` (Codex review, 2026-08-24, round 31) — see
|
|
186
|
+
// local-mcp-config.ts's identical fix for why.
|
|
187
|
+
const resolvedSettingsPath = resolveWriteTarget(path) ?? path;
|
|
188
|
+
ensureTempSiblingExcluded(cwd, dirname(resolvedSettingsPath), `${basename(resolvedSettingsPath)}.tmp-*`);
|
|
189
|
+
const createdFile = !existsSync(path);
|
|
190
|
+
let settings;
|
|
191
|
+
try {
|
|
192
|
+
settings = readSettings(path);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
196
|
+
}
|
|
197
|
+
// Present-but-invalid is refused, same reasoning as local-mcp-config.ts's
|
|
198
|
+
// matching fix (Codex review, 2026-08-24): `asStringArray` alone silently
|
|
199
|
+
// treats a non-array value as `[]`, which would discard a malformed
|
|
200
|
+
// `enabledMcpjsonServers`/`permissions.allow` (or a non-object
|
|
201
|
+
// `permissions`) on write while still reporting success. Only genuinely
|
|
202
|
+
// ABSENT fields default to empty.
|
|
203
|
+
if ('enabledMcpjsonServers' in settings && !Array.isArray(settings.enabledMcpjsonServers)) {
|
|
204
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
205
|
+
}
|
|
206
|
+
if ('permissions' in settings && (settings.permissions === null || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions))) {
|
|
207
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
208
|
+
}
|
|
209
|
+
const permissionsObj = (settings.permissions ?? {});
|
|
210
|
+
if ('allow' in permissionsObj && !Array.isArray(permissionsObj.allow)) {
|
|
211
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
212
|
+
}
|
|
213
|
+
// An array that's the right SHAPE can still hold the wrong ELEMENT types
|
|
214
|
+
// (Codex review, 2026-08-24, round 19): the checks above only confirm
|
|
215
|
+
// "this is an array," not "every element is a string" — `asStringArray`
|
|
216
|
+
// below silently filters out anything else, so a non-string element
|
|
217
|
+
// (e.g. `["real-server", 42]`) would be PERMANENTLY discarded from the
|
|
218
|
+
// operator's own file the moment this module writes it back, despite
|
|
219
|
+
// having nothing to do with what this module owns. Refused here for the
|
|
220
|
+
// same reason a malformed shape is: present-but-invalid content is not
|
|
221
|
+
// this module's to silently normalize away.
|
|
222
|
+
if (Array.isArray(settings.enabledMcpjsonServers) && !settings.enabledMcpjsonServers.every((v) => typeof v === 'string')) {
|
|
223
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
224
|
+
}
|
|
225
|
+
if (Array.isArray(permissionsObj.allow) && !permissionsObj.allow.every((v) => typeof v === 'string')) {
|
|
226
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
227
|
+
}
|
|
228
|
+
// Present-but-NOT-our-own-shape is refused the same way, for the
|
|
229
|
+
// OWNERSHIP MARKER key itself (Codex review, 2026-08-24, round 30): an
|
|
230
|
+
// operator's own unrelated data under `_yolobridge` must never be
|
|
231
|
+
// silently overwritten (write) or deleted (a later detach) just because
|
|
232
|
+
// this module wants to write ITS OWN marker there. A genuinely ABSENT key,
|
|
233
|
+
// or one that already matches this module's own marker shape (a prior,
|
|
234
|
+
// possibly-crashed attach's own record — the legitimate reclaim case
|
|
235
|
+
// round 7 introduced this key for), is the only thing safe to overwrite.
|
|
236
|
+
if (OWNERSHIP_MARKER in settings && !looksLikeOurOwnMarker(settings[OWNERSHIP_MARKER])) {
|
|
237
|
+
return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
|
|
238
|
+
}
|
|
239
|
+
// Consulted BEFORE mutating anything below (Codex review, 2026-08-24,
|
|
240
|
+
// round 7): if the entry is already present but OUR OWN marker from a
|
|
241
|
+
// previous (possibly crashed) attach claims it, this is a reclaim, not a
|
|
242
|
+
// fresh addition or a foreign one -- addedServerEntry/addedPermissionEntry
|
|
243
|
+
// report `true` either way, since both mean "safe for cleanup to remove."
|
|
244
|
+
const priorMarker = readOwnershipMarker(settings);
|
|
245
|
+
const enabled = new Set(asStringArray(settings.enabledMcpjsonServers));
|
|
246
|
+
const addedServerEntry = !enabled.has(SERVER_NAME) || priorMarker.enabledServerEntry === true;
|
|
247
|
+
enabled.add(SERVER_NAME);
|
|
248
|
+
settings.enabledMcpjsonServers = [...enabled];
|
|
249
|
+
const allow = new Set(asStringArray(permissionsObj.allow));
|
|
250
|
+
const addedPermissionEntry = !allow.has(TOOL_PATTERN) || priorMarker.permissionEntry === true;
|
|
251
|
+
allow.add(TOOL_PATTERN);
|
|
252
|
+
permissionsObj.allow = [...allow];
|
|
253
|
+
settings.permissions = permissionsObj;
|
|
254
|
+
const attachId = randomUUID();
|
|
255
|
+
settings[OWNERSHIP_MARKER] = { attachId, enabledServerEntry: addedServerEntry, permissionEntry: addedPermissionEntry };
|
|
256
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
257
|
+
// Atomic (temp file + rename), not a direct overwrite (Codex review,
|
|
258
|
+
// 2026-08-24, round 13): a direct `writeFileSync` on an EXISTING
|
|
259
|
+
// `.claude/settings.local.json` truncates it before writing the new bytes, so
|
|
260
|
+
// ENOSPC or a crash mid-write can leave the OPERATOR's settings
|
|
261
|
+
// half-written — unrecoverable, and settings.local.json can carry far more than
|
|
262
|
+
// just this module's own keys.
|
|
263
|
+
atomicWriteFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
264
|
+
return { ok: true, addedServerEntry, addedPermissionEntry, createdFile, attachId };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Removes only the `yolo-studio` entries THIS attach actually added
|
|
268
|
+
* (`removeServerEntry`/`removePermissionEntry`, from `writeLocalMcpTrust`'s
|
|
269
|
+
* own return) -- never touches any other server name, allow/deny rule,
|
|
270
|
+
* hook, other top-level key, or an entry the operator had already granted
|
|
271
|
+
* before this attach. Deletes `.claude/settings.local.json` only if `createdFile`
|
|
272
|
+
* (also from `writeLocalMcpTrust`'s return) says THIS attachment is the one
|
|
273
|
+
* that created it AND nothing else was ever added to it -- Codex review,
|
|
274
|
+
* 2026-08-24, round 6: emptiness alone isn't proof of that (a pre-existing
|
|
275
|
+
* `{}` looks identical once our entries are removed); a file with other
|
|
276
|
+
* content, or one that already existed before this attach touched it, is
|
|
277
|
+
* left in place, minus just what this attach added.
|
|
278
|
+
*
|
|
279
|
+
* `opts.attachId` (from that same `writeLocalMcpTrust` call) must EXACTLY
|
|
280
|
+
* match the marker CURRENTLY on disk, re-read here, before anything is
|
|
281
|
+
* touched (Codex review, 2026-08-24, round 8) -- decisions captured at
|
|
282
|
+
* write time can go stale by the time cleanup actually runs: the operator
|
|
283
|
+
* may have edited or removed the marker mid-session (a signal to leave
|
|
284
|
+
* their grants alone, not something a blind boolean-driven delete should
|
|
285
|
+
* override), or a second concurrent attach in the same directory may have
|
|
286
|
+
* overwritten it with its own. A mismatched or missing marker means this
|
|
287
|
+
* attach can no longer prove the current state is provably its own, so it
|
|
288
|
+
* fails closed and leaves everything untouched rather than risk deleting
|
|
289
|
+
* something it doesn't actually own anymore.
|
|
290
|
+
*/
|
|
291
|
+
export function removeLocalMcpTrust(cwd, opts) {
|
|
292
|
+
const path = settingsPath(cwd);
|
|
293
|
+
if (!existsSync(path))
|
|
294
|
+
return;
|
|
295
|
+
let settings;
|
|
296
|
+
try {
|
|
297
|
+
settings = readSettings(path);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const currentMarker = readOwnershipMarker(settings);
|
|
303
|
+
const stillOurs = opts.attachId !== undefined && currentMarker.attachId === opts.attachId;
|
|
304
|
+
// A mismatched/missing marker means this call can no longer prove the
|
|
305
|
+
// current state is provably its own (edited/removed mid-session, or
|
|
306
|
+
// clobbered by a concurrent attach) -- touch NOTHING, not even a
|
|
307
|
+
// reformatting rewrite of otherwise-unchanged content.
|
|
308
|
+
if (!stillOurs)
|
|
309
|
+
return;
|
|
310
|
+
// Present-but-invalid is refused, same as the write path (Codex review,
|
|
311
|
+
// 2026-08-24, round 22): the content checks below only catch a
|
|
312
|
+
// WRONG-SHAPE ARRAY (a non-string element) -- they never fire when the
|
|
313
|
+
// value isn't an array AT ALL (hand-edited to a string/number, or
|
|
314
|
+
// `permissions` itself replaced with a non-object). `asStringArray`
|
|
315
|
+
// silently coerces any non-array to `[]`, so `enabled.length > 0` would
|
|
316
|
+
// be false below and this function would `delete` the operator's field
|
|
317
|
+
// entirely -- exactly the "cleanup destroys something it doesn't
|
|
318
|
+
// provably still own" failure this function's own doc comment exists to
|
|
319
|
+
// prevent. Only genuinely ABSENT fields are left to default to empty.
|
|
320
|
+
if ('enabledMcpjsonServers' in settings && !Array.isArray(settings.enabledMcpjsonServers))
|
|
321
|
+
return;
|
|
322
|
+
if ('permissions' in settings && (settings.permissions === null || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)))
|
|
323
|
+
return;
|
|
324
|
+
const permissionsForShapeCheck = (settings.permissions ?? {});
|
|
325
|
+
if ('allow' in permissionsForShapeCheck && !Array.isArray(permissionsForShapeCheck.allow))
|
|
326
|
+
return;
|
|
327
|
+
// Same refuse-rather-than-silently-normalize check as the write path
|
|
328
|
+
// (Codex review, 2026-08-24, round 19): the operator could have
|
|
329
|
+
// hand-edited a non-string element into one of these arrays between
|
|
330
|
+
// attach and detach; `asStringArray` below would otherwise drop it
|
|
331
|
+
// permanently the moment this function writes the file back.
|
|
332
|
+
if (Array.isArray(settings.enabledMcpjsonServers) && !settings.enabledMcpjsonServers.every((v) => typeof v === 'string'))
|
|
333
|
+
return;
|
|
334
|
+
if (settings.permissions && typeof settings.permissions === 'object' && !Array.isArray(settings.permissions) &&
|
|
335
|
+
Array.isArray(settings.permissions.allow) &&
|
|
336
|
+
!settings.permissions.allow.every((v) => typeof v === 'string'))
|
|
337
|
+
return;
|
|
338
|
+
if (opts.removeServerEntry) {
|
|
339
|
+
const enabled = asStringArray(settings.enabledMcpjsonServers).filter((s) => s !== SERVER_NAME);
|
|
340
|
+
if (enabled.length > 0)
|
|
341
|
+
settings.enabledMcpjsonServers = enabled;
|
|
342
|
+
else
|
|
343
|
+
delete settings.enabledMcpjsonServers;
|
|
344
|
+
}
|
|
345
|
+
const permissions = (settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : undefined);
|
|
346
|
+
if (permissions && opts.removePermissionEntry) {
|
|
347
|
+
const allow = asStringArray(permissions.allow).filter((p) => p !== TOOL_PATTERN);
|
|
348
|
+
if (allow.length > 0)
|
|
349
|
+
permissions.allow = allow;
|
|
350
|
+
else
|
|
351
|
+
delete permissions.allow;
|
|
352
|
+
if (Object.keys(permissions).length > 0)
|
|
353
|
+
settings.permissions = permissions;
|
|
354
|
+
else
|
|
355
|
+
delete settings.permissions;
|
|
356
|
+
}
|
|
357
|
+
// Never leave the ownership marker behind — it's internal bookkeeping,
|
|
358
|
+
// not something the operator should see lingering in their settings once
|
|
359
|
+
// this attach is done with it (Codex review, 2026-08-24, round 7).
|
|
360
|
+
delete settings[OWNERSHIP_MARKER];
|
|
361
|
+
if (opts.createdFile && Object.keys(settings).length === 0) {
|
|
362
|
+
// `unlinkWriteTarget`, not a bare `unlinkSync(path)` (Codex review,
|
|
363
|
+
// 2026-08-24, round 26) — same reasoning as local-mcp-config.ts's
|
|
364
|
+
// identical fix: `path` can be a symlink `atomicWriteFileSync` healed.
|
|
365
|
+
unlinkWriteTarget(path);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// Atomic — see the doc comment on the equivalent write in
|
|
369
|
+
// `writeLocalMcpTrust` (Codex review, 2026-08-24, round 13).
|
|
370
|
+
atomicWriteFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
371
|
+
}
|
package/dist/login-cmd.js
CHANGED
|
@@ -14,13 +14,21 @@ export async function runLogin(deps) {
|
|
|
14
14
|
const openBrowser = deps.openBrowser ?? openBrowserBestEffort;
|
|
15
15
|
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
16
16
|
const code = await requestDeviceCode(authBaseUrl, fetchImpl);
|
|
17
|
+
// auth-service's own device/code response has no query string on
|
|
18
|
+
// verification_uri (see device-auth.ts's header comment) -- but the
|
|
19
|
+
// webapp's /device page (webapp/app/device/page.tsx) DOES read a `code`
|
|
20
|
+
// query param and pre-fills the input from it, a convention this CLI
|
|
21
|
+
// simply wasn't using. Building the pre-filled URL is client-side only
|
|
22
|
+
// (no auth-service change needed) -- the user still has to click
|
|
23
|
+
// Approve, so this doesn't skip any consent step, it just saves them
|
|
24
|
+
// re-typing a code they can already see in this terminal.
|
|
25
|
+
const prefilledUri = `${code.verificationUri}${code.verificationUri.includes('?') ? '&' : '?'}code=${encodeURIComponent(code.userCode)}`;
|
|
17
26
|
log('To finish logging in, open this URL in your browser:');
|
|
18
|
-
log(` ${
|
|
19
|
-
log('
|
|
20
|
-
log(` ${code.userCode}`);
|
|
27
|
+
log(` ${prefilledUri}`);
|
|
28
|
+
log(`(the code ${code.userCode} is pre-filled -- if it doesn't open automatically, enter it by hand)`);
|
|
21
29
|
log('');
|
|
22
30
|
log('Waiting for approval...');
|
|
23
|
-
openBrowser(
|
|
31
|
+
openBrowser(prefilledUri);
|
|
24
32
|
const deadline = Date.now() + code.expiresInSec * 1000;
|
|
25
33
|
const intervalMs = Math.max(1, code.intervalSec) * 1000;
|
|
26
34
|
while (Date.now() < deadline) {
|