hop2 0.11.21 → 0.11.22

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.
@@ -491,9 +491,10 @@ test('rename sticks across reconcile ticks and never manufactures a session', as
491
491
  name: 'RenameOrigin', type: 'terminal', cwd: tempDir, startRuntime: true
492
492
  });
493
493
  assert.equal(create.status, 200);
494
+ const originId = create.data.internalName;
494
495
 
495
496
  const renamed = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename', {
496
- oldName: 'RenameOrigin', newName: 'RenameTarget', internalName: 'RenameOrigin'
497
+ oldName: 'RenameOrigin', newName: 'RenameTarget', internalName: originId
497
498
  });
498
499
  assert.equal(renamed.status, 200);
499
500
 
@@ -502,7 +503,7 @@ test('rename sticks across reconcile ticks and never manufactures a session', as
502
503
  for (let i = 0; i < 4; i++) {
503
504
  await delay(1200);
504
505
  const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
505
- const entry = list.data.sessions.find(s => s.internalName === 'RenameOrigin');
506
+ const entry = list.data.sessions.find(s => s.internalName === originId);
506
507
  seen.push(entry ? entry.displayName : '(missing)');
507
508
  }
508
509
  assert.deepEqual(seen, ['RenameTarget', 'RenameTarget', 'RenameTarget', 'RenameTarget'],
@@ -513,12 +514,12 @@ test('rename sticks across reconcile ticks and never manufactures a session', as
513
514
  const after = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
514
515
  const byTarget = after.data.sessions.filter(s => s.displayName === 'RenameTarget');
515
516
  assert.equal(byTarget.length, 1, 'exactly one session may hold the new name');
516
- assert.equal(byTarget[0].internalName, 'RenameOrigin', 'the ORIGINAL session must be the one wearing it');
517
- assert.ok(!after.data.sessions.some(s => s.internalName === 'RenameTarget'),
518
- 'no phantom session may be created under the new name');
517
+ assert.equal(byTarget[0].internalName, originId, 'the ORIGINAL session must be the one wearing it');
518
+ assert.ok(!after.data.sessions.some(s => s.displayName === 'RenameOrigin'),
519
+ 'no phantom session may be created under the old name');
519
520
 
520
521
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
521
- { name: 'RenameTarget', internalName: 'RenameOrigin' });
522
+ { name: 'RenameTarget', internalName: originId });
522
523
  });
523
524
 
524
525
  // Regression: connecting by a name nothing owns must REFUSE, not create.
@@ -561,36 +562,38 @@ test('websocket attach to an unknown session refuses instead of creating one', a
561
562
  // with 409 "Session name already in use", with no way to free the name short
562
563
  // of restarting the daemon.
563
564
  test('a display name freed by a later rename can be reused', async () => {
564
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
565
+ const one = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
565
566
  { name: 'AliasOne', type: 'terminal', cwd: tempDir, startRuntime: true });
566
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
567
+ const two = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
567
568
  { name: 'AliasTwo', type: 'terminal', cwd: tempDir, startRuntime: true });
569
+ const oneId = one.data.internalName;
570
+ const twoId = two.data.internalName;
568
571
  await delay(600);
569
572
 
570
573
  // AliasOne: Freed → Final, leaving the alias "Freed" behind.
571
574
  for (const [from, to] of [['AliasOne', 'Freed'], ['Freed', 'Final']]) {
572
575
  const r = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
573
- { oldName: from, newName: to, internalName: 'AliasOne' });
576
+ { oldName: from, newName: to, internalName: oneId });
574
577
  assert.equal(r.status, 200, `rename ${from}→${to} failed: ${JSON.stringify(r.data)}`);
575
578
  }
576
579
 
577
580
  // "Freed" is now worn by nobody, so another session may take it.
578
581
  const reuse = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
579
- { oldName: 'AliasTwo', newName: 'Freed', internalName: 'AliasTwo' });
582
+ { oldName: 'AliasTwo', newName: 'Freed', internalName: twoId });
580
583
  assert.equal(reuse.status, 200, `a freed name must be reusable, got: ${JSON.stringify(reuse.data)}`);
581
584
 
582
585
  const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
583
586
  const freed = list.data.sessions.filter(s => s.displayName === 'Freed');
