-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient_detach_disk.js
More file actions
1905 lines (1777 loc) · 77.3 KB
/
Copy pathclient_detach_disk.js
File metadata and controls
1905 lines (1777 loc) · 77.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ts-check
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { captureSpoolRoot, isCaptureSpoolDir, sweepCaptureSpool } from '../capture_spool.js'
import { resolveClientSettingsPath } from '../daemon/client_settings_path.js'
import { removeLaunchdEnv } from '../daemon/launchd_env.js'
import { Attr, getLogger } from '../observability/index.js'
import { readObservabilityEnv } from '../observability/env.js'
import { ConcurrentEditError, atomicWriteFile } from '../util/fs_atomic.js'
import { errCode, getAtDottedPath, isPlainObject, redactUrlUserinfo } from '../util/json_util.js'
import { isOwnedProviderEntry } from './provider_entry_ownership.js'
// Bump when an on-disk Claude marker must be rewritten for Claude Code to
// accept or correctly interpret settings.json. Both daemon reconciliation and
// manual attach use this token to force exactly one migration pass.
export const CLAUDE_SETTINGS_MARKER_SCHEMA = 3
/**
* @import { Dirent } from 'node:fs'
* @import { ClientDescriptor } from '../../../src/core/types.js'
* @import { DetachFromDiskResult } from '../../../src/core/config/types.js'
* @import { TrustCommandRunner } from '../../../src/core/tls/types.js'
*/
/**
* The single core undo: the disk-driven, plugin-agnostic reverse of a
* client's attach. It is the *one* detach implementation: both the reconciler's
* `reverse()` (a fleet-config drop, fired only after the staged restart has
* already unloaded the adapter) and the manual `hyp detach` command route
* through it, so there is no second implementation to drift from.
*
* Reverse runs from **disk state alone**: the descriptor's `attachProbe`
* locates the settings file, and the client's own settings-file marker is a
* **self-describing undo record** that `attach()` wrote (LLP 0045 §Part 3). The
* routine is **format-aware but plugin-agnostic**: it understands `json`
* (marker-key) and `toml` (managed-block): the same dispatch
* `probeClientAttached` uses on the *read* side, and how to replay an undo
* record, never "Claude" vs "Codex". It imports no plugin code (which would not
* survive the plugin being unloaded), subsuming what the adapters' old
* `detach()` did: including the Codex `# BEGIN/END hypaware …` marked-block
* strip and prior-`model_provider` restore. The managed-block convention is
* therefore a **core-understood format contract**, not a codex-private detail.
*
* @ref LLP 0045#part-3-reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record [implements]: one core/disk-driven undo, format-aware (json marker-key / toml managed-block), plugin-agnostic, reusing resolveClientSettingsPath + the probeClientAttached format dispatch
* @ref LLP 0044#conflict-back-up--override-restore-on-leave [constrained-by]: the marker is the backup; reverse restores it (or removes the managed value) on leave
*/
// Dotted-path segments the restore helper below refuses to walk. Every
// dotted path here comes off disk: the `prev_malformed` keys of a marker's
// undo record are named by a settings file a hand-edit can reach. The
// helper walks with plain `parent[segment]`, so a `__proto__` segment would
// leave the document and land on `Object.prototype`, assigning there since
// the restore helper creates the parents it walks. No attach records such a
// path, so refusing costs nothing real.
const UNWRITABLE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
/**
* Whether a dotted path names a segment {@link UNWRITABLE_PATH_SEGMENTS}
* refuses. Split from the restore helper so a caller can tell a *policy*
* refusal apart from a path it merely could not reach, and report the right
* reason.
*
* @ref LLP 0163#detach-restores-the-backup [implements]: one predicate for the path writer, so a policy refusal is a reason the caller can name
* @param {string} dottedPath
* @returns {boolean}
*/
function hasUnwritableSegment(dottedPath) {
return dottedPath.split('.').some((segment) => UNWRITABLE_PATH_SEGMENTS.has(segment))
}
const TOML_MANAGED_BEGIN = '# BEGIN hypaware'
const TOML_MANAGED_END = '# END hypaware'
const TOML_PREVIOUS_KEY = 'previous_model_provider'
const TOML_ROOT_RESTORE_KEY = 'model_provider'
const TOML_MANAGED_BASE_URL_KEY = 'base_url'
const TOML_BASIC_MULTILINE_DELIMITER = '"""'
const TOML_LITERAL_MULTILINE_DELIMITER = '\'\'\''
const TOML_KEY_PART = String.raw`(?:"(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_-]+)`
const TOML_DOTTED_KEY = String.raw`${TOML_KEY_PART}(?:\s*\.\s*${TOML_KEY_PART})*`
const TOML_TABLE_HEADER_RE = new RegExp(String.raw`^\s*\[\s*${TOML_DOTTED_KEY}\s*\]\s*(?:#.*)?$`)
const TOML_TABLE_ARRAY_HEADER_RE = new RegExp(String.raw`^\s*\[\[\s*${TOML_DOTTED_KEY}\s*\]\]\s*(?:#.*)?$`)
const TOML_ROOT_MODEL_PROVIDER_RE = new RegExp(
String.raw`^\s*(?:${TOML_ROOT_RESTORE_KEY}|"${TOML_ROOT_RESTORE_KEY}"|'${TOML_ROOT_RESTORE_KEY}')\s*=`
)
export class ClientDetachError extends Error {
/**
* @param {string} message
* @param {{ code?: string, cause?: unknown }} [opts]
*/
constructor(message, opts = {}) {
super(message)
this.name = 'ClientDetachError'
/** @type {string | undefined} */
this.code = opts.code
if (opts.cause !== undefined) {
/** @type {unknown} */
this.cause = opts.cause
}
}
}
/**
* Reverse a client's attach from disk, driven by the descriptor's
* `attachProbe` and the settings-file marker. No-op (`{ changed: false }`) when
* the descriptor has no probe, the file is absent, or it carries no marker.
*
* Every format's undo record lives in the settings file itself: a marker key
* (`json`), a managed block (`toml`), or the self-identifying signature on the
* entries attach wrote (`json_path`, LLP 0210). So this routine needs nothing
* from a running daemon, and works identically whether the gateway is alive,
* stopped, or already uninstalled.
*
* @param {{
* descriptor: ClientDescriptor,
* homeDir?: string,
* env?: NodeJS.ProcessEnv,
* fs?: typeof fsp,
* platform?: NodeJS.Platform,
* runCommand?: TrustCommandRunner,
* }} args
* @returns {Promise<DetachFromDiskResult>}
*/
export async function detachClientFromDisk({
descriptor,
homeDir = os.homedir(),
env,
fs = fsp,
platform = process.platform,
runCommand,
}) {
const probe = descriptor.attachProbe
if (!probe) return { changed: false }
const settingsPath = resolveClientSettingsPath(descriptor.name, probe.settings_file, env, homeDir)
if (probe.format === 'json' && probe.marker_key) {
return await detachJsonMarker({
settingsPath,
markerKey: probe.marker_key,
fs,
env,
homeDir,
platform,
runCommand,
})
}
if (probe.format === 'toml') {
return await detachTomlManagedBlock({ settingsPath, fs })
}
if (probe.format === 'managed_file' && probe.marker_text) {
return await detachManagedFile({ settingsPath, markerText: probe.marker_text, fs })
}
// @ref LLP 0172#lane-a-detach [implements]: the json_path branch LLP 0143 removed returns, reshaped for two provider entries plus a cache purge
if (probe.format === 'json_path') {
return await detachJsonPathProviders({
settingsPath,
settingsFile: probe.settings_file,
containerPath: probe.container_path,
providerKeys: probe.provider_keys,
markerHeader: probe.marker_header,
cacheGlob: probe.cache_glob,
fs,
})
}
// Unknown/incomplete probe: nothing this core routine knows how to reverse.
return { changed: false, settingsPath }
}
/**
* Remove a whole managed file only while its ownership marker is intact.
* A user replacement at the same path is left in place and reported.
*
* @param {{ settingsPath: string, markerText: string, fs: typeof fsp }} args
* @returns {Promise<DetachFromDiskResult>}
* @ref LLP 0306#managed-plugin-file [implements]: detach removes only the
* still-marked HypAware plugin file
*/
async function detachManagedFile({ settingsPath, markerText, fs }) {
let raw
try {
raw = await fs.readFile(settingsPath, 'utf8')
} catch (err) {
if (errCode(err) === 'ENOENT') return { changed: false, settingsPath }
throw err
}
if (!raw.includes(markerText)) {
return {
changed: false,
settingsPath,
warning: 'managed file ownership marker is missing; leaving file in place',
}
}
await fs.unlink(settingsPath)
return { changed: true, settingsPath }
}
/* ------------------------------- JSON format ------------------------------ */
/**
* Reverse a `json` marker-key attach (e.g. Claude's `_hypaware`). Replays the
* self-describing undo record: restore-or-remove each managed `env` key,
* strip the recorded managed hook entries (leaving no orphaned `hyp …` hooks),
* and delete the marker.
*
* @param {{
* settingsPath: string,
* markerKey: string,
* fs: typeof fsp,
* env?: NodeJS.ProcessEnv,
* homeDir?: string,
* platform?: NodeJS.Platform,
* runCommand?: TrustCommandRunner,
* }} args
* @returns {Promise<DetachFromDiskResult>}
*/
async function detachJsonMarker({ settingsPath, markerKey, fs, env, homeDir, platform, runCommand }) {
const read = await readJson(settingsPath, fs)
if (!read.existed) return { changed: false, settingsPath }
const value = read.value
const marker = value[markerKey]
if (!isPlainObject(marker)) return { changed: false, settingsPath }
// Pre-upgrade markers have the legacy shape {attached_at,version,port,
// state_file} with no self-describing `managed` undo record. There is no
// record to replay, so reverse them by the original (now-retired) convention
// instead of just deleting the marker, otherwise env.ANTHROPIC_BASE_URL and
// the `hyp claude-hook session-context` entries it wrote would orphan, and the
// detach is non-retryable once the marker is gone.
// @ref LLP 0045#part-3-reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record [constrained-by]: legacy markers predate the undo record; fall back to the convention attach used before it
if (!isPlainObject(marker.managed)) {
return await detachLegacyJsonMarker({
settingsPath,
markerKey,
value,
marker,
mtimeMs: read.mtimeMs,
fs,
env,
homeDir,
platform,
runCommand,
})
}
const managed = isPlainObject(marker.managed) ? marker.managed : {}
const managedEnv = isPlainObject(managed.env) ? managed.env : {}
// Claude Code 2.1.257 reserves `hooks` throughout settings.json, so current
// markers use `hook_entries`. Keep reading the original field so an upgrade
// can still detach settings written by every earlier HypAware release.
const hookEntries = collectManagedHookEntries(managed)
// Presence, not type: the restore half of the attach-side backup. Attach only
// ever writes this field when there was a prior value to record, so the field
// being there IS the "restore me" fact and its JSON type says nothing. A type
// test threw away a backup the marker was holding and fell through to the
// delete branch below - the one outcome the backup exists to prevent.
const prevBaseUrl = Object.hasOwn(marker, 'prev_base_url')
? decodeBackupValue(marker.prev_base_url, marker.prev_base_url_encoding)
: undefined
// The general form of the same backup, keyed by env name. Proxy-mode attach
// takes over `HTTPS_PROXY`, which - unlike the add-only keys - routinely
// already holds a corporate proxy the user needs back. `prev_base_url` stays
// the special case it always was so markers written before `prev_env` existed
// still restore.
// @ref LLP 0232#detach-restores-any-managed-key [implements]: any managed env key can carry a backup, not just the base URL
const prevEnv = decodeBackupMap(marker.prev_env, marker.prev_env_encoding)
delete value[markerKey]
stripManagedHooks(value, hookEntries)
/** @type {string | undefined} */
let removed
/** @type {string | undefined} */
let restoredValue
// One notice per externally-overridden key: a single reassigned string would
// report only the last one, hiding the earlier keys we left in place.
// @ref LLP 0045#never-clobber-a-user-edit-report-every-override-not-just-the-last [implements]: accumulate the per-key notices, join them into the one `warning` field
/** @type {string[]} */
const warnings = []
if (isPlainObject(value.env)) {
const envObj = /** @type {Record<string, unknown>} */ (value.env)
for (const [key, ourVal] of Object.entries(managedEnv)) {
const current = envObj[key]
if (current === ourVal) {
// The value we wrote is still live, so this key is ours to give back.
// A per-key backup in `prev_env` wins; `prev_base_url` remains the
// restore target for ANTHROPIC_BASE_URL when no per-key backup exists.
// Keys attach only ever *added* when absent (e.g. ENABLE_TOOL_SEARCH)
// have neither, and are removed rather than restored - stamping a
// backup onto them would invent a value the user never set.
//
// Presence, not type, again: `prev_env` records a hand-written `null`
// as a value to hand back.
/** @type {unknown} */
let restore
if (prevEnv && Object.hasOwn(prevEnv, key)) restore = prevEnv[key]
else if (key === 'ANTHROPIC_BASE_URL') restore = prevBaseUrl
if (restore !== undefined) {
envObj[key] = restore
// `restoredValue` is a single display field, so it reports the key a
// user would ask about: the one that decided where their client
// pointed.
//
// Redacted for the display copy only. The write above put the user's
// true value back on disk; this string is printed by `hyp detach` and
// `hyp daemon uninstall` and serialised as `restored_value`, and the
// key it most often describes is a corporate `HTTPS_PROXY` carrying
// `user:pass@`. Handing a credential back to its owner is the point
// of the restore; echoing it is not.
if (key === 'ANTHROPIC_BASE_URL' || key === 'HTTPS_PROXY') {
const shown = typeof restore === 'string' ? restore : String(restore)
restoredValue = redactUrlUserinfo(shown)
}
} else {
if (key === 'ANTHROPIC_BASE_URL' || key === 'HTTPS_PROXY') {
// Our own `http://127.0.0.1:<port>` in every real case, so there is
// nothing to hide; redacted anyway so no path out of this function
// is the one that has to be remembered.
removed = redactUrlUserinfo(typeof current === 'string' ? current : String(current))
}
delete envObj[key]
}
} else if (Object.hasOwn(envObj, key)) {
// Overridden externally after we attached - never clobber a user edit.
//
// Presence, not type, decides that a key was left in place: the same
// rule the attach-side ownership guard follows. A user who hand-edited
// our `ENABLE_TOOL_SEARCH` to a JSON boolean still has a key sitting on
// disk after a detach that reports success, which is exactly the case
// this notice exists to tell them about; a type test would swallow it.
// A key they deleted outright is absent, so nothing was left in place
// and nothing is reported - which is why this is a presence test and
// not a bare `else`.
//
// `Object.hasOwn`, not `key in`: this loop's keys come off disk, from
// whatever `managed.env` a plugin's attach recorded, so an inherited
// `Object.prototype` name (`toString`, `constructor`) would satisfy
// `in` and report a key that is not on disk at all - the exact false
// report the presence test exists to prevent. The attach-side guard
// can use `in` because its keys are in-tree literals.
warnings.push(`${key} was overridden externally; leaving in place`)
}
}
if (Object.keys(envObj).length === 0) delete value.env
}
const restoredPaths = replayPrevMalformed(
value,
marker.prev_malformed,
marker.prev_malformed_encoding,
warnings
)
await writeJsonAtomic(settingsPath, value, read.mtimeMs, fs)
// A proxy-mode attach set `NODE_USE_SYSTEM_CA=1` in the launchd user
// environment; release it here, in the same disk-driven undo, because the
// variable follows the attach - it is re-appliable silently, unlike the CA
// and its keychain trust, which stay so the user's one password-dialog
// grant survives the cycle. Done after the settings write: if this fails,
// the client is already un-attached and safe.
// @ref LLP 0238#ca-survives-detach [implements]: the CA is deliberately NOT deleted here
// @ref LLP 0239#launchctl-setenv [implements]: detach reverses the launchd env
await releaseProxyModeLaunchdEnv({ marker, homeDir, warnings, platform, runCommand })
// And the body spool an `otel`-mode attach pointed the client at. Same
// ordering rule: the settings write has landed, so the client is no longer
// producing bodies, and a sweep that fails leaves a warning rather than an
// un-detached client.
await sweepMarkerSpool({ marker, env, fs, warnings })
const warning = joinWarnings(warnings)
/** @type {DetachFromDiskResult} */
const result = { changed: true, settingsPath }
if (removed !== undefined) result.removed = removed
if (restoredValue !== undefined) result.restoredValue = restoredValue
if (restoredPaths.length > 0) result.restoredPaths = restoredPaths
if (warning !== undefined) result.warning = warning
return result
}
/**
* Put back the blocks attach had to rebuild because what was on disk was
* present with the wrong JSON type. `prev_malformed` is path-keyed
* (`env`, `hooks`, `hooks.<event>`), so the replay is the same format-generic
* shape as the rest of the record: core never learns that `hooks.SessionStart`
* means anything to Claude.
*
* Same never-clobber rule the managed env keys follow, expressed as a presence
* test: the backup only goes back into a slot that is now *empty*, which is
* exactly the case where everything attach put there has just been stripped.
* Anything still sitting at the path arrived after we attached, so it is left
* alone and reported instead.
*
* Every failure notice says the backup is *discarded*, not merely skipped. The
* marker is deleted by the caller in the same write and it held the only copy,
* so a detach that cannot restore is the moment the value stops existing.
* "Leaving it in place" on its own reads as though the record survives to be
* retried, and it does not; the user who reads this line is the last person who
* can act on it.
*
* Shared by both `json` branches. The record-driven undo is not the only way to
* reach a marker carrying this field: a marker whose `managed` record has been
* damaged routes to {@link detachLegacyJsonMarker}, which used to drop the
* whole backup without a word (#500 finding 1). One replay means one set of
* words for both.
*
* Returns the paths whose backup actually went back, so the caller can *report*
* a restore that succeeded. Paths only, never values: a malformed `env` is
* exactly where an API key ends up, and this list is printed to the terminal
* and echoed into `--json` (LLP 0163).
*
* @ref LLP 0163#detach-restores-the-backup [implements]: replay prev_malformed shallowest-first, restoring only into a slot the strip emptied, and report both halves by path
* @param {Record<string, unknown>} value
* @param {unknown} recorded the marker's `prev_malformed` field, whatever type it is on disk
* @param {unknown} encoding the marker's value encoding, absent on legacy raw-value records
* @param {string[]} warnings accumulator for the per-path failure notices
* @returns {string[]}
*/
function replayPrevMalformed(value, recorded, encoding, warnings) {
const prevMalformed = decodePrevMalformed(recorded, encoding)
/** @type {string[]} */
const restoredPaths = []
// Shallowest first, so a `hooks` backup is considered before any
// `hooks.<event>` backup nested inside it. Not because the parent has to exist
// first - `restoreAtDottedPath` recreates a missing parent either way - but
// because when both are recorded they cannot both go back, and the order is
// what picks the winner.
//
// Which one it *should* pick is a question depth cannot answer: the shallower
// entry is the older one in one recording sequence and the newer one in the
// other, so neither direction implements "the earliest backup holds the user's
// content". The record carries no age, so the sort is an arbitrary but stable
// tiebreak and the loser is reported. See LLP 0163.
for (const dotted of Object.keys(prevMalformed).sort((a, b) => pathDepth(a) - pathDepth(b))) {
// Reported before the in-use test, so a path this undo refuses on principle
// is never explained as somebody else's key sitting in the way.
if (hasUnwritableSegment(dotted)) {
warnings.push(
`${dotted} could not be restored; ` +
'its path is not one this undo may write, so the backed-up value is discarded with the marker'
)
continue
}
if (getAtDottedPath(value, dotted) !== undefined) {
warnings.push(
`${dotted} is in use again; leaving it in place, and the backed-up value is discarded with the marker`
)
continue
}
// The remaining failure is a parent that is present as a non-object, which
// is the one this branch actually hits in practice: a `hooks` backup that
// went back first is a string, and the `hooks.<event>` backup nested inside
// it now has nowhere to go. Naming that cause matters - the earlier wording
// reported it as a path the undo may not write, which is both false and
// unactionable for the one person who can still act on it.
if (restoreAtDottedPath(value, dotted, prevMalformed[dotted])) {
restoredPaths.push(dotted)
} else {
warnings.push(
`${dotted} could not be restored; ` +
'a parent on its path is no longer a JSON object, ' +
'so the backed-up value is discarded with the marker'
)
}
}
return restoredPaths
}
/**
* Decode malformed-block backups written as JSON strings. Claude Code 2.1.257
* treats every structural `hooks` key in settings.json as hook configuration,
* including keys inside HypAware's undo marker, so current markers serialize
* each backed-up value instead of embedding it as a traversable object.
*
* Legacy markers have no encoding field and keep their raw-value behavior.
* The short-lived schema-2 object form declared every entry encoded, so its
* compatibility read decodes every entry; that format had no per-entry tag.
*
* @param {unknown} recorded
* @param {unknown} encoding
* @returns {Record<string, unknown>}
*/
function decodePrevMalformed(recorded, encoding) {
return decodeBackupMap(recorded, encoding) ?? {}
}
/**
* Decode one backup value from the marker. Current markers serialize values
* so an arbitrary object containing `hooks` cannot be interpreted as Claude
* hook configuration; legacy markers keep their raw-value behavior.
*
* @param {unknown} recorded
* @param {unknown} encoding
* @returns {unknown}
*/
function decodeBackupValue(recorded, encoding) {
if (encoding !== 'json' || typeof recorded !== 'string') return recorded
try {
return JSON.parse(recorded)
} catch {
return recorded
}
}
/**
* Decode a marker backup map serialized as one scalar JSON value.
*
* @param {unknown} recorded
* @param {unknown} encoding
* @returns {Record<string, unknown> | undefined}
*/
function decodeBackupMap(recorded, encoding) {
if (encoding !== 'json') {
return isPlainObject(recorded) ? recorded : undefined
}
// Current markers serialize the whole map so neither a value containing a
// nested `hooks` property nor a key literally named `hooks` remains
// structural in Claude's settings file.
if (typeof recorded === 'string') {
try {
const decoded = JSON.parse(recorded)
return isPlainObject(decoded) ? decoded : undefined
} catch {
return undefined
}
}
// Compatibility with the short-lived per-value representation written by
// earlier builds carrying this schema token.
if (!isPlainObject(recorded)) return undefined
/** @type {Record<string, unknown>} */
const decoded = {}
for (const [dotted, serialized] of Object.entries(recorded)) {
decoded[dotted] = decodeBackupValue(serialized, encoding)
}
return decoded
}
/**
* Fold the per-key never-clobber notices into the single
* `DetachFromDiskResult.warning` string, `undefined` when there are none.
*
* The field stays one human-readable string - it is displayed, never parsed
* (`action_attach.js` logs it as a span `detail`; `hyp detach` prints it and
* echoes it into the `--json` payload) - but it now carries every key the undo
* left in place, not just whichever one the loop happened to visit last.
*
* The separator is ` | `, NOT `; `: every notice already contains a `; ` of its
* own ("... overridden externally; leaving in place"), so joining on `; ` would
* make the notice boundaries indistinguishable from the punctuation inside a
* notice. No in-tree attach records a managed env key or a dotted set path
* containing `|`, so ` | ` reads unambiguously for the notices joined here.
*
* That is a READABILITY choice, not a parseable framing. The field is shared
* with `detachTomlManagedBlock`, whose single notice interpolates the user's
* live `model_provider` value and can therefore contain ` | ` itself. Callers
* display `warning`; they must not split it. See `DetachFromDiskResult`.
*
* @param {string[]} warnings
* @returns {string | undefined}
*/
function joinWarnings(warnings) {
return warnings.length === 0 ? undefined : warnings.join(' | ')
}
/**
* Read both hook-entry field names from a marker and deduplicate valid entries.
* A partially migrated marker can contain both; preferring one field would
* orphan handlers named only by the other when detach deletes the marker.
*
* @param {Record<string, unknown>} managed
* @returns {unknown[]}
*/
function collectManagedHookEntries(managed) {
/** @type {unknown[]} */
const entries = []
const seen = new Set()
const sources = [managed.hook_entries, managed.hooks]
for (const source of sources) {
if (!Array.isArray(source)) continue
for (const entry of source) {
if (!isPlainObject(entry) || typeof entry.event !== 'string' || typeof entry.command !== 'string') {
continue
}
const matcher = typeof entry.matcher === 'string' ? entry.matcher : undefined
const identity = JSON.stringify([entry.event, matcher, entry.command])
if (seen.has(identity)) continue
seen.add(identity)
entries.push(entry)
}
}
return entries
}
/**
* Strip the managed hook entries the marker recorded: matching each by its
* `event` / `matcher` / exact `command`, so only the handlers this attach
* installed are removed and no orphaned `hyp …` hooks survive. Empty groups
* and empty event arrays are pruned; an emptied `hooks` root is deleted.
*
* @param {Record<string, unknown>} value
* @param {unknown[]} hookEntries
*/
function stripManagedHooks(value, hookEntries) {
const hooksRoot = value.hooks
if (!isPlainObject(hooksRoot)) return
for (const entry of hookEntries) {
if (!isPlainObject(entry)) continue
const event = typeof entry.event === 'string' ? entry.event : undefined
const command = typeof entry.command === 'string' ? entry.command : undefined
if (event === undefined || command === undefined) continue
const matcher = typeof entry.matcher === 'string' ? entry.matcher : undefined
const groups = hooksRoot[event]
if (!Array.isArray(groups)) continue
/** @type {unknown[]} */
const nextGroups = []
for (const group of groups) {
if (!isPlainObject(group) || !groupMatcherEquals(group, matcher) || !Array.isArray(group.hooks)) {
nextGroups.push(group)
continue
}
const handlers = group.hooks
const keptHandlers = handlers.filter((h) => !isManagedHandler(h, command))
if (keptHandlers.length === handlers.length) {
nextGroups.push(group) // nothing matched - leave the group untouched
} else if (keptHandlers.length > 0) {
nextGroups.push({ ...group, hooks: keptHandlers })
}
// else: the group held only the managed handler, drop it entirely.
}
if (nextGroups.length > 0) {
hooksRoot[event] = nextGroups
} else {
delete hooksRoot[event]
}
}
if (Object.keys(hooksRoot).length === 0) delete value.hooks
}
/**
* @param {Record<string, unknown>} group
* @param {string | undefined} matcher
*/
function groupMatcherEquals(group, matcher) {
const groupMatcher = typeof group.matcher === 'string' ? group.matcher : undefined
return groupMatcher === matcher
}
/**
* @param {unknown} handler
* @param {string} command
*/
function isManagedHandler(handler, command) {
return isPlainObject(handler) && handler.type === 'command' && handler.command === command
}
/* ----------------------------- legacy JSON marker ---------------------------- */
// Both `hyp claude-hook` sub-commands attach has ever installed. `classify-cwd`
// (LLP 0106) postdates the `managed` undo record, so a *genuine* pre-record
// marker never had one to orphan - but this branch is also where a marker whose
// record has been damaged lands, and there the entries are on disk and the
// record naming them is unreadable. Matching the command is proof of ownership
// (nothing but hypaware writes `hyp claude-hook …`), so widening the pattern
// cannot clobber a user's own hook. Kept in step with the adapter's
// `MANAGED_HOOK_PATTERN`.
// @ref LLP 0163#the-legacy-branch-replays-every-backup-the-marker-carries [implements]: match on the command, which is proof of ownership, so a damaged-record detach leaves no classify-cwd hook orphaned
const LEGACY_CLAUDE_HOOK_PATTERN = /\bclaude-hook\s+(?:session-context|classify-cwd)\b/
// Marker fields no pre-record marker ever carried. Any of them present means
// this is a *current*-shape marker whose `managed` record has been damaged (a
// hand edit, or anything else with write access to the settings file), not the
// pre-upgrade shape this branch was written for. The reversal below is then
// knowingly partial - the record that named the managed keys is unreadable - so
// it says so instead of reporting a clean detach.
const POST_LEGACY_MARKER_FIELDS = [
'managed',
'prev_base_url',
'prev_base_url_encoding',
'prev_env',
'prev_env_encoding',
'prev_malformed',
'mode',
]
/**
* Reverse a pre-upgrade legacy `json` marker: the old Claude marker shape
* `{attached_at,version,port,state_file}` that predates the self-describing
* `managed` undo record. We can't replay a record the marker never wrote, so we
* fall back to the convention `attach()` used before the record existed:
* remove `env.ANTHROPIC_BASE_URL` only when it still equals the recorded
* `http://127.0.0.1:${port}` gateway URL (never clobbering a later user edit),
* and strip the managed hooks by their {@link LEGACY_CLAUDE_HOOK_PATTERN}
* command pattern. Legacy JSON markers were only ever written by Claude, so the
* key/pattern are safe to assume here. Moved from the retired claude-adapter
* `detach()` so the one core undo owns this reversal too.
*
* It is also where a **current**-shape marker lands once its `managed` record
* has been damaged - only reachable by hand-editing (or otherwise corrupting)
* the record out of a marker, never through attach/re-attach/detach. That case
* is not the one this branch was written for, and the difference is visible:
* such a marker still carries its `prev_base_url` / `prev_malformed` backups.
* Those are replayed here exactly as the record-driven branch replays them -
* the marker is deleted in the same write and holds the only copy, so dropping
* them is destruction, not a deferral (#500 finding 1). What cannot be replayed
* (the managed keys the unreadable record named) is reported instead of quietly
* left behind.
*
* @param {{
* settingsPath: string,
* markerKey: string,
* value: Record<string, unknown>,
* marker: Record<string, unknown>,
* mtimeMs: number | undefined,
* fs: typeof fsp,
* env?: NodeJS.ProcessEnv,
* homeDir?: string,
* platform?: NodeJS.Platform,
* runCommand?: TrustCommandRunner,
* }} args
* @returns {Promise<DetachFromDiskResult>}
*/
async function detachLegacyJsonMarker({ settingsPath, markerKey, value, marker, mtimeMs, fs, env, homeDir, platform, runCommand }) {
const markerPort = typeof marker.port === 'number' ? marker.port : undefined
// Read every backup the marker carries BEFORE it is deleted. A genuine
// pre-record marker has none of these and nothing changes for it. A marker
// that got here with them is a damaged current-shape one, and dropping a
// backup it is holding is the single worst thing this undo can do: the marker
// is the only copy, so "no record to replay" was silently destroying the
// user's value while reporting a successful detach (#500 finding 1).
// Presence, not type, for the same reason the record-driven branch uses it:
// attach only writes these when there was something to record.
// @ref LLP 0044#conflict-back-up--override-restore-on-leave [constrained-by]: the marker IS the backup, on every branch that deletes it
const prevBaseUrl = Object.hasOwn(marker, 'prev_base_url')
? decodeBackupValue(marker.prev_base_url, marker.prev_base_url_encoding)
: undefined
const prevEnv = decodeBackupMap(marker.prev_env, marker.prev_env_encoding)
const recordDamaged = POST_LEGACY_MARKER_FIELDS.some((field) => Object.hasOwn(marker, field))
delete value[markerKey]
stripLegacyClaudeHooks(value)
/** @type {string | undefined} */
let removed
/** @type {string | undefined} */
let restoredValue
/** @type {string[]} */
const warnings = []
if (recordDamaged) {
// The one thing this branch cannot do is name the keys a post-record attach
// added beside the base URL (`ENABLE_TOOL_SEARCH`,
// `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL`, …): the record that listed
// them is exactly what is unreadable, and nothing on disk distinguishes a
// value we wrote from one the user did. Deleting on a guess would clobber a
// user edit, which this undo never does - so they stay, and the user is told
// the reversal was partial rather than left to discover the leftovers.
warnings.push(
`${markerKey} carried no readable undo record; ` +
'reversed by the pre-record convention, so any managed value a newer attach ' +
'added beside the gateway base URL is left in place'
)
}
if (isPlainObject(value.env)) {
const envObj = /** @type {Record<string, unknown>} */ (value.env)
const current = envObj.ANTHROPIC_BASE_URL
if (markerPort !== undefined && current === `http://127.0.0.1:${markerPort}`) {
// Still our gateway URL. A recorded prior goes back; with none recorded
// the key is removed, which is all a genuine legacy marker ever supports.
if (prevBaseUrl !== undefined) {
envObj.ANTHROPIC_BASE_URL = prevBaseUrl
restoredValue = typeof prevBaseUrl === 'string' ? prevBaseUrl : String(prevBaseUrl)
} else {
removed = typeof current === 'string' ? current : String(current)
delete envObj.ANTHROPIC_BASE_URL
}
} else if (Object.hasOwn(envObj, 'ANTHROPIC_BASE_URL')) {
// Presence, not type - the same rule the record-driven undo above
// follows. A legacy marker meets settings this tree never wrote, so the
// value at the key is whatever a hand edit left there: `null` or `false`
// is a user deliberately switching the base URL off, and it survives the
// detach (correctly) but used to survive it silently, because a `typeof
// current === 'string'` gate swallowed the notice for exactly the values
// most likely to be deliberate. The key absent is still silent - nothing
// was left in place to report.
warnings.push('ANTHROPIC_BASE_URL was overridden externally; leaving in place')
}
// A damaged *proxy* marker reaches this branch too, and the convention
// above only knows about the base URL. Left alone, `HTTPS_PROXY` stays
// pointing at a gateway that is no longer attached, which breaks every
// HTTPS request the client makes rather than merely its capture, and the
// `prev_env` backup would be deleted along with the marker. Reverse the
// proxy keys by the same still-ours-then-restore-or-remove rule.
//
// `recordDamaged`, not `markerPort !== undefined`: every genuine pre-record
// legacy marker carries a `port`, so gating on the port ran this on plain
// base-URL legacy detaches too. Proxy mode did not exist when those markers
// were written, so any `HTTPS_PROXY` or `NODE_EXTRA_CA_CERTS` beside one is
// the user's own - and the reversal reported it as HypAware residue of
// unknown provenance (#886 finding 2).
//
// And never when `mode` positively names a non-proxy attach. `mode` is one
// of the fields that routes a marker here as damaged in the first place, so
// it usually survives, and only a proxy attach ever writes these two keys;
// without this the same false provenance claim comes back one case over,
// for a damaged base-URL or otel marker beside the user's own corporate
// bundle. Absent `mode` is *not* that evidence, though, so it still runs:
// a marker damaged badly enough to lose `mode` as well can still be a proxy
// one holding `prev_env`, and skipping it there would leave `HTTPS_PROXY`
// pointing at a gateway that no longer exists. That is safe because every
// mutation below is separately gated on the value being ours (`HTTPS_PROXY`
// must still equal our gateway URL) or on a recorded prior; only the
// warnings claim provenance, and with no `mode` at all "the undo record is
// unreadable" is exactly true.
// @ref LLP 0232#detach-restores-any-managed-key [implements]: the damaged-record branch reverses proxy keys too
// @ref LLP 0275#legacy-proxy-reversal-needs-a-damaged-record [constrained-by]: only a damaged current-shape marker, never a genuine legacy one
const modeSaysNotProxy = marker.mode === 'base_url' || marker.mode === 'otel'
if (recordDamaged && !modeSaysNotProxy && markerPort !== undefined) {
reverseLegacyProxyKeys(envObj, markerPort, prevEnv, warnings)
}
if (Object.keys(envObj).length === 0) delete value.env
}
// Same replay, same words, as the record-driven branch. It runs after the
// strip above for the same reason it does there: the strip is what empties
// the slot a backup can go back into.
const restoredPaths = replayPrevMalformed(
value,
marker.prev_malformed,
marker.prev_malformed_encoding,
warnings
)
await writeJsonAtomic(settingsPath, value, mtimeMs, fs)
// The record is damaged but `mode` survived it (that is one of the fields
// that routes a current-shape marker here at all), so the launchd release
// still runs; the CA stays, exactly as on the record-driven branch.
await releaseProxyModeLaunchdEnv({ marker, homeDir, warnings, platform, runCommand })
// `spool_dir` is a top-level marker field, so it survives a damaged undo
// record the same way `mode` does. Sweeping it here is what keeps the one
// branch that reverses by convention from leaving raw prompt bodies behind.
await sweepMarkerSpool({ marker, env, fs, warnings })
const warning = joinWarnings(warnings)
/** @type {DetachFromDiskResult} */
const result = { changed: true, settingsPath }
if (removed !== undefined) result.removed = removed
if (restoredValue !== undefined) result.restoredValue = restoredValue
if (restoredPaths.length > 0) result.restoredPaths = restoredPaths
if (warning !== undefined) result.warning = warning
return result
}
/**
* Release the launchd user environment a proxy-mode attach set. The CA and
* its keychain trust deliberately stay: they carry the user's
* once-per-machine password-dialog grant, and only `hyp daemon uninstall` or
* `hyp detach --purge` may end that (LLP 0238#ca-survives-detach). The
* variable, by contrast, is re-appliable silently, so it follows the attach.
*
* Shared by both JSON branches on purpose. A marker whose `managed` record is
* damaged still routes through {@link detachLegacyJsonMarker}, and that branch
* already reverses `HTTPS_PROXY` by convention; leaving the variable set there
* would keep claiming a trust configuration the attach no longer backs, on
* exactly the path where the user has least evidence anything was missed.
*
* Always called after the settings write: if the release fails the client is
* already un-attached and safe, and the leftover is reported rather than
* silently retained.
*
* `homeDir`, not the ambient home: this undo is routinely pointed at a sandbox
* (every test) or another user's tree, and unlinking the LaunchAgent from
* `os.homedir()` while resolving the settings file from `homeDir` breaks a
* different install's Remote Control.
*
* @ref LLP 0239#launchctl-setenv [implements]: every branch that reverses a proxy marker releases the launchd env
* @param {{
* marker: Record<string, unknown>,
* homeDir: string | undefined,
* warnings: string[],
* platform: NodeJS.Platform | undefined,
* runCommand: TrustCommandRunner | undefined,
* }} args
*/
async function releaseProxyModeLaunchdEnv({ marker, homeDir, warnings, platform, runCommand }) {
if (marker.mode !== 'proxy') return
if ((platform ?? process.platform) !== 'darwin') return
try {
const removal = await removeLaunchdEnv({
homeDir,
...(runCommand ? { run: runCommand } : {}),
})
if (!removal.unset) {
warnings.push(
'NODE_USE_SYSTEM_CA could not be unset from the launchd environment' +
`${removal.detail ? ` (${removal.detail})` : ''}; ` +
'run `launchctl unsetenv NODE_USE_SYSTEM_CA` by hand'
)
}
} catch (err) {
warnings.push(
`the launchd environment could not be released (${err instanceof Error ? err.message : String(err)}); ` +
'run `launchctl unsetenv NODE_USE_SYSTEM_CA` by hand'
)
}
}
/**
* Empty the raw-body spool an `otel`-mode attach recorded on its marker.
*
* The path comes off the marker rather than being recomputed, because the
* config that produced it is gone by the time detach runs and a machine whose
* HypAware home moved would otherwise sweep the wrong directory (or none).
* That makes the path *settings-file input*, which a hand edit can reach, so it
* is honored only when it is a direct child of this install's
* `<hyp-home>/spool`: without that gate, "empty the directory the marker names"
* would be a recursive delete pointed anywhere. A path that fails the gate is
* left alone and reported, never guessed at.
*
* Best effort and never fatal, like the launchd release above: the settings
* undo has already landed, and a spool we could not empty is a leftover the
* user can be told about, not a reason to fail a detach that succeeded.
*
* @ref LLP 0253#purge-and-detach-sweep [implements]: detach removes the spool
* directory's contents, using the path the marker recorded
* @ref LLP 0258#marker-and-spool [constrained-by]: the marker records the spool
* directory precisely so this undo does not have to compute it
* @param {{
* marker: Record<string, unknown>,
* env: NodeJS.ProcessEnv | undefined,
* fs: typeof fsp,
* warnings: string[],
* }} args
*/
async function sweepMarkerSpool({ marker, env, fs, warnings }) {
const recorded = marker.spool_dir
if (recorded === undefined) return
const { hypHome } = readObservabilityEnv(env)
if (!isCaptureSpoolDir(recorded, hypHome)) {
warnings.push(
`the attach marker names a body spool outside ${captureSpoolRoot(hypHome)}; ` +
'it was left in place, so delete it by hand if it holds captured bodies'
)
return
}
const dir = /** @type {string} */ (recorded)
const swept = await sweepCaptureSpool(dir, { fs })
if (swept.failed > 0) {
warnings.push(
`${swept.failed} item${swept.failed === 1 ? '' : 's'} in the body spool could not be removed; ` +
`empty ${dir} by hand`
)
}
if (swept.filesRemoved === 0 && swept.failed === 0) return
// Counts, never filenames: a spooled body's name is the client's and its
// content is a raw prompt.
getLogger('client-detach').info('client.detach.spool_swept', {
[Attr.COMPONENT]: 'client-detach',
[Attr.OPERATION]: 'client.detach.spool_sweep',
[Attr.STATUS]: swept.failed > 0 ? 'partial' : 'ok',
files_removed: swept.filesRemoved,
bytes_removed: swept.bytesRemoved,
failed: swept.failed,
})
}
/**
* Reverse the proxy-mode env keys from a marker whose undo record is damaged.
*
* Only `HTTPS_PROXY` can be recognised by convention (it is the gateway URL the
* marker's `port` names). `NODE_EXTRA_CA_CERTS` cannot: nothing on disk
* distinguishes a path we wrote from one the user set, so it is restored only
* when the marker still carries a backup for it, and otherwise left in place
* and reported. Same never-clobber-a-user-edit rule as every other branch.
*
* @param {Record<string, unknown>} envObj mutated in place