@pi-archimedes/image-paste 2.10.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,18 +43,36 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
43
43
  // re-exported from the package root.
44
44
  type Mode = "tui" | "rpc" | "json" | "print";
45
45
 
46
- const { offerKeybindingFix, CREATED_NOTIFY, CREATED_RELOAD_HINT } = await import("./keybinding-offer.js");
46
+ const {
47
+ offerKeybindingFix,
48
+ restoreNativePaste,
49
+ runNativePasteRestore,
50
+ suiteMayClaimPasteKey,
51
+ CREATED_NOTIFY,
52
+ CREATED_RELOAD_HINT,
53
+ RESTORED_NOTIFY,
54
+ RESTORED_RELOAD_HINT,
55
+ } = await import("./keybinding-offer.js");
56
+
57
+ import type {
58
+ NativePasteRestoreOutcome,
59
+ RestoreNativePasteStatus,
60
+ UntouchedReason,
61
+ } from "./keybinding-offer.js";
47
62
 
48
63
  // ── fixtures ─────────────────────────────────────────────────────────────────
49
64
 
50
65
  const NAMESPACE = "archimedes.imagePaste";
51
66
  /** The exact snippet from packages/image-paste/README.md → "Paste shortcuts". */
52
- const SNIPPET = '{ "app.clipboard.pasteImage": [] }';
67
+ const SNIPPET =
68
+ '{ "$schema": "https://pi.dev/schemas/keybindings.schema.json", "app.clipboard.pasteImage": [] }';
53
69
  /** What a concurrent process writes into keybindings.json at rename time. */
54
70
  const CONCURRENT_CONTENT = '{ "app.clipboard.pasteImage": ["ctrl+v"] }';
55
71
 
56
72
 
57
73
  const settingsPath = (): string => join(tempDir, "settings.json");
74
+ /** The title the offer passes to `ctx.ui.confirm` (title first, per Pi API). */
75
+ const CONFIRM_TITLE_FOR_TEST = "First run";
58
76
  const keybindingsPath = (): string => join(tempDir, "keybindings.json");
59
77
  const tmpPath = (): string => `${keybindingsPath()}.${process.pid}.tmp`;
60
78
 
@@ -77,7 +95,13 @@ function readFlag(): boolean {
77
95
  }
78
96
 