584
587
  assert.equal(freed.length, 1, 'exactly one session may wear the reused name');
585
- assert.equal(freed[0].internalName, 'AliasTwo');
588
+ assert.equal(freed[0].internalName, twoId);
586
589
 
587
590
  // A name a LIVE session still wears stays protected.
588
591
  const collide = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
589
- { oldName: 'Final', newName: 'Freed', internalName: 'AliasOne' });
592
+ { oldName: 'Final', newName: 'Freed', internalName: oneId });
590
593
  assert.equal(collide.status, 409, 'a name in active use must still be refused');
591
594
 
592
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Final', internalName: 'AliasOne' });
593
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Freed', internalName: 'AliasTwo' });
595
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Final', internalName: oneId });
596
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Freed', internalName: twoId });
594
597
  });
595
598
 
596
599
  // Regression: folder membership must survive every cache rebuild. The store
@@ -600,8 +603,9 @@ test('a display name freed by a later rename can be reused', async () => {
600
603
  // the live-rooms loop) already knew. Nine of eleven folder assignments
601
604
  // vanished from the UI after a host cycle while every meta on disk was right.
602
605
  test('folder membership survives restore re-registration and a daemon restart', async () => {
603
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
606
+ const created = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
604
607
  { name: 'FolderKeeper', type: 'terminal', cwd: tempDir, startRuntime: true });
608
+ const keeperId = created.data.internalName;
605
609
  await delay(600);
606
610
  const folder = await requestJson(state.port, state.sessionSecret, 'POST', '/api/folders', { name: 'KeeperShelf' });
607
611
  assert.equal(folder.status, 200, `folder create failed: ${JSON.stringify(folder.data)}`);
@@ -611,7 +615,7 @@ test('folder membership survives restore re-registration and a daemon restart',
611
615
  assert.equal(moved.status, 200, `move failed: ${JSON.stringify(moved.data)}`);
612
616
 
613
617
  const listedNow = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
614
- assert.equal(listedNow.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
618
+ assert.equal(listedNow.data.sessions.find(s => s.internalName === keeperId)?.folderId, folderId,
615
619
  'freshly moved session must list its folder');
616
620
 
617
621
  // Restore re-registration recreates cached session definitions — the write
@@ -619,25 +623,26 @@ test('folder membership survives restore re-registration and a daemon restart',
619
623
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', {});
620
624
  await delay(1200);
621
625
  const afterRestore = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
622
- assert.equal(afterRestore.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
626
+ assert.equal(afterRestore.data.sessions.find(s => s.internalName === keeperId)?.folderId, folderId,
623
627
  'folder membership must survive restore re-registration');
624
628
 
625
629
  const previousState = state;
626
630
  await stopDaemon(true);
627
631
  await launchDaemon(previousState);
628
632
  const afterRestart = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
629
- assert.equal(afterRestart.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
633
+ assert.equal(afterRestart.data.sessions.find(s => s.internalName === keeperId)?.folderId, folderId,
630
634
  'folder membership must survive a daemon restart');
631
635
 
632
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'FolderKeeper', internalName: 'FolderKeeper' });
636
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'FolderKeeper', internalName: keeperId });
633
637
  });
634
638
 
635
639
  // Session names resolve case-insensitively everywhere a name addresses a
636
640
  // session: exact-case wearers win, a UNIQUE folded match resolves, and two
637
641
  // sessions differing only by case stay exact-only rather than guessed at.
638
642
  test('session names resolve case-insensitively', async () => {
639
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
643
+ const caseCreate = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
640
644
  { name: 'CaseFold', type: 'terminal', cwd: tempDir, startRuntime: true });
645
+ const caseId = caseCreate.data.internalName;
641
646
  await delay(600);
642
647
 
643
648
  // A by-name API call accepts any casing — no internalName hint given.
@@ -651,20 +656,20 @@ test('session names resolve case-insensitively', async () => {
651
656
  assert.equal(caseOnly.status, 200, `case-only rename must be allowed: ${JSON.stringify(caseOnly.data)}`);
652
657
 
653
658
  const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
654
- const worn = list.data.sessions.filter(s => s.internalName === 'CaseFold');
659
+ const worn = list.data.sessions.filter(s => s.internalName === caseId);
655
660
  assert.equal(worn.length, 1, 'still exactly one session — folded lookups must never mint twins');
656
661
  assert.equal(worn[0].displayName, 'casekept', 'the case-only rename is the worn spelling');
657
662
 
658
663
  // A name another session wears — in ANY casing — is a collision.
659
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
664
+ const other = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
660
665
  { name: 'CaseOther', type: 'terminal', cwd: tempDir, startRuntime: true });
661
666
  await delay(400);
662
667
  const collide = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
663
- { oldName: 'CaseOther', newName: 'CASEKEPT', internalName: 'CaseOther' });
668
+ { oldName: 'CaseOther', newName: 'CASEKEPT', internalName: other.data.internalName });
664
669
  assert.equal(collide.status, 409, 'a differently-cased spelling of a worn name must be refused');
665
670
 
666
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'casekept', internalName: 'CaseFold' });
667
- await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CaseOther', internalName: 'CaseOther' });
671
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'casekept', internalName: caseId });
672
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CaseOther', internalName: other.data.internalName });
668
673
  });
669
674
 
670
675
  // Regression: an undeclared Bearer-token creation is automation, not a human.
@@ -676,7 +681,7 @@ test('bare token API creations default to agent origin', async () => {
676
681
  { name: 'BareTokenProbe', type: 'terminal', cwd: tempDir, startRuntime: true });
677
682
  await delay(400);
678
683
  const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
679
- const s = list.data.sessions.find(x => x.internalName === 'BareTokenProbe');
684
+ const s = list.data.sessions.find(x => x.displayName === 'BareTokenProbe');
680
685
  assert.ok(s, 'session exists');
681
686
  assert.equal(s.createdBy, 'agent', `undeclared token caller must be agent, got ${s.createdBy}`);
682
687
 
@@ -686,7 +691,7 @@ test('bare token API creations default to agent origin', async () => {
686
691
  { 'X-Hop-Via': 'cli' });
687
692
  await delay(400);
688
693
  const list2 = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
689
- const s2 = list2.data.sessions.find(x => x.internalName === 'CliUserProbe');
694
+ const s2 = list2.data.sessions.find(x => x.displayName === 'CliUserProbe');
690
695
  assert.equal(s2 && s2.createdBy, 'user', 'CLI-stamped caller stays user');
691
696
 
692
697
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
@@ -919,3 +924,100 @@ test('a user attach stamps lastUserSeenAt; a monitor tile does not', async () =>
919
924
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
920
925
  { name: 'seenprobe', internalName: internal });
921
926
  });