79
97
  function cleanupArtifacts(): void {
80
- for (const p of [settingsPath(), settingsPath() + ".tmp", keybindingsPath(), tmpPath()]) {
98
+ for (const p of [
99
+ settingsPath(),
100
+ settingsPath() + ".tmp",
101
+ keybindingsPath(),
102
+ `${keybindingsPath()}.tmp`,
103
+ tmpPath(),
104
+ ]) {
81
105
  try {
82
106
  fs.rmSync(p, { recursive: true, force: true });
83
107
  } catch {
@@ -86,19 +110,20 @@ function cleanupArtifacts(): void {
86
110
  }
87
111
  }
88
112
 
89
- function makeCtx(mode: Mode, opts?: { withReload?: boolean }) {
113
+ function makeCtx(mode: Mode, opts?: { withReload?: boolean; hasUI?: boolean }) {
90
114
  const confirm = vi.fn();
91
115
  const notify = vi.fn();
92
116
  // Intentionally mirrors the base ExtensionContext the real session_start
93
- // handler receives: no command-only methods like `reload` (Pi 0.85.1
94
- // attaches reload to the command context only), so tests stay honest about
95
- // what the real ctx provides. The auto-reload branch is covered separately
96
- // by passing { withReload: true }, which simulates a future Pi version
97
- // that puts reload() on the event ctx.
117
+ // handler receives: no command-only methods like `reload`. Measured on Pi
118
+ // 1.2.0 in a real session — the notify the restore produced was the
119
+ // "run /reload to apply" hint, i.e. the event ctx had no `reload` (it is
120
+ // attached to the command context only, `runner.js` `createCommandContext`).
121
+ // The auto-reload branch is covered separately by passing { withReload: true },
122
+ // which simulates a future Pi version that puts reload() on the event ctx.
98
123
  const reload = opts?.withReload ? vi.fn(async () => {}) : undefined;
99
124
  const ctx = {
100
125
  mode,
101
- hasUI: true,
126
+ hasUI: opts?.hasUI ?? true,
102
127
  ui: { confirm, notify },
103
128
  ...(reload ? { reload } : {}),
104
129
  } as unknown as ExtensionContext;
@@ -313,6 +338,30 @@ describe("file write throws → flag stays false (self-heals next session)", ()
313
338
  });
314
339
  });
315
340
 
341
+ describe("the offer text tells the truth about the clash", () => {
342
+ it("does not promise a double-fire: with both bindings live Pi runs ONLY the extension shortcut", async () => {
343
+ // Measured on Pi 1.2.0 in a scratch agent dir with a stubbed clipboard
344
+ // (`keybindings.json` = `{}`, i.e. the built-in live, our shortcut
345
+ // registered): one `ctrl+v` produced exactly one insertion — ours
346
+ // (`[Image #1]` + "Image attached from clipboard.", no `pi-clipboard-*.png`
347
+ // temp file) — plus the `[Extension issues]` line "… Using <ext>".
348
+ // `CustomEditor.handleInput` consults `onExtensionShortcut` and returns
349
+ // before its `app.clipboard.pasteImage` branch (`custom-editor.js:63-70`),
350
+ // so nothing ever double-fired and the dialog must not say it does.
351
+ const { ctx, confirm } = makeCtx("tui");
352
+ confirm.mockResolvedValue(false);
353
+
354
+ await offerKeybindingFix(ctx);
355
+
356
+ expect(confirm).toHaveBeenCalledTimes(1);
357
+ const [title, message] = confirm.mock.calls[0] as [string, string];
358
+ expect(title).toBe(CONFIRM_TITLE_FOR_TEST);
359
+ expect(message).not.toMatch(/double-?fir/i);
360
+ expect(message).toMatch(/never runs its own action/i);
361
+ expect(message).toContain("Extension issues");
362
+ });
363
+ });
364
+
316
365
  describe("flag write throws → notify, no crash (file write already succeeded)", () => {
317
366
  it("yes with an unwritable settings target: file kept, flag unset, both notifies, no throw", async () => {
318
367
  // Make settings.json an unwritable target: a directory (rename + direct
@@ -537,3 +586,1615 @@ describe("concurrent creation is never clobbered", () => {
537
586
  ).toBe(false);
538
587
  });
539
588
  });
589
+
590
+ // ── the $schema line in the generated artefact ───────────────────────────
591
+ //
592
+ // What the file is FOR, not just that it was written. `$schema` points at pi's
593
+ // published schema (https://pi.dev/schemas/keybindings.schema.json, which
594
+ // declares `"$schema": { "type": "string" }` and an `$id` of the same URL), so
595
+ // an editor or a standalone validator can check the file. It is
596
+ // forward-compatibility and tooling, NOT startup validation: pi 1.2.0 validates
597
+ // *themes* at startup (`setThemeJsonValidator`, dist/main.js:452) and ships
598
+ // `KeybindingsSchema` (dist/core/keybindings-schema.js:13) with nothing
599
+ // importing it. pi's own loader skips `$schema` outright
600
+ // (`if (key === "$schema") continue;`, dist/core/keybindings.js:247-248), which
601
+ // is what makes the extra key safe to write: the unbinding still applies.
602
+
603
+ describe("generated snippet carries pi's published $schema", () => {
604
+ it("the written file parses to $schema plus the unbinding, and nothing else", async () => {
605
+ writeSettings({ [NAMESPACE]: { enabled: true } });
606
+ const { ctx, confirm } = makeCtx("tui");
607
+ confirm.mockResolvedValue(true);
608
+
609
+ await offerKeybindingFix(ctx);
610
+
611
+ const raw = fs.readFileSync(keybindingsPath(), "utf-8");
612
+ expect(JSON.parse(raw)).toEqual({
613
+ $schema: "https://pi.dev/schemas/keybindings.schema.json",
614
+ "app.clipboard.pasteImage": [],
615
+ });
616
+ // Byte-exact, because the README quotes this line verbatim and a reader
617
+ // copy-pasting it should get exactly what the suite writes.
618
+ expect(raw).toBe(SNIPPET);
619
+ });
620
+ });
621
+
622
+ // ══════════════════════════════════════════════════════════════════════════
623
+ // restoreNativePaste() — ADR 0030's Amendment (2026-10-13)
624
+ // ══════════════════════════════════════════════════════════════════════════
625
+ //
626
+ // The user who accepted the first-run offer holds a keybindings.json that
627
+ // leaves pi's native `app.clipboard.pasteImage` UNBOUND. `suppressNativePaste:
628
+ // false` has to give the key back, which means deleting our own unbinding from
629
+ // a file that already exists — the narrow exception to gate 4's "never rewrite
630
+ // an existing user keybindings file" rule.
631
+ //
632
+ // ACCEPT-SET DECISION, recorded here in the test names (the amendment demands an
633
+ // explicit choice): the accept-set is exactly the two artefacts this suite has
634
+ // ever written, and nothing else —
635
+ // * pre-#123: { "app.clipboard.pasteImage": [] }
636
+ // * since #123: { "$schema": "https://pi.dev/schemas/keybindings.schema.json",
637
+ // "app.clipboard.pasteImage": [] }
638
+ // `$schema` counts as ours ONLY when it carries pi's published URL (that is
639
+ // literally what the suite writes); any other second key is foreign. The set is
640
+ // never widened to "any object containing app.clipboard.pasteImage: []".
641
+
642
+ const UNBIND_KEY = "app.clipboard.pasteImage";
643
+ const SCHEMA_URL = "https://pi.dev/schemas/keybindings.schema.json";
644
+ /** What the suite wrote before PR #123 added `$schema`. */
645
+ const ARTEFACT_PRE_123 = `{ "${UNBIND_KEY}": [] }`;
646
+ /** Byte-for-byte what `offerKeybindingFix` writes today (SNIPPET above). */
647
+ const ARTEFACT_POST_123 = `{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [] }`;
648
+
649
+ /** The `globalThis` guard key the surfacing rule mandates (contract clause 5). */
650
+ const NOTICE_KEY = Symbol.for("pi-archimedes:image-paste:restore-notified");
651
+
652
+ function clearNoticeGuard(): void {
653
+ delete (globalThis as Record<symbol, unknown>)[NOTICE_KEY];
654
+ }
655
+
656
+ /** Ask for the key back: `suppressNativePaste: false`. */
657
+ function askForNativePaste(): void {
658
+ writeSettings({ [NAMESPACE]: { suppressNativePaste: false } });
659
+ }
660
+
661
+ function writeKeybindings(content: string): void {
662
+ fs.writeFileSync(keybindingsPath(), content, "utf-8");
663
+ }
664
+
665
+ /** Narrow a status, failing loudly with the kind actually seen. */
666
+ function asUntouched(s: RestoreNativePasteStatus | NativePasteRestoreOutcome) {
667
+ if (s.kind !== "untouched") {
668
+ return expect.fail(`expected status "untouched", got "${s.kind}"`);
669
+ }
670
+ return s as Extract<RestoreNativePasteStatus, { kind: "untouched" }>;
671
+ }
672
+
673
+ describe("restoreNativePaste — the accept-set (only our own artefact is rewritten)", () => {
674
+ beforeEach(clearNoticeGuard);
675
+
676
+ it("accept-set INCLUDES the pre-#123 artefact { app.clipboard.pasteImage: [] }: rewritten to {} and a reload is required", () => {
677
+ writeKeybindings(ARTEFACT_PRE_123);
678
+
679
+ const status = restoreNativePaste();
680
+
681
+ expect(status.kind).toBe("restored");
682
+ expect(status.path).toBe(keybindingsPath());
683
+ // The rewrite only ever REMOVES the key: the file is left as an empty object.
684
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
685
+ // Nothing asked for yet — the caller owns the reload, so the status is what
686
+ // tells it that pi must re-read keybindings.json.
687
+ expect(
688
+ (status as Extract<RestoreNativePasteStatus, { kind: "restored" }>)
689
+ .reloadRequired,
690
+ ).toBe(true);
691
+ });
692
+
693
+ it("accept-set INCLUDES the $schema + app.clipboard.pasteImage: [] pair — byte-for-byte what the suite writes since PR #123", () => {
694
+ // The decision the amendment asks for explicitly: `$schema` is OUR key when
695
+ // it points at pi's published schema, because that is the file we wrote.
696
+ writeKeybindings(ARTEFACT_POST_123);
697
+
698
+ const status = restoreNativePaste();
699
+
700
+ expect(status.kind).toBe("restored");
701
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
702
+ });
703
+
704
+ it("accept-set does NOT widen: a $schema carrying any other URL is a foreign key, so the file is untouched byte-for-byte", () => {
705
+ const foreign = `{ "$schema": "https://example.com/keybindings.schema.json", "${UNBIND_KEY}": [] }`;
706
+ writeKeybindings(foreign);
707
+
708
+ const status = asUntouched(restoreNativePaste());
709
+
710
+ expect(status.reason).toBe("extra-key");
711
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
712
+ });
713
+
714
+ it("accept-set does NOT widen to 'any object containing app.clipboard.pasteImage: []': an extra user key means untouched, and the status carries the path plus the one-line manual edit", () => {
715
+ const foreign = `{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
716
+ writeKeybindings(foreign);
717
+
718
+ const status = asUntouched(restoreNativePaste());
719
+
720
+ // The status is actionable on its own: path + the exact edit to make.
721
+ expect(status.path).toBe(keybindingsPath());
722
+ expect(status.manualEdit).toContain(keybindingsPath());
723
+ expect(status.manualEdit).toContain(UNBIND_KEY);
724
+ expect(status.manualEdit).not.toContain("\n");
725
+ // Byte-for-byte untouched — no rewrite, no reformatting, no trailing newline.
726
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
727
+ // And the never-touch path leaves no write artefact behind.
728
+ expect(fs.existsSync(`${keybindingsPath()}.tmp`)).toBe(false);
729
+ });
730
+
731
+ it("a non-empty app.clipboard.pasteImage value is the user's binding, not our artefact: untouched", () => {
732
+ const owned = `{ "${UNBIND_KEY}": ["ctrl+v"] }`;
733
+ writeKeybindings(owned);
734
+
735
+ const status = asUntouched(restoreNativePaste());
736
+
737
+ expect(status.reason).toBe("binding-not-empty");
738
+ expect(status.manualEdit).toContain(UNBIND_KEY);
739
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(owned);
740
+ });
741
+
742
+ it("content that does not parse is left byte-for-byte alone and never throws", () => {
743
+ const raw = `{ "${UNBIND_KEY}": [] // hand-edited, not valid JSON`;
744
+ writeKeybindings(raw);
745
+
746
+ let status: RestoreNativePasteStatus | undefined;
747
+ expect(() => {
748
+ status = restoreNativePaste();
749
+ }).not.toThrow();
750
+
751
+ expect(asUntouched(status!).reason).toBe("unparsable");
752
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(raw);
753
+ });
754
+
755
+ it("JSON that is not an object (an array) is not our artefact: untouched", () => {
756
+ const raw = `["${UNBIND_KEY}"]`;
757
+ writeKeybindings(raw);
758
+
759
+ expect(asUntouched(restoreNativePaste()).reason).toBe("not-an-object");
760
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(raw);
761
+ });
762
+
763
+ it("a missing keybindings.json is a no-op: nothing written, status 'missing'", () => {
764
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
765
+
766
+ const status = restoreNativePaste();
767
+
768
+ expect(status.kind).toBe("missing");
769
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
770
+ });
771
+
772
+ it("a keybindings.json that cannot be read (a directory where the file should be) is 'untouched (unreadable)', never a throw", () => {
773
+ // The one branch the contract did not name: the extension must not crash on
774
+ // it and must not try to write over it.
775
+ fs.mkdirSync(keybindingsPath(), { recursive: true });
776
+
777
+ let status: RestoreNativePasteStatus | undefined;
778
+ expect(() => {
779
+ status = restoreNativePaste();
780
+ }).not.toThrow();
781
+
782
+ const untouched = asUntouched(status!);
783
+ expect(untouched.reason).toBe("unreadable");
784
+ // Issue #127: the file could not be read, so nothing in it can be named.
785
+ // The guidance names the file and hands the reading back to the user — it
786
+ // does not order them to delete an entry we never saw.
787
+ expect(untouched.manualEdit).toContain(keybindingsPath());
788
+ expect(untouched.manualEdit).not.toContain(UNBIND_KEY);
789
+ expect(untouched.manualEdit).not.toMatch(/remove|delete/i);
790
+ expect(fs.statSync(keybindingsPath()).isDirectory()).toBe(true);
791
+ });
792
+
793
+ it("restoreNativePaste is silent by itself — it takes no ctx (nothing to notify with) and writes no log line", () => {
794
+ // Contract clause 2: "It returns a status; it never logs or notifies
795
+ // itself." Surfacing belongs to the caller, so one session cannot produce
796
+ // two messages and a headless caller stays silent on its own.
797
+ expect(restoreNativePaste.length).toBe(0);
798
+
799
+ const logFile = join(tempDir, `diag-${process.pid}-silent.log`);
800
+ writeKeybindings(ARTEFACT_POST_123);
801
+ process.env.PI_ARCHIMEDES_LOG = logFile;
802
+ let status: RestoreNativePasteStatus | undefined;
803
+ try {
804
+ status = restoreNativePaste();
805
+ } finally {
806
+ delete process.env.PI_ARCHIMEDES_LOG;
807
+ }
808
+
809
+ // It did the work …
810
+ expect(status!.kind).toBe("restored");
811
+ // … and recorded nothing: the log sink stays untouched (it is never created).
812
+ expect(fs.existsSync(logFile)).toBe(false);
813
+ });
814
+
815
+ it("a file already reading {} is 'already-restored' and is NOT rewritten — bytes and mtime both untouched", () => {
816
+ // Deliberately NOT the bytes we would write, so a rewrite that produces the
817
+ // same *value* is still caught, and a fixed old mtime catches a rewrite that
818
+ // would produce identical bytes.
819
+ const raw = "{\n}\n";
820
+ writeKeybindings(raw);
821
+ const stamp = new Date("2020-01-01T12:00:00.000Z");
822
+ fs.utimesSync(keybindingsPath(), stamp, stamp);
823
+
824
+ const status = restoreNativePaste();
825
+
826
+ expect(status.kind).toBe("already-restored");
827
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(raw);
828
+ expect(fs.statSync(keybindingsPath()).mtimeMs).toBe(stamp.getTime());
829
+ });
830
+
831
+ it("a symlinked keybindings.json stays a symlink: the link survives, its target is replaced atomically (new inode, {} content), and no tmp file lands beside the link", () => {
832
+ // The amendment's consequence: the write must go through
833
+ // writeAtomicPreservingSymlinks, not the offer's plain tmp+rename-onto-path
834
+ // (which would replace the link with a regular file in the agent dir) and
835
+ // not a plain writeFileSync (which would write in place, same inode).
836
+ const realDir = join(tempDir, `link-target-${process.pid}`);
837
+ const realPath = join(realDir, "keybindings.json");
838
+ fs.mkdirSync(realDir, { recursive: true });
839
+ fs.writeFileSync(realPath, ARTEFACT_POST_123, "utf-8");
840
+ fs.symlinkSync(realPath, keybindingsPath());
841
+ const inodeBefore = fs.statSync(realPath).ino;
842
+
843
+ const status = restoreNativePaste();
844
+
845
+ expect(status.kind).toBe("restored");
846
+ // Still a symlink, still pointing where the user pointed it.
847
+ expect(fs.lstatSync(keybindingsPath()).isSymbolicLink()).toBe(true);
848
+ expect(fs.readlinkSync(keybindingsPath())).toBe(realPath);
849
+ // The content change landed on the TARGET, and via rename (a new inode),
850
+ // i.e. atomically — not as an in-place write.
851
+ expect(fs.readFileSync(realPath, "utf-8")).toBe("{}");
852
+ expect(fs.statSync(realPath).ino).not.toBe(inodeBefore);
853
+ expect(fs.existsSync(`${realPath}.tmp`)).toBe(false);
854
+ expect(fs.existsSync(`${keybindingsPath()}.tmp`)).toBe(false);
855
+ });
856
+
857
+ it("a write that fails yields 'failed' with the path and the manual edit, never throws, and leaves nothing behind", () => {
858
+ writeKeybindings(ARTEFACT_POST_123);
859
+ // Deterministic write failure, for every uid: a DIRECTORY sits where the
860
+ // atomic writer puts its temp file, so `writeFileSync(tmp)` throws EISDIR,
861
+ // the rename is never attempted, and the live file must survive.
862
+ //
863
+ // This replaced `chmodSync(tempDir, 0o555)`, which is uid-dependent: a root
864
+ // process ignores the mode bit (CAP_DAC_OVERRIDE), so under root the write
865
+ // SUCCEEDED and this test failed against correct code with
866
+ // `expected status "failed", got "restored"` — reproduced by neutralising
867
+ // the chmod to 0o755 and watching exactly that failure.
868
+ const blockedTmp = `${keybindingsPath()}.tmp`;
869
+ fs.mkdirSync(blockedTmp, { recursive: true });
870
+ let status: RestoreNativePasteStatus | undefined;
871
+ try {
872
+ expect(() => {
873
+ status = restoreNativePaste();
874
+ }).not.toThrow();
875
+ } finally {
876
+ fs.rmSync(blockedTmp, { recursive: true, force: true });
877
+ }
878
+
879
+ const failed = status!;
880
+ if (failed.kind !== "failed") {
881
+ return expect.fail(`expected status "failed", got "${failed.kind}"`);
882
+ }
883
+ expect(failed.path).toBe(keybindingsPath());
884
+ expect(failed.error.length).toBeGreaterThan(0);
885
+ expect(failed.manualEdit).toContain(keybindingsPath());
886
+ expect(failed.manualEdit).toContain(UNBIND_KEY);
887
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
888
+ });
889
+ });
890
+
891
+ // ── the accept-set, probed at its edges ─────────────────────────────────────
892
+ //
893
+ // These are the inputs an adversarial (or merely hand-editing) user can put in
894
+ // front of the restore. Each was measured, not reasoned about: the accept-set is
895
+ // a parse-based test, so the questions are what the parse yields and what the
896
+ // bytes look like afterwards.
897
+
898
+ describe("restoreNativePaste — accept-set edges (measured)", () => {
899
+ beforeEach(clearNoticeGuard);
900
+
901
+ it("the $schema URL is compared exactly: a different case, scheme or trailing slash is a foreign key", () => {
902
+ for (const url of [
903
+ "HTTPS://PI.DEV/schemas/keybindings.schema.json",
904
+ "http://pi.dev/schemas/keybindings.schema.json",
905
+ `${SCHEMA_URL}/`,
906
+ `${SCHEMA_URL}?v=2`,
907
+ ]) {
908
+ const foreign = `{ "$schema": "${url}", "${UNBIND_KEY}": [] }`;
909
+ writeKeybindings(foreign);
910
+
911
+ expect(asUntouched(restoreNativePaste()).reason, url).toBe("extra-key");
912
+ expect(fs.readFileSync(keybindingsPath(), "utf-8"), url).toBe(foreign);
913
+ }
914
+ });
915
+
916
+ it("a differently-cased $Schema key is not our key either", () => {
917
+ const foreign = `{ "$Schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [] }`;
918
+ writeKeybindings(foreign);
919
+
920
+ expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
921
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
922
+ });
923
+
924
+ it("a nested object as the second key is foreign, however innocent it looks", () => {
925
+ const foreign = `{ "${UNBIND_KEY}": [], "nested": { "a": { "b": 1 } } }`;
926
+ writeKeybindings(foreign);
927
+
928
+ expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
929
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
930
+ });
931
+
932
+ it("a $schema with no binding at all is not an artefact (pi's paste is already live)", () => {
933
+ const onlySchema = `{ "$schema": "${SCHEMA_URL}" }`;
934
+ writeKeybindings(onlySchema);
935
+
936
+ expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
937
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(onlySchema);
938
+ });
939
+
940
+ it("a UTF-8 BOM leaves the file untouched: JSON.parse rejects it, so the restore is STRICTER than pi", () => {
941
+ // Worth naming the divergence: pi strips a BOM before parsing
942
+ // (`stripBom`, dist/core/keybindings.js `loadRawConfig`), so to PI this
943
+ // file is a live unbinding — but the restore will not act on bytes it did
944
+ // not write. The safe direction: never delete what we cannot identify.
945
+ const bommed = `\uFEFF${ARTEFACT_POST_123}`;
946
+ writeKeybindings(bommed);
947
+
948
+ const status = asUntouched(restoreNativePaste());
949
+
950
+ expect(status.reason).toBe("unparsable");
951
+ // Same as the unreadable case (issue #127): bytes that do not parse cannot
952
+ // tell us what the key is bound to, so the guidance says "check this file"
953
+ // rather than naming a line to delete.
954
+ expect(status.manualEdit).toContain(keybindingsPath());
955
+ expect(status.manualEdit).not.toContain(UNBIND_KEY);
956
+ expect(status.manualEdit).not.toMatch(/remove|delete/i);
957
+ // Byte-for-byte, BOM included.
958
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(bommed);
959
+ });
960
+
961
+ it("JSON that parses to null / false / 0 / a string is 'not-an-object' and is never rewritten", () => {
962
+ for (const raw of ["null", "false", "0", `"${UNBIND_KEY}"`, "[[]]"]) {
963
+ writeKeybindings(raw);
964
+
965
+ expect(asUntouched(restoreNativePaste()).reason, raw).toBe("not-an-object");
966
+ expect(fs.readFileSync(keybindingsPath(), "utf-8"), raw).toBe(raw);
967
+ }
968
+ });
969
+
970
+ it("a __proto__ key is foreign and is not treated as a prototype (no mutation, no rewrite)", () => {
971
+ const weird = `{ "__proto__": [], "${UNBIND_KEY}": [] }`;
972
+ writeKeybindings(weird);
973
+
974
+ expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
975
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(weird);
976
+ expect(({} as Record<string, unknown>)[UNBIND_KEY]).toBeUndefined();
977
+ });
978
+
979
+ it("the accept-set is parse-based, so whitespace, layout and duplicate keys still count as ours", () => {
980
+ // JSON.parse takes the LAST duplicate, which is exactly what pi's own
981
+ // loader sees, so acting on the parsed value is consistent with pi.
982
+ const spaced = `{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [ ] } \n\n`;
983
+ writeKeybindings(spaced);
984
+ expect(restoreNativePaste().kind).toBe("restored");
985
+
986
+ writeKeybindings(`{\n "$schema": "${SCHEMA_URL}",\n "${UNBIND_KEY}": [\n ]\n}\n`);
987
+ expect(restoreNativePaste().kind).toBe("restored");
988
+
989
+ writeKeybindings(`{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [], "${UNBIND_KEY}": [] }`);
990
+ expect(restoreNativePaste().kind).toBe("restored");
991
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
992
+ });
993
+
994
+ it("the one sharp edge of a parse-based accept-set: a duplicate key carrying a real binding is shadowed, and pi reads it the same way", () => {
995
+ // `{ pasteImage: ["ctrl+v"], pasteImage: [] }` parses to `[]` for US and
996
+ // for PI alike (last duplicate wins), so the `ctrl+v` in the raw bytes is
997
+ // already dead before we run: rewriting to `{}` removes a binding pi was
998
+ // never going to honour. Documented here because it is the only case where
999
+ // bytes containing a non-empty binding are replaced.
1000
+ const shadowed = `{ "${UNBIND_KEY}": ["ctrl+v"], "${UNBIND_KEY}": [] }`;
1001
+ writeKeybindings(shadowed);
1002
+
1003
+ expect(restoreNativePaste().kind).toBe("restored");
1004
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1005
+ });
1006
+
1007
+ it("a keybindings.json symlinked OUTSIDE the agent dir stays a symlink and the target is replaced atomically", () => {
1008
+ // The dotfiles-checkout case: `~/.pi/agent/keybindings.json ->
1009
+ // ~/dotfiles/pi/keybindings.json`, a path with nothing to do with the agent
1010
+ // dir. The link must survive and the write must land on the target.
1011
+ const dotfiles = join(tempDir, `..-dotfiles-${process.pid}`);
1012
+ const realPath = join(dotfiles, "keybindings.json");
1013
+ fs.mkdirSync(dotfiles, { recursive: true });
1014
+ try {
1015
+ fs.writeFileSync(realPath, ARTEFACT_POST_123, "utf-8");
1016
+ fs.symlinkSync(realPath, keybindingsPath());
1017
+ const inodeBefore = fs.statSync(realPath).ino;
1018
+
1019
+ const status = restoreNativePaste();
1020
+
1021
+ expect(status.kind).toBe("restored");
1022
+ expect(fs.lstatSync(keybindingsPath()).isSymbolicLink()).toBe(true);
1023
+ expect(fs.readlinkSync(keybindingsPath())).toBe(realPath);
1024
+ expect(fs.readFileSync(realPath, "utf-8")).toBe("{}");
1025
+ // Written beside the target and renamed onto it — not in the agent dir.
1026
+ expect(fs.statSync(realPath).ino).not.toBe(inodeBefore);
1027
+ expect(fs.existsSync(`${realPath}.tmp`)).toBe(false);
1028
+ expect(fs.existsSync(`${keybindingsPath()}.tmp`)).toBe(false);
1029
+ // No stray file was created in the agent dir either.
1030
+ expect(fs.readdirSync(tempDir).filter((f: string) => f.startsWith("keybindings"))).toEqual([
1031
+ "keybindings.json",
1032
+ ]);
1033
+ } finally {
1034
+ fs.rmSync(dotfiles, { recursive: true, force: true });
1035
+ }
1036
+ });
1037
+
1038
+ it("a DANGLING symlink is 'missing': nothing is written through the link and the link is left alone", () => {
1039
+ // `existsSync` follows the link, so an unresolvable target — including a
1040
+ // symlink LOOP — is `missing`, not `failed` and not a write-through.
1041
+ const outside = join(tempDir, `dangling-${process.pid}`);
1042
+ const target = join(outside, "keybindings.json");
1043
+ fs.mkdirSync(outside, { recursive: true });
1044
+ try {
1045
+ fs.symlinkSync(target, keybindingsPath());
1046
+
1047
+ const status = restoreNativePaste();
1048
+
1049
+ expect(status.kind).toBe("missing");
1050
+ expect(status.path).toBe(keybindingsPath());
1051
+ expect(fs.lstatSync(keybindingsPath()).isSymbolicLink()).toBe(true);
1052
+ // The restore must not resurrect a file the user's link points at.
1053
+ expect(fs.existsSync(target)).toBe(false);
1054
+ expect(fs.readdirSync(outside)).toEqual([]);
1055
+ } finally {
1056
+ fs.rmSync(keybindingsPath(), { force: true });
1057
+ fs.rmSync(outside, { recursive: true, force: true });
1058
+ }
1059
+ });
1060
+
1061
+ it("a symlink to a DIRECTORY is 'unreadable' and the directory is left alone", () => {
1062
+ const dir = join(tempDir, `kb-is-dir-${process.pid}`);
1063
+ fs.mkdirSync(dir, { recursive: true });
1064
+ try {
1065
+ fs.symlinkSync(dir, keybindingsPath());
1066
+
1067
+ expect(asUntouched(restoreNativePaste()).reason).toBe("unreadable");
1068
+ expect(fs.statSync(dir).isDirectory()).toBe(true);
1069
+ } finally {
1070
+ fs.rmSync(keybindingsPath(), { force: true });
1071
+ fs.rmSync(dir, { recursive: true, force: true });
1072
+ }
1073
+ });
1074
+ });
1075
+
1076
+ describe("runNativePasteRestore — the caller surfaces the outcome (once per process, reload-stable)", () => {
1077
+ beforeEach(clearNoticeGuard);
1078
+
1079
+ it("the DEFAULT (suppressNativePaste absent) path writes nothing and notifies nothing", async () => {
1080
+ writeKeybindings(ARTEFACT_POST_123);
1081
+ const { ctx, notify } = makeCtx("tui", { withReload: true });
1082
+
1083
+ const outcome = await runNativePasteRestore(ctx);
1084
+
1085
+ expect(outcome.kind).toBe("suppressed");
1086
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
1087
+ expect(notify).not.toHaveBeenCalled();
1088
+ });
1089
+
1090
+ it("an explicit suppressNativePaste: true behaves exactly like the default", async () => {
1091
+ writeSettings({ [NAMESPACE]: { suppressNativePaste: true } });
1092
+ writeKeybindings(ARTEFACT_POST_123);
1093
+ const { ctx, notify } = makeCtx("tui", { withReload: true });
1094
+
1095
+ const outcome = await runNativePasteRestore(ctx);
1096
+
1097
+ expect(outcome.kind).toBe("suppressed");
1098
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
1099
+ expect(notify).not.toHaveBeenCalled();
1100
+ });
1101
+
1102
+ it("the suppressed path does not even READ keybindings.json — a directory in its place is still 'suppressed'", async () => {
1103
+ // Bytes-plus-mtime can only prove "no write". This proves "no read": if the
1104
+ // suppressed path opened the path at all it would hit EISDIR and report
1105
+ // `untouched (unreadable)`; reporting `suppressed` means the settings check
1106
+ // returned before any filesystem access, so a default install never touches
1107
+ // the user's keybinding file, in any shape it happens to be in.
1108
+ fs.mkdirSync(keybindingsPath(), { recursive: true });
1109
+ const { ctx, notify } = makeCtx("tui", { withReload: true });
1110
+
1111
+ const outcome = await runNativePasteRestore(ctx);
1112
+
1113
+ expect(outcome.kind).toBe("suppressed");
1114
+ expect(notify).not.toHaveBeenCalled();
1115
+ expect(fs.statSync(keybindingsPath()).isDirectory()).toBe(true);
1116
+ fs.rmSync(keybindingsPath(), { recursive: true, force: true });
1117
+ });
1118
+
1119
+ it("a value that is not strictly false does not ask for the key back (\"false\", 0, null, \"off\")", async () => {
1120
+ // Same rule as `isConfigEnabled`: strict comparison, so a stringified
1121
+ // "false" from a hand-edited settings.json keeps the default behaviour.
1122
+ for (const value of ["false", 0, null, "off"]) {
1123
+ writeSettings({ [NAMESPACE]: { suppressNativePaste: value } });
1124
+ writeKeybindings(ARTEFACT_POST_123);
1125
+ const stamp = new Date("2020-06-07T08:09:10.000Z");
1126
+ fs.utimesSync(keybindingsPath(), stamp, stamp);
1127
+ const { ctx, notify } = makeCtx("tui", { withReload: true });
1128
+
1129
+ const outcome = await runNativePasteRestore(ctx);
1130
+
1131
+ expect(outcome.kind, JSON.stringify(value)).toBe("suppressed");
1132
+ expect(notify, JSON.stringify(value)).not.toHaveBeenCalled();
1133
+ expect(fs.readFileSync(keybindingsPath(), "utf-8"), JSON.stringify(value)).toBe(
1134
+ ARTEFACT_POST_123,
1135
+ );
1136
+ expect(fs.statSync(keybindingsPath()).mtimeMs, JSON.stringify(value)).toBe(stamp.getTime());
1137
+ }
1138
+ });
1139
+
1140
+ it("the suite-owned artefact becomes {} and a reload is requested (reload on the ctx → reload() runs and the status no longer requires /reload)", async () => {
1141
+ askForNativePaste();
1142
+ writeKeybindings(ARTEFACT_POST_123);
1143
+ const { ctx, notify, reload } = makeCtx("tui", { withReload: true });
1144
+
1145
+ const outcome = await runNativePasteRestore(ctx);
1146
+
1147
+ expect(outcome.kind).toBe("restored");
1148
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1149
+ expect(reload).toHaveBeenCalledTimes(1);
1150
+ if (outcome.kind !== "restored") return expect.fail("expected restored");
1151
+ // The reload ran, so pi has re-read keybindings.json: nothing left to ask.
1152
+ expect(outcome.reloadRequired).toBe(false);
1153
+ expect(
1154
+ callArgs(notify).some((c) => c[0] === RESTORED_NOTIFY && c[1] === "info"),
1155
+ ).toBe(true);
1156
+ expect(CREATED_NOTIFY).toContain("reloading now");
1157
+ expect(RESTORED_NOTIFY).toContain("reloading now");
1158
+ });
1159
+
1160
+ it("on a ctx WITHOUT reload the restored status says /reload is required and the hint is notified", async () => {
1161
+ askForNativePaste();
1162
+ writeKeybindings(ARTEFACT_PRE_123);
1163
+ const { ctx, notify } = makeCtx("tui");
1164
+
1165
+ const outcome = await runNativePasteRestore(ctx);
1166
+
1167
+ if (outcome.kind !== "restored") return expect.fail(`expected restored, got ${outcome.kind}`);
1168
+ expect(outcome.reloadRequired).toBe(true);
1169
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1170
+ expect(
1171
+ callArgs(notify).some((c) => c[0] === RESTORED_RELOAD_HINT && c[1] === "info"),
1172
+ ).toBe(true);
1173
+ expect(RESTORED_RELOAD_HINT).toContain("/reload");
1174
+ });
1175
+
1176
+ it("an untouched file is notified exactly once, with a message naming the file path and app.clipboard.pasteImage", async () => {
1177
+ askForNativePaste();
1178
+ const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
1179
+ writeKeybindings(foreign);
1180
+ const { ctx, notify } = makeCtx("tui");
1181
+
1182
+ const outcome = await runNativePasteRestore(ctx);
1183
+
1184
+ expect(outcome.kind).toBe("untouched");
1185
+ expect(notify).toHaveBeenCalledTimes(1);
1186
+ const [message, level] = notify.mock.calls[0] as [string, string];
1187
+ expect(level).toBe("warning");
1188
+ expect(message).toContain(keybindingsPath());
1189
+ expect(message).toContain(UNBIND_KEY);
1190
+ // Byte-for-byte unchanged, and no rewrite attempt of any kind.
1191
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
1192
+ });
1193
+
1194
+ it("the untouched warning also says the paste key is left unbound, because the flag took our shortcut with it", async () => {
1195
+ // Since the shortcut gate exists, an untouched file is not merely "we did
1196
+ // not help you": with `suppressNativePaste: false` image-paste registers no
1197
+ // paste shortcut either, so between the warning and the user's edit the key
1198
+ // does NOTHING. Saying only "remove the entry and run /reload" would let
1199
+ // the user conclude that image-paste broke the key.
1200
+ //
1201
+ // Scoped deliberately to the refusal where that sentence is TRUE — a file
1202
+ // that still carries our `[]` unbinding. The no-entry refusals assert the
1203
+ // opposite wording (see "the refuse warning says what this file actually
1204
+ // holds").
1205
+ askForNativePaste();
1206
+ const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
1207
+ writeKeybindings(foreign);
1208
+ const { ctx, notify } = makeCtx("tui");
1209
+
1210
+ await runNativePasteRestore(ctx);
1211
+
1212
+ const [message] = notify.mock.calls[0] as [string, string];
1213
+ expect(message).toMatch(/not binding the paste key/i);
1214
+ expect(message).toMatch(/does nothing/i);
1215
+ expect(message).toContain(UNBIND_KEY);
1216
+ });
1217
+
1218
+ it("a plain second session_start in the same process stays silent for the same untouched file", async () => {
1219
+ askForNativePaste();
1220
+ const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
1221
+ writeKeybindings(foreign);
1222
+
1223
+ const first = makeCtx("tui");
1224
+ await runNativePasteRestore(first.ctx);
1225
+ expect(first.notify).toHaveBeenCalledTimes(1);
1226
+
1227
+ const second = makeCtx("tui");
1228
+ const outcome = await runNativePasteRestore(second.ctx);
1229
+
1230
+ expect(outcome.kind).toBe("untouched");
1231
+ expect(second.notify).not.toHaveBeenCalled();
1232
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
1233
+ });
1234
+
1235
+ it("the once-guard is RELOAD-STABLE, not merely per-session: after vi.resetModules() + re-import (what /reload does to the module registry) the caller still does not notify again", async () => {
1236
+ // A module-level `let` passes the "second session_start" test above and
1237
+ // STILL re-warns here, because pi re-evaluates the extension module on
1238
+ // every load: loadExtensionModule() builds a fresh jiti with
1239
+ // `moduleCache: false` (pi 1.2.0 dist/core/extensions/loader.js:477-481) and
1240
+ // ctx.reload() clears the cached factory first (dist/core/resource-loader.js:359
1241
+ // → clearExtensionCache(), dist/core/extensions/loader.js:89-93).
1242
+ // `globalThis` is the only thing that survives it — so the guard lives under
1243
+ // Symbol.for("pi-archimedes:image-paste:restore-notified").
1244
+ askForNativePaste();
1245
+ const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
1246
+ writeKeybindings(foreign);
1247
+
1248
+ const first = makeCtx("tui");
1249
+ await runNativePasteRestore(first.ctx);
1250
+ expect(first.notify).toHaveBeenCalledTimes(1);
1251
+
1252
+ // /reload: a brand-new module instance, same process, same globalThis.
1253
+ vi.resetModules();
1254
+ const reloaded = await import("./keybinding-offer.js");
1255
+ expect(reloaded.runNativePasteRestore).not.toBe(runNativePasteRestore);
1256
+
1257
+ const second = makeCtx("tui");
1258
+ await reloaded.runNativePasteRestore(second.ctx);
1259
+
1260
+ expect(second.notify).not.toHaveBeenCalled();
1261
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
1262
+ });
1263
+
1264
+ it("a reload that re-fires session_start does NOT recurse: the second pass reports already-restored and never reloads again", async () => {
1265
+ // pi's own reload path: `AgentSession.reload()` emits `session_start`
1266
+ // (`reason: "reload"`, pi 1.2.0 dist/core/agent-session.js:2998) and
1267
+ // interactive mode calls `keybindings.reload()` only after it returns
1268
+ // (dist/modes/interactive/interactive-mode.js:5406). So a restore that
1269
+ // reloads re-enters this call — and must terminate. It does, because the
1270
+ // first pass leaves `{}`, which is reported without a rewrite.
1271
+ askForNativePaste();
1272
+ writeKeybindings(ARTEFACT_POST_123);
1273
+
1274
+ const reloads: number[] = [];
1275
+ const passes: string[] = [];
1276
+ const runPass = async (depth: number): Promise<void> => {
1277
+ const notify = vi.fn();
1278
+ const ctx = {
1279
+ mode: "tui",
1280
+ hasUI: true,
1281
+ ui: { confirm: vi.fn(), notify },
1282
+ reload: async () => {
1283
+ reloads.push(depth);
1284
+ await runPass(depth + 1);
1285
+ },
1286
+ } as unknown as ExtensionContext;
1287
+
1288
+ const outcome = await runNativePasteRestore(ctx);
1289
+ passes.push(`${outcome.kind}:${notify.mock.calls.length}`);
1290
+ };
1291
+
1292
+ await runPass(0);
1293
+
1294
+ // Exactly one reload, exactly two passes, one rewrite, no notify on the
1295
+ // second pass (the inner pass reports first — the reload is awaited inside
1296
+ // the outer call). A restore that rewrote `{}` instead of recognising it
1297
+ // would reload forever and never reach this assertion.
1298
+ expect(reloads).toEqual([0]);
1299
+ expect(passes).toEqual(["already-restored:0", "restored:1"]);
1300
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1301
+ });
1302
+
1303
+ it("the guard lives on globalThis under the mandated symbol key", async () => {
1304
+ askForNativePaste();
1305
+ writeKeybindings(`{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`);
1306
+ expect((globalThis as Record<symbol, unknown>)[NOTICE_KEY]).toBeUndefined();
1307
+
1308
+ await runNativePasteRestore(makeCtx("tui").ctx);
1309
+
1310
+ expect((globalThis as Record<symbol, unknown>)[NOTICE_KEY]).toBe(true);
1311
+ });
1312
+
1313
+ it("no notify at all when ctx.hasUI is false — and a headless run does not spend the guard", async () => {
1314
+ askForNativePaste();
1315
+ const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
1316
+ writeKeybindings(foreign);
1317
+
1318
+ const headless = makeCtx("rpc", { hasUI: false });
1319
+ const headlessOutcome = await runNativePasteRestore(headless.ctx);
1320
+ expect(headlessOutcome.kind).toBe("untouched");
1321
+ expect(headless.notify).not.toHaveBeenCalled();
1322
+
1323
+ // A later TUI session still gets its one warning.
1324
+ const tui = makeCtx("tui");
1325
+ await runNativePasteRestore(tui.ctx);
1326
+ expect(tui.notify).toHaveBeenCalledTimes(1);
1327
+ expect((tui.notify.mock.calls[0] as [string, string])[1]).toBe("warning");
1328
+ });
1329
+
1330
+ // The silence promise is stated for EVERY outcome, not just "untouched":
1331
+ // "No notify at all when ctx.hasUI is false". The `restored` branch goes
1332
+ // through the shared reload helper, which notified unconditionally. Measured
1333
+ // against pi 1.2.0: `hasUI` is `uiContext !== noOpUIContext`
1334
+ // (dist/core/extensions/runner.js:404), so print/json hosts hand out a
1335
+ // no-op `notify` and rpc reports `hasUI: true` — meaning the guard is what
1336
+ // makes the documented promise TRUE rather than accidentally true via a
1337
+ // host-side no-op, and it is the same rule the `untouched` branch already
1338
+ // applies.
1339
+ it("a headless session that RESTORES the file never notifies, and the log still carries the outcome", async () => {
1340
+ askForNativePaste();
1341
+ writeKeybindings(ARTEFACT_POST_123);
1342
+ const logFile = join(tempDir, `diag-${process.pid}-headless-restored.log`);
1343
+ const headless = makeCtx("rpc", { hasUI: false, withReload: true });
1344
+ process.env.PI_ARCHIMEDES_LOG = logFile;
1345
+ let outcome: NativePasteRestoreOutcome | undefined;
1346
+ try {
1347
+ outcome = await runNativePasteRestore(headless.ctx);
1348
+ } finally {
1349
+ delete process.env.PI_ARCHIMEDES_LOG;
1350
+ }
1351
+
1352
+ // The work still happens — silence is not inaction.
1353
+ expect(outcome!.kind).toBe("restored");
1354
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1355
+ // …with the headless ctx (which has a reload) reporting no /reload is needed.
1356
+ if (outcome!.kind !== "restored") return expect.fail(`expected restored, got ${outcome!.kind}`);
1357
+ expect(outcome!.reloadRequired).toBe(false);
1358
+ // …and NOT one notify emitted, and the once-per-process warning budget is
1359
+ // still unspent for whatever comes next.
1360
+ expect(headless.notify).not.toHaveBeenCalled();
1361
+ expect((globalThis as Record<symbol, unknown>)[NOTICE_KEY]).toBeUndefined();
1362
+ // …while the detailed channel still records it.
1363
+ expect(fs.existsSync(logFile)).toBe(true);
1364
+ expect(fs.readFileSync(logFile, "utf-8")).toContain(keybindingsPath());
1365
+ });
1366
+
1367
+ it("a headless session with no reload on the ctx notifies nothing, and says /reload is required", async () => {
1368
+ askForNativePaste();
1369
+ writeKeybindings(ARTEFACT_PRE_123);
1370
+ const headless = makeCtx("print", { hasUI: false });
1371
+
1372
+ const outcome = await runNativePasteRestore(headless.ctx);
1373
+
1374
+ if (outcome.kind !== "restored") return expect.fail(`expected restored, got ${outcome.kind}`);
1375
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1376
+ expect(outcome.reloadRequired).toBe(true);
1377
+ expect(headless.notify).not.toHaveBeenCalled();
1378
+ });
1379
+
1380
+ it("a second session on the already-restored {} file notifies nothing and writes nothing", async () => {
1381
+ askForNativePaste();
1382
+ writeKeybindings("{}");
1383
+ const stamp = new Date("2020-01-01T12:00:00.000Z");
1384
+ fs.utimesSync(keybindingsPath(), stamp, stamp);
1385
+
1386
+ const first = makeCtx("tui", { withReload: true });
1387
+ const outcome = await runNativePasteRestore(first.ctx);
1388
+
1389
+ expect(outcome.kind).toBe("already-restored");
1390
+ expect(first.notify).not.toHaveBeenCalled();
1391
+ expect(first.reload).not.toHaveBeenCalled();
1392
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
1393
+ expect(fs.statSync(keybindingsPath()).mtimeMs).toBe(stamp.getTime());
1394
+
1395
+ const second = makeCtx("tui", { withReload: true });
1396
+ expect((await runNativePasteRestore(second.ctx)).kind).toBe("already-restored");
1397
+ expect(second.notify).not.toHaveBeenCalled();
1398
+ });
1399
+
1400
+ it("a failed write is surfaced as one warning naming the path and the key, and never throws", async () => {
1401
+ askForNativePaste();
1402
+ writeKeybindings(ARTEFACT_POST_123);
1403
+ const { ctx, notify } = makeCtx("tui");
1404
+ // Same uid-independent injection as the `restoreNativePaste` case: a
1405
+ // directory at the temp path makes the temp write fail for any uid, where
1406
+ // `chmod 0o555` is simply ignored by root.
1407
+ const blockedTmp = `${keybindingsPath()}.tmp`;
1408
+ fs.mkdirSync(blockedTmp, { recursive: true });
1409
+ let outcome: NativePasteRestoreOutcome | undefined;
1410
+ try {
1411
+ outcome = await runNativePasteRestore(ctx);
1412
+ } finally {
1413
+ fs.rmSync(blockedTmp, { recursive: true, force: true });
1414
+ }
1415
+
1416
+ expect(outcome!.kind).toBe("failed");
1417
+ expect(notify).toHaveBeenCalledTimes(1);
1418
+ const [message, level] = notify.mock.calls[0] as [string, string];
1419
+ expect(level).toBe("warning");
1420
+ expect(message).toContain(keybindingsPath());
1421
+ expect(message).toContain(UNBIND_KEY);
1422
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
1423
+ });
1424
+
1425
+ it("diag carries the detail, so the outcome is recorded even where notify cannot run", async () => {
1426
+ // `diag` is opt-in (PI_ARCHIMEDES_LOG names the file), which is exactly why
1427
+ // notify is mandatory for the actionable case — but with the log on, the
1428
+ // path and the key have to be in it.
1429
+ const logFile = join(tempDir, `diag-${process.pid}-untouched.log`);
1430
+ askForNativePaste();
1431
+ writeKeybindings(`{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`);
1432
+ process.env.PI_ARCHIMEDES_LOG = logFile;
1433
+ try {
1434
+ await runNativePasteRestore(makeCtx("rpc", { hasUI: false }).ctx);
1435
+ } finally {
1436
+ delete process.env.PI_ARCHIMEDES_LOG;
1437
+ }
1438
+
1439
+ expect(fs.existsSync(logFile)).toBe(true);
1440
+ const log = fs.readFileSync(logFile, "utf-8");
1441
+ expect(log).toContain("image-paste");
1442
+ expect(log).toContain(keybindingsPath());
1443
+ expect(log).toContain(UNBIND_KEY);
1444
+ });
1445
+
1446
+ it("every session's untouched outcome is logged while notify stays silenced after the first", async () => {
1447
+ // `diag` is the detailed channel and may fire every session; the notify is
1448
+ // the once-per-process surface. Together they are the surfacing rule.
1449
+ askForNativePaste();
1450
+ const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
1451
+ writeKeybindings(foreign);
1452
+ const logFile = join(tempDir, `diag-${process.pid}-twice.log`);
1453
+ process.env.PI_ARCHIMEDES_LOG = logFile;
1454
+ const first = makeCtx("tui");
1455
+ const second = makeCtx("tui");
1456
+ try {
1457
+ await runNativePasteRestore(first.ctx);
1458
+ await runNativePasteRestore(second.ctx);
1459
+ } finally {
1460
+ delete process.env.PI_ARCHIMEDES_LOG;
1461
+ }
1462
+ const log: string = fs.readFileSync(logFile, "utf-8");
1463
+ expect(
1464
+ log.split("\n").filter((line: string) => line.includes(keybindingsPath())).length,
1465
+ ).toBeGreaterThanOrEqual(2);
1466
+ expect(first.notify).toHaveBeenCalledTimes(1);
1467
+ expect(second.notify).not.toHaveBeenCalled();
1468
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
1469
+ });
1470
+ });
1471
+
1472
+ // ══════════════════════════════════════════════════════════════════════════
1473
+ // The wording of the refuse warning, per refusal (issue #127)
1474
+ // ══════════════════════════════════════════════════════════════════════════
1475
+ //
1476
+ // ONE warning covers five refusals, and they are five different files. Only two
1477
+ // of them actually hold an `app.clipboard.pasteImage` entry the user could
1478
+ // delete:
1479
+ //
1480
+ // * `extra-key` WITH our `[]` still sitting beside a foreign key — delete it
1481
+ // and pi's paste comes back, and until then nothing answers the key, because
1482
+ // the entry is empty AND `suppressNativePaste: false` took our shortcut;
1483
+ // * `binding-not-empty` — the entry is there but it is the USER's binding, so
1484
+ // the wording has to say whose it is, and cannot claim the key is dead.
1485
+ //
1486
+ // The other refusals hold no such entry at all: foreign keys only, `$schema`
1487
+ // only (`extra-key`), JSON that is not an object, JSON that does not parse, and
1488
+ // a file that cannot be read. There the instruction "remove the
1489
+ // `app.clipboard.pasteImage` entry and run /reload" sends the user looking for a
1490
+ // line that is not in the file, in a file that needs no edit at all — and for the
1491
+ // two readable ones among them pi is already answering the paste key. So every
1492
+ // refusal is pinned on its own: the warning always names the file, and it only
1493
+ // tells the user to delete something when there is something to delete.
1494
+
1495
+ describe("runNativePasteRestore — the refuse warning says what this file actually holds", () => {
1496
+ beforeEach(clearNoticeGuard);
1497
+
1498
+ /**
1499
+ * Refuse `raw`, and return the one warning the caller notified.
1500
+ *
1501
+ * The shared shape of EVERY refusal lives here, because it is the same for all
1502
+ * five: exactly one notify, at `"warning"`, naming the file path, on one line,
1503
+ * and the status behind it carrying the reason the wording branches on.
1504
+ */
1505
+ async function warningFor(raw: string, reason: UntouchedReason): Promise<string> {
1506
+ askForNativePaste();
1507
+ writeKeybindings(raw);
1508
+ const { ctx, notify } = makeCtx("tui");
1509
+
1510
+ const outcome = await runNativePasteRestore(ctx);
1511
+
1512
+ if (outcome.kind !== "untouched") {
1513
+ return expect.fail(`expected "untouched", got "${outcome.kind}"`);
1514
+ }
1515
+ expect(outcome.reason, "the reason the wording branches on").toBe(reason);
1516
+ expect(notify).toHaveBeenCalledTimes(1);
1517
+ const [message, level] = notify.mock.calls[0] as [string, string];
1518
+ expect(level).toBe("warning");
1519
+ expect(message).toContain(keybindingsPath());
1520
+ expect(message).not.toContain("\n");
1521
+ expect(outcome.manualEdit).toContain(keybindingsPath());
1522
+ return message;
1523
+ }
1524
+
1525
+ // ── the two refusals where an entry really is in the file ────────────────
1526
+
1527
+ it("extra-key WITH our [] entry: the one case where naming the entry to delete is true, and the key is genuinely dead until it goes", async () => {
1528
+ const message = await warningFor(
1529
+ `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`,
1530
+ "extra-key",
1531
+ );
1532
+
1533
+ expect(message).toContain(UNBIND_KEY);
1534
+ expect(message).toMatch(/remove the .*entry/i);
1535
+ expect(message).toMatch(/\/reload/);
1536
+ expect(message).toMatch(/not binding the paste key/i);
1537
+ expect(message).toMatch(/does nothing/i);
1538
+ });
1539
+
1540
+ it("binding-not-empty: the entry is named, but as the user's OWN binding, and the key is not called dead", async () => {
1541
+ // The user wrote `"app.clipboard.pasteImage": ["ctrl+v"]`. Handing the key
1542
+ // to pi still means deleting that line, so the instruction is right — but it
1543
+ // is their line, not ours, and something (their own binding) does answer the
1544
+ // key today, so "does nothing until you do" would be false here.
1545
+ const message = await warningFor(`{ "${UNBIND_KEY}": ["ctrl+v"] }`, "binding-not-empty");
1546
+
1547
+ expect(message).toContain(UNBIND_KEY);
1548
+ expect(message).toMatch(/your/i);
1549
+ expect(message).toMatch(/remove/i);
1550
+ expect(message).toMatch(/\/reload/);
1551
+ expect(message).not.toMatch(/does nothing/i);
1552
+ });
1553
+
1554
+ // ── the refusals where there is nothing of ours to remove ────────────────
1555
+
1556
+ it("extra keys but no app.clipboard.pasteImage entry: nothing of ours to remove, nothing to edit, pi already answers the key", async () => {
1557
+ const message = await warningFor(
1558
+ `{ "app.editor.newLine": ["ctrl+enter"] }`,
1559
+ "extra-key",
1560
+ );
1561
+
1562
+ // No instruction to delete anything — there is nothing to delete.
1563
+ expect(message).not.toMatch(/remove|delete/i);
1564
+ expect(message).toMatch(/nothing for you to edit/i);
1565
+ expect(message).toMatch(/pi's own paste answers that key/i);
1566
+ // And no reload is demanded: a file that needs no edit needs no reload.
1567
+ expect(message).not.toMatch(/\/reload/);
1568
+ });
1569
+
1570
+ it("a $schema-only file is the same refusal: no entry of ours, so no errand", async () => {
1571
+ const message = await warningFor(`{ "$schema": "${SCHEMA_URL}" }`, "extra-key");
1572
+
1573
+ expect(message).not.toMatch(/remove|delete/i);
1574
+ expect(message).toMatch(/nothing for you to edit/i);
1575
+ expect(message).toMatch(/pi's own paste answers that key/i);
1576
+ expect(message).not.toMatch(/\/reload/);
1577
+ });
1578
+
1579
+ // ── the refusals where the file cannot be read for advice ────────────────
1580
+
1581
+ it("JSON that is not an object: left untouched, the file named, the user asked to check it — no entry named", async () => {
1582
+ const message = await warningFor(`["${UNBIND_KEY}"]`, "not-an-object");
1583
+
1584
+ expect(message).toMatch(/not a JSON object/i);
1585
+ expect(message).toMatch(/check .*yourself/i);
1586
+ expect(message).toMatch(/\/reload/);
1587
+ // Neither an entry nor a deletion is named: we cannot see one to name.
1588
+ expect(message).not.toContain(UNBIND_KEY);
1589
+ expect(message).not.toMatch(/remove|delete/i);
1590
+ });
1591
+
1592
+ it("JSON that does not parse: left untouched, the file named, the user asked to check it — no entry named", async () => {
1593
+ const message = await warningFor(
1594
+ `{ "${UNBIND_KEY}": [] // hand-edited, not valid JSON`,
1595
+ "unparsable",
1596
+ );
1597
+
1598
+ expect(message).toMatch(/does not parse/i);
1599
+ expect(message).toMatch(/check .*yourself/i);
1600
+ expect(message).toMatch(/\/reload/);
1601
+ expect(message).not.toContain(UNBIND_KEY);
1602
+ expect(message).not.toMatch(/remove|delete/i);
1603
+ });
1604
+
1605
+ it("a file that cannot be read: left untouched, the file named, the user asked to check it — no entry named", async () => {
1606
+ // The helper writes a file, so this refusal is set up by hand — same shape
1607
+ // of assertion: one `"warning"` naming the path, no entry, no deletion.
1608
+ askForNativePaste();
1609
+ fs.mkdirSync(keybindingsPath(), { recursive: true });
1610
+ const { ctx, notify } = makeCtx("tui");
1611
+ try {
1612
+ const outcome = await runNativePasteRestore(ctx);
1613
+
1614
+ if (outcome.kind !== "untouched") {
1615
+ return expect.fail(`expected "untouched", got "${outcome.kind}"`);
1616
+ }
1617
+ expect(outcome.reason).toBe("unreadable");
1618
+ expect(notify).toHaveBeenCalledTimes(1);
1619
+ const [message, level] = notify.mock.calls[0] as [string, string];
1620
+ expect(level).toBe("warning");
1621
+ expect(message).toContain(keybindingsPath());
1622
+ expect(message).toMatch(/could not be read/i);
1623
+ expect(message).toMatch(/check .*yourself/i);
1624
+ expect(message).not.toContain(UNBIND_KEY);
1625
+ expect(message).not.toMatch(/remove|delete/i);
1626
+ } finally {
1627
+ fs.rmSync(keybindingsPath(), { recursive: true, force: true });
1628
+ }
1629
+ });
1630
+
1631
+ it("a failed write still names the entry: the artefact is ours, it is in there, and it is dead until it goes", async () => {
1632
+ // The `failed` status shares the wording with the case above because the
1633
+ // file it refused IS our artefact — the entry is present and pi's paste is
1634
+ // the thing waiting on it.
1635
+ askForNativePaste();
1636
+ writeKeybindings(ARTEFACT_POST_123);
1637
+ const blockedTmp = `${keybindingsPath()}.tmp`;
1638
+ fs.mkdirSync(blockedTmp, { recursive: true });
1639
+ const { ctx, notify } = makeCtx("tui");
1640
+ try {
1641
+ const outcome = await runNativePasteRestore(ctx);
1642
+ expect(outcome.kind).toBe("failed");
1643
+
1644
+ const [message, level] = notify.mock.calls[0] as [string, string];
1645
+ expect(level).toBe("warning");
1646
+ expect(message).toContain(keybindingsPath());
1647
+ expect(message).toContain(UNBIND_KEY);
1648
+ expect(message).toMatch(/remove the .*entry/i);
1649
+ expect(message).toMatch(/does nothing/i);
1650
+ } finally {
1651
+ fs.rmSync(blockedTmp, { recursive: true, force: true });
1652
+ }
1653
+ });
1654
+ });
1655
+
1656
+ // ══════════════════════════════════════════════════════════════════════════
1657
+ // PR #129 review — the remaining clauses, checked against pi's own loader
1658
+ // ══════════════════════════════════════════════════════════════════════════
1659
+ //
1660
+ // The #127 fix split one sentence into four and checked each clause against the
1661
+ // shapes the split names. Three claims survived that are not facts about the
1662
+ // file in front of us, and one promise turned out to be untested. Every claim
1663
+ // below was measured against pi's OWN loader — `KeybindingsManager.create()`
1664
+ // from `dist/core/keybindings.js`, run against these exact files on pi 1.2.0 and
1665
+ // pi 0.87.0, which carry the same code — not reasoned about from our side:
1666
+ //
1667
+ // * **The old name still bites.** pi renames `pasteImage` to
1668
+ // `app.clipboard.pasteImage` before it applies anything
1669
+ // (`KEYBINDING_NAME_MIGRATIONS`, `dist/core/keybindings.js:225`, applied by
1670
+ // `migrateKeybindingsConfig` at `:254-269`), so
1671
+ // `{ "pasteImage": [] }` resolves the paste action to `[]`: the key is
1672
+ // DEAD. Our reader only looks for the modern key, so it filed that file
1673
+ // under "nothing in it clears pi's paste binding — pi's own paste answers
1674
+ // that key", which is the exact opposite of what pi does with it.
1675
+ // pi's startup migration (`migrations.js:139-157`) rewrites the file on
1676
+ // disk before extensions load, so this is the narrow case where it did not:
1677
+ // a read-only agent dir (its `catch {}` swallows the failure), or a file
1678
+ // replaced by a dotfiles sync / editor after the migration and then
1679
+ // `/reload`ed — `keybindings.reload()` (`interactive-mode.js:5406`) only
1680
+ // re-reads, it never migrates the file.
1681
+ // * **We cannot see who wrote the file.** `{ "$schema": <pi's URL> }` is
1682
+ // byte-for-byte the line `offerKeybindingFix` writes, and it is the state a
1683
+ // user is in after following our own "remove the entry" instruction on that
1684
+ // file, so "nothing in this file was written by this suite" is not something
1685
+ // the refusal is allowed to assert.
1686
+ // * **An unreadable file does not leave the binding unknown.** pi DISCARDS the
1687
+ // whole file when it cannot read or parse it (`loadRawConfig` returns
1688
+ // `undefined` → `loadFromFile` returns `{}`, `keybindings.js:285-297`) and
1689
+ // every action falls back to its default, silently, with no diagnostic on
1690
+ // any path. So the paste key IS answered by pi's default and none of the
1691
+ // user's own bindings in that file are in force — "we cannot tell what the
1692
+ // paste key is bound to" tells the user to go read a file that pi is
1693
+ // ignoring.
1694
+
1695
+ describe("runNativePasteRestore — the refuse warning vs pi's own keybindings loader (PR #129 review)", () => {
1696
+ beforeEach(clearNoticeGuard);
1697
+
1698
+ /** Refuse `raw` and return the one warning, with the shared refusal shape. */
1699
+ async function warning(raw: string): Promise<string> {
1700
+ // The notice guard is once-per-PROCESS, and one test below asks for two
1701
+ // refusals in a row, so the helper re-arms it rather than relying on
1702
+ // `beforeEach` alone.
1703
+ clearNoticeGuard();
1704
+ askForNativePaste();
1705
+ writeKeybindings(raw);
1706
+ const { ctx, notify } = makeCtx("tui");
1707
+
1708
+ const outcome = await runNativePasteRestore(ctx);
1709
+
1710
+ if (outcome.kind !== "untouched") {
1711
+ return expect.fail(`expected "untouched", got "${outcome.kind}"`);
1712
+ }
1713
+ expect(notify).toHaveBeenCalledTimes(1);
1714
+ const [message, level] = notify.mock.calls[0] as [string, string];
1715
+ expect(level).toBe("warning");
1716
+ expect(message).toContain(keybindingsPath());
1717
+ expect(message).not.toContain("\n");
1718
+ return message;
1719
+ }
1720
+
1721
+ it("the OLD name pasteImage: [] is an unbinding too: pi's paste is dead, and the entry to name is the old one", async () => {
1722
+ // Measured: pi resolves app.clipboard.pasteImage to [] for this file, so it
1723
+ // belongs with the "there is an entry, remove it, the key is dead until you
1724
+ // do" refusals — and naming the modern key would send the user looking for
1725
+ // a line that is not in the file (issue #127 again, in the other direction).
1726
+ const message = await warning(`{ "pasteImage": [] }`);
1727
+
1728
+ expect(message).toMatch(/"pasteImage"/);
1729
+ expect(message).toMatch(/remove .*"pasteImage"/i);
1730
+ expect(message).toMatch(/\/reload/);
1731
+ expect(message).toMatch(/does nothing/i);
1732
+ // The two claims this file refutes.
1733
+ expect(message).not.toMatch(/pi's own paste answers that key/i);
1734
+ expect(message).not.toMatch(/nothing for you to edit/i);
1735
+ // Not told to delete an entry under a name the file does not contain.
1736
+ expect(message).not.toMatch(/remove the "app\.clipboard\.pasteImage" entry/i);
1737
+ });
1738
+
1739
+ it("a $schema carrying pi's own URL is not evidence the file is foreign: no claim about who wrote it", async () => {
1740
+ // Still the no-errand refusal — pi keeps its default binding here.
1741
+ const message = await warning(`{ "$schema": "${SCHEMA_URL}" }`);
1742
+
1743
+ expect(message).toMatch(/nothing for you to edit/i);
1744
+ expect(message).toMatch(/pi's own paste answers that key/i);
1745
+ expect(message).not.toMatch(/\/reload/);
1746
+ expect(message).not.toMatch(/remove|delete/i);
1747
+ // But the refusal must not assert authorship it cannot see: this exact line
1748
+ // is what the first-run offer writes.
1749
+ expect(message).not.toMatch(/was written by this suite/i);
1750
+ expect(message).not.toMatch(/nothing in .* was written/i);
1751
+ });
1752
+
1753
+ it("a file pi cannot read is a file pi ignores: the warning says pi falls back to its defaults", async () => {
1754
+ for (const [label, raw] of [
1755
+ ["unparsable", `{ "${UNBIND_KEY}": [] // hand-edited, not valid JSON`],
1756
+ ["not-a-JSON-object", `["${UNBIND_KEY}"]`],
1757
+ ] as const) {
1758
+ const message = await warning(raw);
1759
+
1760
+ expect(message, label).not.toMatch(/cannot tell what the paste key is bound to/i);
1761
+ expect(message, label).toMatch(/default/i);
1762
+ // Still: the file is named, the cause is named, the reading is handed back.
1763
+ expect(message, label).toMatch(/check .*yourself/i);
1764
+ expect(message, label).not.toContain(UNBIND_KEY);
1765
+ expect(message, label).not.toMatch(/remove|delete/i);
1766
+ }
1767
+
1768
+ // The third shape cannot come from `warning()` (it writes a file), so it is
1769
+ // set up by hand: a directory where keybindings.json should be.
1770
+ clearNoticeGuard();
1771
+ askForNativePaste();
1772
+ // Clear whatever the loop above left at the path, then make it a directory.
1773
+ fs.rmSync(keybindingsPath(), { recursive: true, force: true });
1774
+ fs.mkdirSync(keybindingsPath(), { recursive: true });
1775
+ const { ctx, notify } = makeCtx("tui");
1776
+ try {
1777
+ await runNativePasteRestore(ctx);
1778
+ const [message] = notify.mock.calls[0] as [string, string];
1779
+
1780
+ expect(message).toMatch(/could not be read/i);
1781
+ expect(message).toMatch(/default/i);
1782
+ expect(message).not.toMatch(/cannot tell what the paste key is bound to/i);
1783
+ expect(message).not.toContain(UNBIND_KEY);
1784
+ expect(message).not.toMatch(/remove|delete/i);
1785
+ } finally {
1786
+ fs.rmSync(keybindingsPath(), { recursive: true, force: true });
1787
+ }
1788
+ });
1789
+
1790
+ it("a modern binding with a legacy [] behind it: clearing only the modern entry would re-dead the key", async () => {
1791
+ // Measured: pi drops the legacy entry while the modern key exists
1792
+ // (`keybindings.js:260-264`), so the old name is invisible today and takes
1793
+ // the binding over the moment the modern entry is deleted — i.e. exactly
1794
+ // after the user does what this warning tells them to do.
1795
+ const message = await warning(`{ "${UNBIND_KEY}": ["ctrl+v"], "pasteImage": [] }`);
1796
+
1797
+ expect(message).toMatch(/your/i);
1798
+ expect(message).toMatch(/pasteImage/);
1799
+ expect(message).toMatch(/too\b/i);
1800
+ // Their own binding answers the key right now, so the key is not dead.
1801
+ expect(message).not.toMatch(/does nothing/i);
1802
+ });
1803
+
1804
+ it("diag logs the SAME guidance the warning gave — the log line is not a second wording", async () => {
1805
+ // Written against a surviving mutant: replacing `status.manualEdit` with the
1806
+ // generic "remove the entry" guidance inside `describeRestoreStatus` passed
1807
+ // all 163 tests, because the log assertions only demanded "path + key name"
1808
+ // — both of which the OLD one-size sentence also contains. `manualEdit` is
1809
+ // the single source for the status, the notify and the log, so the log is
1810
+ // asserted against the refusal it belongs to.
1811
+ askForNativePaste();
1812
+ writeKeybindings(`{ "app.editor.newLine": ["ctrl+enter"] }`);
1813
+ const logFile = join(tempDir, `diag-${process.pid}-single-source.log`);
1814
+ process.env.PI_ARCHIMEDES_LOG = logFile;
1815
+ let notified = "";
1816
+ try {
1817
+ const { ctx, notify } = makeCtx("tui");
1818
+ const outcome = await runNativePasteRestore(ctx);
1819
+ expect(outcome.kind).toBe("untouched");
1820
+ notified = (notify.mock.calls[0] as [string])[0];
1821
+ } finally {
1822
+ delete process.env.PI_ARCHIMEDES_LOG;
1823
+ }
1824
+
1825
+ const log = fs.readFileSync(logFile, "utf-8");
1826
+ expect(log).toContain(keybindingsPath());
1827
+ expect(log).toContain("pi's own paste answers that key");
1828
+ expect(log).not.toMatch(/remove the .*entry/i);
1829
+ expect(notified).toContain("pi's own paste answers that key");
1830
+ });
1831
+ });
1832
+
1833
+ // ══════════════════════════════════════════════════════════════════════════
1834
+ // The paste-key gate on the OFFER — issue #133
1835
+ // ══════════════════════════════════════════════════════════════════════════
1836
+ //
1837
+ // `suiteMayClaimPasteKey()` is the single predicate that decides whether the
1838
+ // suite holds pi's paste key this session, and until #133 the offer was not one
1839
+ // of its readers: `packages/image-paste/src/index.ts:201` (shortcut
1840
+ // registration) and `runNativePasteRestore` (`keybinding-offer.ts:778`) were its
1841
+ // only two callers. So a fresh profile with `suppressNativePaste: false` — the
1842
+ // escape hatch open, our shortcut deliberately NOT registered — still got the
1843
+ // offer, and accepting it wrote the very unbinding that leaves `ctrl+v` bound to
1844
+ // nobody: pi's action is cleared by the file and our handler is not there to
1845
+ // take the key. The next session's restore then emptied the file the offer had
1846
+ // just asked permission to create, and announced that it had.
1847
+ //
1848
+ // Two things the gate owes the user, both pinned below:
1849
+ //
1850
+ // * **Nothing to offer.** With the hatch open there is no shared key to
1851
+ // disentangle, so the offer does not ask for permission to create a file this
1852
+ // install has already decided it does not want, and emits no UI text at all.
1853
+ // * **Still armed.** The skip must not spend `keybindingsPromptDone`. The hatch
1854
+ // is reversible; a user who closes it again must meet the offer in a later
1855
+ // session, which is the whole point of an escape hatch you can undo.
1856
+
1857
+ /** The two notices that must never describe the same file — see the invariant. */
1858
+ const CREATED_TEXT = /Created ~\/\.pi\/agent\/keybindings\.json/;
1859
+ const RESTORED_TEXT = /Restored pi's native clipboard paste/;
1860
+
1861
+ describe("offerKeybindingFix — the paste-key gate (issue #133)", () => {
1862
+ it("the hatch is open: no confirm, no file, no tmp, no notify, resolves false", async () => {
1863
+ askForNativePaste();
1864
+ const { ctx, confirm, notify } = makeCtx("tui");
1865
+
1866
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
1867
+
1868
+ expect(confirm).not.toHaveBeenCalled();
1869
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
1870
+ expect(fs.existsSync(tmpPath())).toBe(false);
1871
+ // Not one UI message of any kind — in particular no "Created …" text.
1872
+ expect(notify).not.toHaveBeenCalled();
1873
+ });
1874
+
1875
+ it("the skip does NOT spend keybindingsPromptDone, and closing the hatch fires the offer on the same profile", async () => {
1876
+ // The reversible-hatch case end to end, on ONE profile: skip → settings
1877
+ // byte-identical → the user closes the hatch → the offer fires and accepting
1878
+ // it works. The middle assertion is what makes this a proof rather than a
1879
+ // hope: an implementation that marked the prompt done on the skip would
1880
+ // leave the offer dead for every later session.
1881
+ askForNativePaste();
1882
+ const skipped = makeCtx("tui");
1883
+ expect(await offerKeybindingFix(skipped.ctx)).toBe(false);
1884
+
1885
+ expect(readFlag()).toBe(false);
1886
+ // Nothing was added to the namespace: the hatch-off object is untouched.
1887
+ expect(readSettings()).toEqual({ [NAMESPACE]: { suppressNativePaste: false } });
1888
+
1889
+ writeSettings({ [NAMESPACE]: { suppressNativePaste: true } });
1890
+ const armed = makeCtx("tui");
1891
+ armed.confirm.mockResolvedValue(true);
1892
+ await offerKeybindingFix(armed.ctx);
1893
+
1894
+ expect(armed.confirm).toHaveBeenCalledTimes(1);
1895
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
1896
+ expect(readFlag()).toBe(true);
1897
+ });
1898
+
1899
+ it("deleting the setting instead of closing it also re-arms the offer on the same profile", async () => {
1900
+ // The other way back to the default: the key disappears entirely, and the
1901
+ // offer that the skip withheld is delivered.
1902
+ askForNativePaste();
1903
+ await expect(offerKeybindingFix(makeCtx("tui").ctx)).resolves.toBe(false);
1904
+ expect(readFlag()).toBe(false);
1905
+
1906
+ cleanupArtifacts();
1907
+ writeSettings({ [NAMESPACE]: { enabled: true } });
1908
+ const armed = makeCtx("tui");
1909
+ armed.confirm.mockResolvedValue(true);
1910
+ await offerKeybindingFix(armed.ctx);
1911
+
1912
+ expect(armed.confirm).toHaveBeenCalledTimes(1);
1913
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
1914
+ });
1915
+
1916
+ it("gate 1 still wins: extension disabled + the hatch open → no confirm, no file, no flag", async () => {
1917
+ // The gate matrix must not shift: the ADR 0012 in-package `enabled` read
1918
+ // stays the first thing the offer consults, whichever way the hatch is set.
1919
+ writeSettings({ [NAMESPACE]: { enabled: false, suppressNativePaste: false } });
1920
+ const settingsBefore = readSettings();
1921
+ const { ctx, confirm, notify } = makeCtx("tui");
1922
+
1923
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
1924
+
1925
+ expect(confirm).not.toHaveBeenCalled();
1926
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
1927
+ expect(readSettings()).toEqual(settingsBefore);
1928
+ expect(readFlag()).toBe(false);
1929
+ expect(notify).not.toHaveBeenCalled();
1930
+ });
1931
+
1932
+ it("the gate uses the restore's strict rule: only a strict false withholds the offer (\"false\", 0, null, \"off\", true all still ask)", async () => {
1933
+ // The two readers of the setting must not disagree about what "off" means:
1934
+ // `runNativePasteRestore` refuses to act on a stringified "false", so the
1935
+ // offer must not withhold itself on one either — otherwise the paste key
1936
+ // would be left with neither an offer nor a restore.
1937
+ for (const value of ["false", 0, null, "off", true]) {
1938
+ cleanupArtifacts();
1939
+ writeSettings({ [NAMESPACE]: { suppressNativePaste: value } });
1940
+ const { ctx, confirm } = makeCtx("tui");
1941
+ confirm.mockResolvedValue(false);
1942
+
1943
+ await offerKeybindingFix(ctx);
1944
+
1945
+ expect(confirm, JSON.stringify(value)).toHaveBeenCalledTimes(1);
1946
+ // A decline still spends the flag, exactly as the gate matrix records.
1947
+ expect(readFlag(), JSON.stringify(value)).toBe(true);
1948
+ }
1949
+ });
1950
+
1951
+ it("no skip path emits a Created text — and a hatch-off skip never touches the keybindings.json path", async () => {
1952
+ // Two claims, both asserted rather than narrated:
1953
+ //
1954
+ // * No skip announces anything. Every row resolves `false`, asks nothing and
1955
+ // notifies nothing, so no "Created …" can reach a user on any way out of
1956
+ // the offer.
1957
+ // * The hatch-off rows never look at `keybindings.json` at all. Gate 4's
1958
+ // check IS `existsSync(keybindingsPath)`, and `node:fs`'s `existsSync` is
1959
+ // spied in this file, so the rows below assert that the path is never
1960
+ // named: gate 1b demonstrably runs before gate 4, not merely somewhere
1961
+ // before the dialog. A mutation that moves the paste-key check after gate
1962
+ // 4 fails here (verified: the spy then records the path for every
1963
+ // hatch-off row) — the shape of regression this sentence is for.
1964
+ //
1965
+ // Gate 3's flag read is a different story and is NOT claimed here: it reads
1966
+ // settings.json through the same helper gate 1b itself uses, so its order is
1967
+ // not observable from outside. What matters about gate 3 is asserted
1968
+ // elsewhere — the reversible-hatch test above, and the gate matrix's rule
1969
+ // that a non-asking row leaves settings.json byte-identical.
1970
+ const skipPaths: Array<[string, Record<string, unknown>, Mode, boolean]> = [
1971
+ ["hatch open, tui", { suppressNativePaste: false }, "tui", false],
1972
+ ["hatch open, rpc", { suppressNativePaste: false }, "rpc", false],
1973
+ ["hatch open, json", { suppressNativePaste: false }, "json", false],
1974
+ ["hatch open, print", { suppressNativePaste: false }, "print", false],
1975
+ [
1976
+ "hatch open + flag already set",
1977
+ { suppressNativePaste: false, keybindingsPromptDone: true },
1978
+ "tui",
1979
+ false,
1980
+ ],
1981
+ [
1982
+ "hatch open + extension disabled",
1983
+ { enabled: false, suppressNativePaste: false },
1984
+ "tui",
1985
+ false,
1986
+ ],
1987
+ ["hatch open + a directory where the file would be", { suppressNativePaste: false }, "tui", true],
1988
+ ];
1989
+
1990
+ for (const [label, ns, mode, asDirectory] of skipPaths) {
1991
+ cleanupArtifacts();
1992
+ writeSettings({ [NAMESPACE]: ns });
1993
+ if (asDirectory) fs.mkdirSync(keybindingsPath(), { recursive: true });
1994
+ const { ctx, confirm, notify } = makeCtx(mode);
1995
+ confirm.mockResolvedValue(true);
1996
+ existsSyncSpy.mockClear();
1997
+
1998
+ await expect(offerKeybindingFix(ctx), label).resolves.toBe(false);
1999
+
2000
+ // Gate 4's check IS `existsSync(keybindingsPath)` and that call is spied,
2001
+ // so a hatch-off skip naming the path at all would mean the paste-key gate
2002
+ // ran after it. It must not be named — not for the absent file, not for the
2003
+ // directory stand-in.
2004
+ const namedPath = existsSyncSpy.mock.calls
2005
+ .map((c) => String(c[0]))
2006
+ .filter((p) => p === keybindingsPath());
2007
+ expect(namedPath, label).toEqual([]);
2008
+
2009
+ expect(confirm, label).not.toHaveBeenCalled();
2010
+ expect(
2011
+ callArgs(notify).some((c) => CREATED_TEXT.test(String(c[0]))),
2012
+ label,
2013
+ ).toBe(false);
2014
+ expect(notify, label).not.toHaveBeenCalled();
2015
+ if (asDirectory) {
2016
+ expect(fs.statSync(keybindingsPath()).isDirectory(), label).toBe(true);
2017
+ fs.rmSync(keybindingsPath(), { recursive: true, force: true });
2018
+ } else {
2019
+ expect(fs.existsSync(keybindingsPath()), label).toBe(false);
2020
+ }
2021
+ }
2022
+ });
2023
+ });
2024
+
2025
+ // ── the invariant: offer and restore are mutually exclusive ────────────────
2026
+ //
2027
+ // Stated in `docs/features/first-run-keybinding-offer.md` and in the code: for
2028
+ // one setting value only one of the two flows may act — the offer runs only when
2029
+ // `suiteMayClaimPasteKey()` is true, the restore only when it is false. So no
2030
+ // session creates `keybindings.json` and empties it again, and one run never
2031
+ // tells the user both "Created ~/.pi/agent/keybindings.json …" and "Restored
2032
+ // pi's native clipboard paste …" about one file. The sequenced pair across a
2033
+ // settings change is NOT covered here and is not a defect: accept the offer under
2034
+ // the default, close the hatch later, and the restore hands the key back — that
2035
+ // is the reversible hatch, exactly what the setting promises.
2036
+ //
2037
+ // This drives BOTH entry points in the order `meta/src/index.ts` runs them (the
2038
+ // offer from the merged first-run handler, the restore last in the registration
2039
+ // handler because a successful restore can call `ctx.reload()`) against real
2040
+ // files, so the invariant is an artefact rather than a comment.
2041
+
2042
+ describe("the invariant — the offer and the restore cannot both act on keybindings.json", () => {
2043
+ beforeEach(clearNoticeGuard);
2044
+
2045
+ /** What one session did, from the outside. */
2046
+ interface Session {
2047
+ offered: boolean;
2048
+ /** Whether the offer got as far as asking the user. */
2049
+ asked: boolean;
2050
+ restore: NativePasteRestoreOutcome;
2051
+ notices: string[];
2052
+ file: string | null;
2053
+ flag: boolean;
2054
+ }
2055
+
2056
+ /**
2057
+ * One session, both flows, meta's order. Base-shape ctxs (no `reload`), which
2058
+ * is what the measured Pi versions hand a `session_start` handler, so neither
2059
+ * flow reloads the runtime out from under the other and the file/notice
2060
+ * effects are all that is left to judge.
2061
+ *
2062
+ * The namespace is MERGED into whatever settings the profile already holds,
2063
+ * because that is what a user does: they edit one key in `settings.json`. So a
2064
+ * second call inside one test is a later session on the same profile, and it
2065
+ * keeps the flag an earlier session spent — replacing the namespace here would
2066
+ * quietly reset the state the sequenced-pair test depends on.
2067
+ */
2068
+ async function runSession(
2069
+ ns: Record<string, unknown>,
2070
+ opts?: { confirmAnswer?: boolean; existingFile?: string },
2071
+ ): Promise<Session> {
2072
+ const before = readSettings();
2073
+ const beforeNs = (before[NAMESPACE] ?? {}) as Record<string, unknown>;
2074
+ writeSettings({ ...before, [NAMESPACE]: { ...beforeNs, ...ns } });
2075
+ if (opts?.existingFile !== undefined) writeKeybindings(opts.existingFile);
2076
+
2077
+ const offer = makeCtx("tui");
2078
+ offer.confirm.mockResolvedValue(opts?.confirmAnswer ?? true);
2079
+ const offered = await offerKeybindingFix(offer.ctx);
2080
+
2081
+ const restore = makeCtx("tui");
2082
+ const outcome = await runNativePasteRestore(restore.ctx);
2083
+
2084
+ return {
2085
+ offered,
2086
+ asked: offer.confirm.mock.calls.length > 0,
2087
+ restore: outcome,
2088
+ notices: [
2089
+ ...callArgs(offer.notify).map((c) => String(c[0])),
2090
+ ...callArgs(restore.notify).map((c) => String(c[0])),
2091
+ ],
2092
+ file: fs.existsSync(keybindingsPath())
2093
+ ? fs.readFileSync(keybindingsPath(), "utf-8")
2094
+ : null,
2095
+ flag: readFlag(),
2096
+ };
2097
+ }
2098
+
2099
+ /** The hatch, open: the setting that flips which flow is allowed to act. */
2100
+ const HATCH_OFF: Record<string, unknown> = { suppressNativePaste: false };
2101
+ const hatchOn: Array<Record<string, unknown>> = [{}, { suppressNativePaste: true }];
2102
+
2103
+ it("hatch open, fresh profile: the session is silent end to end — no Created, no Restored, no file, still armed", async () => {
2104
+ const s = await runSession(HATCH_OFF);
2105
+
2106
+ expect(s.offered).toBe(false);
2107
+ expect(s.restore.kind).toBe("missing");
2108
+ expect(s.file).toBe(null);
2109
+ expect(s.flag).toBe(false);
2110
+ // Before #133 this frame held exactly two notices: the offer wrote the file
2111
+ // and said "Created …", the restore emptied it and said "Restored …". The
2112
+ // hatch-off install never sees either one now.
2113
+ expect(s.notices).toEqual([]);
2114
+ });
2115
+
2116
+ it("hatch open with our artefact already on disk: only the restore acts, and the offer never speaks about that file", async () => {
2117
+ const s = await runSession(HATCH_OFF, { existingFile: ARTEFACT_POST_123 });
2118
+
2119
+ expect(s.offered).toBe(false);
2120
+ expect(s.restore.kind).toBe("restored");
2121
+ expect(s.file).toBe("{}");
2122
+ expect(s.notices.filter((t) => CREATED_TEXT.test(t))).toEqual([]);
2123
+ expect(s.notices.filter((t) => RESTORED_TEXT.test(t))).toHaveLength(1);
2124
+ // The offer stayed armed: the file it would have offered to create is the
2125
+ // one the restore just handed back to pi.
2126
+ expect(s.flag).toBe(false);
2127
+ });
2128
+
2129
+ it.each([
2130
+ ["the default (key absent)", {}],
2131
+ ["hatch closed (suppressNativePaste: true)", { suppressNativePaste: true }],
2132
+ ] as Array<[string, Record<string, unknown>]>)(
2133
+ "%s: only the offer acts, and the restore reports suppressed",
2134
+ async (_label, ns) => {
2135
+ const s = await runSession(ns);
2136
+
2137
+ expect(s.offered).toBe(false); // base-shape ctx: no reload was available
2138
+ expect(s.restore.kind).toBe("suppressed");
2139
+ expect(s.file).toBe(SNIPPET); // the offer wrote the artefact …
2140
+ expect(s.flag).toBe(true); // … and the accept spent the flag
2141
+ expect(s.notices.filter((t) => RESTORED_TEXT.test(t))).toEqual([]);
2142
+ expect(s.notices.filter((t) => CREATED_TEXT.test(t))).toHaveLength(1);
2143
+ },
2144
+ );
2145
+
2146
+ it("the sequenced pair across a settings change is the hatch, not the breach: Created in session one, Restored in session two", async () => {
2147
+ // Greptile's read of the invariant, answered with a test rather than an
2148
+ // argument. Session one, the default setting: the offer fires, writes the
2149
+ // artefact and says "Created …".
2150
+ const first = await runSession({});
2151
+ expect(first.file).toBe(SNIPPET);
2152
+ expect(first.notices.filter((t) => CREATED_TEXT.test(t))).toHaveLength(1);
2153
+ expect(first.notices.filter((t) => RESTORED_TEXT.test(t))).toEqual([]);
2154
+
2155
+ // Session two on the SAME profile, with the user having closed the hatch: the
2156
+ // restore gives the key back and says "Restored …". So one install does carry
2157
+ // both texts — in order, one per session, each describing what that session
2158
+ // did. That is the reversible hatch from ADR 0030, and the mutual exclusion
2159
+ // above must not be read to forbid it: it binds a single configuration, and
2160
+ // this case has two.
2161
+ clearNoticeGuard();
2162
+ const second = await runSession(HATCH_OFF);
2163
+ expect(second.restore.kind).toBe("restored");
2164
+ expect(second.file).toBe("{}");
2165
+ expect(second.notices.filter((t) => RESTORED_TEXT.test(t))).toHaveLength(1);
2166
+ // The offer stayed out of session two on both counts: the flag was spent in
2167
+ // session one, and the hatch is open.
2168
+ expect(second.asked).toBe(false);
2169
+ expect(second.notices.filter((t) => CREATED_TEXT.test(t))).toEqual([]);
2170
+ });
2171
+
2172
+ it("no configuration makes both flows act on one file", async () => {
2173
+ for (const ns of [HATCH_OFF, ...hatchOn, { enabled: true, suppressNativePaste: false }]) {
2174
+ cleanupArtifacts();
2175
+ clearNoticeGuard();
2176
+ const s = await runSession(ns);
2177
+ const label = JSON.stringify(ns);
2178
+
2179
+ // The notices: never two verdicts about one file.
2180
+ const created = s.notices.some((t) => CREATED_TEXT.test(t));
2181
+ const restored = s.notices.some((t) => RESTORED_TEXT.test(t));
2182
+ expect(created && restored, `${label}: both notices: ${JSON.stringify(s.notices)}`).toBe(
2183
+ false,
2184
+ );
2185
+
2186
+ // The same exclusivity one level down, on the flows themselves: the
2187
+ // predicate that admits one must exclude the other.
2188
+ if (suiteMayClaimPasteKey()) {
2189
+ // The offer's configuration — the restore must have touched nothing.
2190
+ expect(s.restore.kind, label).toBe("suppressed");
2191
+ } else {
2192
+ // The restore's configuration — the offer must not have asked about,
2193
+ // written, or announced the file the restore is entitled to rewrite.
2194
+ expect(s.asked, label).toBe(false);
2195
+ expect(created, label).toBe(false);
2196
+ }
2197
+ }
2198
+ });
2199
+ });
2200
+