927
+
928
+ // Identity is minted, names are labels. A session's internal id is opaque
929
+ // (s_hex), never derived from the display name, so a rename touches only
930
+ // the label — and a display name that some renamed-away session was BORN
931
+ // with is free for reuse (deriving ids used to make that collide).
932
+ test('sessions carry an opaque invariant id; renames touch only the label', async () => {
933
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions', {
934
+ name: 'projectx', type: 'terminal', startRuntime: true, cwd: tempDir
935
+ });
936
+ assert.equal(create.status, 200);
937
+ assert.equal(create.data.displayName, 'projectx');
938
+ assert.match(create.data.internalName, /^s_[0-9a-f]{10}$/,
939
+ `id must be minted, got ${create.data.internalName}`);
940
+ const id = create.data.internalName;
941
+ await delay(400);
942
+
943
+ // Rename: same id, new label, and the entry the clients render says so.
944
+ const rename = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename', {
945
+ oldName: 'projectx', newName: 'projecty'
946
+ });
947
+ assert.equal(rename.status, 200, JSON.stringify(rename.data));
948
+ await delay(400);
949
+ const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
950
+ const entry = list.data.sessions.find(s => s.internalName === id);
951
+ assert.ok(entry, 'the invariant id survives the rename');
952
+ assert.equal(entry.displayName, 'projecty');
953
+ assert.equal(entry.name, 'projecty', 'every user-facing name field is the display name');
954
+
955
+ // The freed display name is genuinely free — the latent collision.
956
+ const reuse = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions', {
957
+ name: 'projectx', type: 'terminal', startRuntime: true, cwd: tempDir
958
+ });
959
+ assert.equal(reuse.status, 200, `freed name must be reusable: ${JSON.stringify(reuse.data)}`);
960
+ assert.notEqual(reuse.data.internalName, id, 'the new session is its own identity');
961
+
962
+ for (const s of [id, reuse.data.internalName]) {
963
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
964
+ { name: s, internalName: s });
965
+ }
966
+ });
967
+
968
+ // Restore's cwd arbiter must not trust a durable meta whose cwd sits in the
969
+ // transcript store. The meta is normally the room's last LIVE directory
970
+ // (the arbiter that catches a foreign claude's record) — but a shell left
971
+ // standing in ~/.claude/projects/* poisoned it, the arbiter then fired
972
+ // against the CORRECT record, and Accessibility-fork restored as a bare zsh
973
+ // in the transcript dir with its conversation gone.
974
+ test('restore does not let a transcript-store meta cwd override a good record', async () => {
975
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
976
+ name: 'metapoison', cwd: tempDir
977
+ }, agentHeaders);
978
+ assert.equal(create.status, 200);
979
+ const internal = create.data.sessionName;
980
+ const dir = path.join(hopHome, 'claude-sessions');
981
+ await fs.mkdir(dir, { recursive: true });
982
+ // Good record: a real conversation, in the real workspace.
983
+ await fs.writeFile(path.join(dir, `${internal}.json`), JSON.stringify({
984
+ sessionId: '22222222-3333-4444-5555-666666666666', cwd: tempDir,
985
+ launchCmd: 'claude', updatedAt: new Date().toISOString()
986
+ }), { mode: 0o600 });
987
+ // Poisoned meta: cwd inside a transcript store.
988
+ const meta = JSON.parse(await fs.readFile(path.join(dir, `${internal}.meta`), 'utf8').catch(() => '{}'));
989
+ await fs.writeFile(path.join(dir, `${internal}.meta`), JSON.stringify({
990
+ ...meta, internalName: internal, cwd: path.join(os.homedir(), '.claude', 'projects', '-Users-x')
991
+ }), { mode: 0o600 });
992
+ // Make the transcript "exist" where the record says it lives.
993
+ const tdir = path.join(os.homedir(), '.claude', 'projects', tempDir.replace(/[^A-Za-z0-9]/g, '-'));
994
+ await fs.mkdir(tdir, { recursive: true });
995
+ const tfile = path.join(tdir, '22222222-3333-4444-5555-666666666666.jsonl');
996
+ await fs.writeFile(tfile, '{"type":"user"}\n');
997
+
998
+ const plan = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', { dryRun: true });
999
+ assert.equal(plan.status, 200, JSON.stringify(plan.data));
1000
+ const mine = (plan.data.planned || plan.data.sessions || []).find(p => p.internalName === internal);
1001
+ // It is live, so restore skips it — instead exercise the planner through
1002
+ // a stopped copy: delete the runtime, keep the records, plan again.
1003
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: internal, internalName: internal });
1004
+ await fs.writeFile(path.join(dir, `${internal}.json`), JSON.stringify({
1005
+ sessionId: '22222222-3333-4444-5555-666666666666', cwd: tempDir,
1006
+ launchCmd: 'claude', updatedAt: new Date().toISOString()
1007
+ }), { mode: 0o600 });
1008
+ await fs.writeFile(path.join(dir, `${internal}.meta`), JSON.stringify({
1009
+ internalName: internal, displayName: 'metapoison',
1010
+ cwd: path.join(os.homedir(), '.claude', 'projects', '-Users-x')
1011
+ }), { mode: 0o600 });
1012
+ const plan2 = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', { dryRun: true });
1013
+ const p2 = (plan2.data.restored || []).find(p => p.name === 'metapoison');
1014
+ assert.ok(p2, `planned: ${JSON.stringify(plan2.data).slice(0, 300)}`);
1015
+ // The poisoned meta must not have demoted this to a plain shell.
1016
+ assert.equal(p2.warning, null, `no wrong-conversation warning, got: ${p2.warning}`);
1017
+ assert.match(String(p2.command || ''), /--resume 22222222/, `must resume the conversation, got ${p2.command}`);
1018
+ void mine;
1019
+
1020
+ await fs.rm(tfile, { force: true });
1021
+ await fs.rm(path.join(dir, `${internal}.json`), { force: true });
1022
+ await fs.rm(path.join(dir, `${internal}.meta`), { force: true });
1023
+ });