hop2 0.11.2 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/hay/apps/web/src/App.tsx +57 -101
- package/hay/apps/web/src/components/SessionSwitcher.tsx +87 -8
- package/hay/apps/web/src/styles.css +13 -0
- package/hay/apps/web/src/utils/voiceHold.ts +170 -0
- package/hay-web/assets/{index-BohAWpcL.css → index-BRpBf8QE.css} +1 -1
- package/hay-web/assets/index-BrLbmVOB.js +132 -0
- package/hay-web/assets/index-BrLbmVOB.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +152 -8
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +24 -3
- package/scripts/hay-host.js +11 -0
- package/tests/terminal-api.integration.test.cjs +26 -0
- package/hay-web/assets/index-DfGT3DuO.js +0 -130
- package/hay-web/assets/index-DfGT3DuO.js.map +0 -1
package/hay-web/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<!-- Terminal prompt chevron on a dark tile — reads "terminal app" at 16px. -->
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317181d'/%3E%3Cpath d='M11 9.5 L20.5 16 L11 22.5' fill='none' stroke='%23a78bfa' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
|
8
8
|
<title>Hay</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BrLbmVOB.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BRpBf8QE.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/hop
CHANGED
|
@@ -658,13 +658,60 @@ function getRuntimeSessionMetadata(name) {
|
|
|
658
658
|
return runtimeSessionMetadata.get(name) || null;
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
// Durable meta is read on every effective-config lookup, so a tiny cache
|
|
662
|
+
// keeps it off the hot path. Invalidated by mtime, so an external write
|
|
663
|
+
// (the hook, another process) is picked up without a restart.
|
|
664
|
+
const durableIdentityCache = new Map();
|
|
665
|
+
function readDurableIdentity(name) {
|
|
666
|
+
if (!name || !SESSION_NAME_RE.test(name)) return null;
|
|
667
|
+
let mtimeMs = 0;
|
|
668
|
+
try {
|
|
669
|
+
mtimeMs = fs.statSync(path.join(CLAUDE_SESSIONS_DIR, `${name}.meta`)).mtimeMs;
|
|
670
|
+
} catch (e) {
|
|
671
|
+
durableIdentityCache.delete(name);
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
const hit = durableIdentityCache.get(name);
|
|
675
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.value;
|
|
676
|
+
const value = readClaudeSessionMeta(name);
|
|
677
|
+
durableIdentityCache.set(name, { mtimeMs, value });
|
|
678
|
+
return value;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// THE identity accessor. Everything that asks "what is this session called,
|
|
682
|
+
// where is it filed, who made it" comes through here.
|
|
683
|
+
//
|
|
684
|
+
// The layers are not peers. saved/runtime configs are CACHES that any code
|
|
685
|
+
// path may recreate with defaults — `hop restore` writes one per session
|
|
686
|
+
// with the INTERNAL name — while durable meta is where a user's own answers
|
|
687
|
+
// are recorded and the only layer that survives the daemon. Merging
|
|
688
|
+
// saved-over-runtime and stopping there meant a crash + restore silently
|
|
689
|
+
// reverted every rename and un-filed every folder, because restore's freshly
|
|
690
|
+
// written cache outranked the file that knew better. Meta wins for the
|
|
691
|
+
// user-authored fields; everything else keeps the old precedence.
|
|
661
692
|
function getEffectiveSessionConfig(name) {
|
|
662
693
|
const saved = getSessionConfig(name);
|
|
663
694
|
const runtime = getRuntimeSessionMetadata(name);
|
|
664
|
-
|
|
665
|
-
|
|
695
|
+
const base = saved && runtime ? { ...runtime, ...saved } : (saved || runtime || null);
|
|
696
|
+
const durable = readDurableIdentity(name);
|
|
697
|
+
if (!durable) return base;
|
|
698
|
+
if (!base) {
|
|
699
|
+
return {
|
|
700
|
+
type: 'terminal',
|
|
701
|
+
displayName: durable.displayName,
|
|
702
|
+
folderId: durable.folderId,
|
|
703
|
+
createdBy: durable.createdBy,
|
|
704
|
+
cwd: durable.cwd
|
|
705
|
+
};
|
|
666
706
|
}
|
|
667
|
-
|
|
707
|
+
const merged = { ...base };
|
|
708
|
+
if (durable.displayName) merged.displayName = durable.displayName;
|
|
709
|
+
if (durable.folderId !== undefined) merged.folderId = durable.folderId || null;
|
|
710
|
+
if (durable.createdBy) merged.createdBy = durable.createdBy;
|
|
711
|
+
if (durable.tagline) merged.tagline = durable.tagline;
|
|
712
|
+
if (durable.parked !== undefined) merged.parked = durable.parked;
|
|
713
|
+
if (durable.archived !== undefined) merged.archived = durable.archived;
|
|
714
|
+
return merged;
|
|
668
715
|
}
|
|
669
716
|
|
|
670
717
|
function hasSavedSessionConfig(name) {
|
|
@@ -1994,6 +2041,9 @@ function saveHopConfig(config) {
|
|
|
1994
2041
|
// session should be the session you left, not a summary of it. Set
|
|
1995
2042
|
// resume_mode to "summary" (cheaper) or "ask" (leave the prompt alone).
|
|
1996
2043
|
const RESUME_PROMPT_RE = /Resuming the full session|Resume from summary|Resume full session as-is/i;
|
|
2044
|
+
// Claude Code's own chrome — proof the app is up even when the room reports
|
|
2045
|
+
// the wrapper shell as its foreground process (argv launch).
|
|
2046
|
+
const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc to interrupt|\? for shortcuts/i;
|
|
1997
2047
|
function getResumeMode() {
|
|
1998
2048
|
const mode = String(getHopConfig().resume_mode || '').toLowerCase();
|
|
1999
2049
|
return mode === 'summary' || mode === 'ask' ? mode : 'full';
|
|
@@ -3839,7 +3889,9 @@ function writeClaudeSessionRecord(internalName, rec) {
|
|
|
3839
3889
|
if (!internalName || !SESSION_NAME_RE.test(internalName)) return;
|
|
3840
3890
|
try { fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); } catch (e) { /* best effort */ }
|
|
3841
3891
|
try {
|
|
3842
|
-
|
|
3892
|
+
// Same crash-safety as the metas: records are what lets a claude
|
|
3893
|
+
// conversation resume at all.
|
|
3894
|
+
writePrivateFile(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.json`), JSON.stringify(rec), 0o600);
|
|
3843
3895
|
} catch (e) { /* best effort */ }
|
|
3844
3896
|
}
|
|
3845
3897
|
|
|
@@ -3881,7 +3933,14 @@ function writeClaudeSessionMeta(internalName, meta) {
|
|
|
3881
3933
|
if (!internalName || !SESSION_NAME_RE.test(internalName)) return;
|
|
3882
3934
|
try { fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); } catch (e) { /* best effort */ }
|
|
3883
3935
|
try {
|
|
3884
|
-
|
|
3936
|
+
// ATOMIC + FSYNCED, not writeFileSync. The thermal-sleep crash proved
|
|
3937
|
+
// the difference with a natural experiment: folders.json (written via
|
|
3938
|
+
// writePrivateFile the same night) SURVIVED, while every meta
|
|
3939
|
+
// rewritten in the prior three days — every rename, every folder
|
|
3940
|
+
// filing — vanished with the un-fsynced page cache. These files are
|
|
3941
|
+
// the restore inventory; they are exactly the data that must outlive
|
|
3942
|
+
// a power cut.
|
|
3943
|
+
writePrivateFile(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.meta`), JSON.stringify(meta), 0o600);
|
|
3885
3944
|
} catch (e) { /* best effort */ }
|
|
3886
3945
|
}
|
|
3887
3946
|
|
|
@@ -4276,6 +4335,19 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
4276
4335
|
// a second copy on top of it (that is how command lines piled
|
|
4277
4336
|
// up in a shell that was busy booting).
|
|
4278
4337
|
if (running) { finish('already-running'); continue; }
|
|
4338
|
+
// Same blindness applies before typing: a session whose
|
|
4339
|
+
// command was launched as argv shows a shell here while
|
|
4340
|
+
// claude runs beneath it. Typing again would land keystrokes
|
|
4341
|
+
// in claude's own prompt.
|
|
4342
|
+
try {
|
|
4343
|
+
const grid = await getRoomGridPreviewShared(job.plan.internalName);
|
|
4344
|
+
const text = String(grid?.text || '');
|
|
4345
|
+
if (RESUME_PROMPT_RE.test(text) || CLAUDE_UI_RE.test(text)) {
|
|
4346
|
+
job.state = 'verify';
|
|
4347
|
+
job.since = Date.now();
|
|
4348
|
+
continue;
|
|
4349
|
+
}
|
|
4350
|
+
} catch (e) { /* fall through to typing */ }
|
|
4279
4351
|
// A fresh shell reports its own name once it has execed; that
|
|
4280
4352
|
// is our prompt-ready signal, and it beats guessing with a timer.
|
|
4281
4353
|
if (Date.now() - job.since < 400) continue;
|
|
@@ -4287,7 +4359,25 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
4287
4359
|
continue;
|
|
4288
4360
|
}
|
|
4289
4361
|
if (job.state === 'verify') {
|
|
4290
|
-
|
|
4362
|
+
// "Is it running?" cannot be answered by the foreground
|
|
4363
|
+
// process alone. Argv launch runs `shell -lc "<cmd>; exec
|
|
4364
|
+
// shell -l"`, so the foreground process is the WRAPPER SHELL
|
|
4365
|
+
// for the whole life of the command — the very mechanism that
|
|
4366
|
+
// replaced typing made the process check blind. After the
|
|
4367
|
+
// crash restore, claude was sitting at its own resume prompt
|
|
4368
|
+
// while this loop reported "never-started", retyped the
|
|
4369
|
+
// command into that prompt, and gave up without answering it.
|
|
4370
|
+
// The screen is the honest witness: a resume prompt, or
|
|
4371
|
+
// claude's own chrome, means it is up.
|
|
4372
|
+
let screenSaysRunning = false;
|
|
4373
|
+
if (!running) {
|
|
4374
|
+
try {
|
|
4375
|
+
const grid = await getRoomGridPreviewShared(job.plan.internalName);
|
|
4376
|
+
const text = String(grid?.text || '');
|
|
4377
|
+
screenSaysRunning = RESUME_PROMPT_RE.test(text) || CLAUDE_UI_RE.test(text);
|
|
4378
|
+
} catch (e) { /* host busy — try again next tick */ }
|
|
4379
|
+
}
|
|
4380
|
+
if (running || screenSaysRunning) {
|
|
4291
4381
|
const key = resumeChoiceKey(getResumeMode());
|
|
4292
4382
|
if (!key) { finish('resumed'); continue; }
|
|
4293
4383
|
job.state = 'answer-prompt';
|
|
@@ -4990,6 +5080,14 @@ const resolveVia = (req) => {
|
|
|
4990
5080
|
};
|
|
4991
5081
|
|
|
4992
5082
|
function resolveActor(req) {
|
|
5083
|
+
const declared = String(req?.headers?.[ACTOR_HEADER] || '').toLowerCase();
|
|
5084
|
+
if (declared === 'agent' || declared === 'user') return declared;
|
|
5085
|
+
// Undeclared: infer from the auth path. Cookie = a human in the web UI.
|
|
5086
|
+
// Bearer token without the hop CLI's via stamp = automation.
|
|
5087
|
+
if (req?.hopAuthVia === 'token') {
|
|
5088
|
+
const via = String(req?.headers?.[VIA_HEADER] || '').toLowerCase();
|
|
5089
|
+
if (via !== 'cli' && via !== 'web') return 'agent';
|
|
5090
|
+
}
|
|
4993
5091
|
return resolveActorFromHeaders(req?.headers || {}, ACTOR_HEADER);
|
|
4994
5092
|
}
|
|
4995
5093
|
|
|
@@ -6077,9 +6175,25 @@ function syncSessionMetaFromRooms(rooms) {
|
|
|
6077
6175
|
for (const room of rooms) {
|
|
6078
6176
|
if (!room || typeof room.id !== 'string' || !SESSION_NAME_RE.test(room.id)) continue;
|
|
6079
6177
|
const cwd = typeof room.cwd === 'string' ? room.cwd : '';
|
|
6080
|
-
if (!cwd) continue;
|
|
6081
6178
|
const existing = readClaudeSessionMeta(room.id);
|
|
6082
|
-
|
|
6179
|
+
// A room with no cwd used to be skipped entirely — so it never got a
|
|
6180
|
+
// durable meta, and a session that only ever lived in the host's
|
|
6181
|
+
// memory was UNRECOVERABLE the moment the host died: `hop restore`
|
|
6182
|
+
// reads this directory, and a session absent from it cannot even be
|
|
6183
|
+
// named, let alone relaunched. That is how a thermal-sleep crash took
|
|
6184
|
+
// ten sessions with no trace. Every live room now leaves a record;
|
|
6185
|
+
// cwd is filled in when it becomes known.
|
|
6186
|
+
if (!existing) {
|
|
6187
|
+
writeClaudeSessionMeta(room.id, {
|
|
6188
|
+
internalName: room.id,
|
|
6189
|
+
...(cwd ? { cwd } : {}),
|
|
6190
|
+
createdAt: new Date().toISOString(),
|
|
6191
|
+
updatedAt: new Date().toISOString()
|
|
6192
|
+
});
|
|
6193
|
+
continue;
|
|
6194
|
+
}
|
|
6195
|
+
if (!cwd) continue;
|
|
6196
|
+
if (existing.cwd === cwd) continue;
|
|
6083
6197
|
// Carry an origin we already know; never invent one. This pass exists
|
|
6084
6198
|
// to backfill cwd, and defaulting createdBy to 'user' here silently
|
|
6085
6199
|
// stamped "human" onto every agent session whose origin lived only in
|
|
@@ -6299,6 +6413,28 @@ async function ensureExternalHayHostReady() {
|
|
|
6299
6413
|
}
|
|
6300
6414
|
try {
|
|
6301
6415
|
|
|
6416
|
+
// Last look before spawning: an already-running, healthy host for THIS hop
|
|
6417
|
+
// home means we must adopt it, not add a rival. Two hosts split the fleet —
|
|
6418
|
+
// the daemon canonicalizes one, so every room on the loser goes invisible
|
|
6419
|
+
// and dies unrecorded when it is reaped — and both thrash the same state
|
|
6420
|
+
// files. The spawn lock above only serializes racing spawners; a host that
|
|
6421
|
+
// survived a crash (or was started by hand) is outside its scope, which is
|
|
6422
|
+
// how two hosts came to exist across the thermal-sleep crash.
|
|
6423
|
+
try {
|
|
6424
|
+
const survivors = (await inspectExternalHayHosts(true))
|
|
6425
|
+
.filter((entry) => entry.healthy && hostBelongsToCurrentHome(entry, null));
|
|
6426
|
+
const adoptable = survivors
|
|
6427
|
+
.map((entry) => entry.host)
|
|
6428
|
+
.filter((host) => host && Number.isInteger(host.pid) && isProcessAlive(host.pid))
|
|
6429
|
+
.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))[0];
|
|
6430
|
+
if (adoptable) {
|
|
6431
|
+
log(`[host] adopting the running host (pid ${adoptable.pid}, port ${adoptable.port}) instead of spawning a rival`, DIM);
|
|
6432
|
+
writeHayHostStateFile(adoptable);
|
|
6433
|
+
hayHostState = adoptable;
|
|
6434
|
+
return adoptable;
|
|
6435
|
+
}
|
|
6436
|
+
} catch (e) { /* inspection failed — spawning is still the right move */ }
|
|
6437
|
+
|
|
6302
6438
|
const hostScript = path.join(__dirname, 'scripts', 'hay-host.js');
|
|
6303
6439
|
if (!fs.existsSync(hostScript)) {
|
|
6304
6440
|
throw new Error('Hay host script missing');
|
|
@@ -6940,6 +7076,14 @@ function startAuthProxy(authEmitter) {
|
|
|
6940
7076
|
: null;
|
|
6941
7077
|
const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET
|
|
6942
7078
|
|| tokenFromHeader === SESSION_SECRET;
|
|
7079
|
+
// How this request proved itself decides the DEFAULT actor. Humans
|
|
7080
|
+
// reach the API through the web UI (cookie) or the hop CLI (which
|
|
7081
|
+
// stamps x-hop-via); a raw Bearer-token call with neither is, in
|
|
7082
|
+
// practice, always automation — an agent's script, a probe, a cron.
|
|
7083
|
+
// Those callers rarely remember to declare x-hop-actor, and every
|
|
7084
|
+
// one that forgot used to land its sessions in the USER tab
|
|
7085
|
+
// (SelectProbe et al). Explicit headers still win outright.
|
|
7086
|
+
req.hopAuthVia = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ? 'cookie' : 'token';
|
|
6943
7087
|
|
|
6944
7088
|
// API: Auth status (used by local CLI to wait for remote login)
|
|
6945
7089
|
if (parsedUrl.pathname === '/api/auth' && req.method === 'GET') {
|
package/package.json
CHANGED
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require("node:fs");
|
|
20
20
|
const os = require("node:os");
|
|
21
|
+
|
|
22
|
+
// Crash-safe write: temp + fsync + atomic rename. Records are the restore
|
|
23
|
+
// inventory — an un-fsynced page cache at power loss must not be able to
|
|
24
|
+
// take a conversation's identity with it (the 07-29 thermal-sleep crash
|
|
25
|
+
// erased three days of plainly-written files while fsynced ones survived).
|
|
26
|
+
function writeDurable(file, data) {
|
|
27
|
+
try {
|
|
28
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
29
|
+
const fd = fs.openSync(tmp, "w", 0o600);
|
|
30
|
+
try {
|
|
31
|
+
fs.writeSync(fd, data);
|
|
32
|
+
fs.fsyncSync(fd);
|
|
33
|
+
} finally {
|
|
34
|
+
fs.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
fs.renameSync(tmp, file);
|
|
37
|
+
} catch {
|
|
38
|
+
try { fs.writeFileSync(file, data, { mode: 0o600 }); } catch { /* never disrupt claude */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
const path = require("node:path");
|
|
22
43
|
const { execFileSync } = require("node:child_process");
|
|
23
44
|
|
|
@@ -86,13 +107,13 @@ function recordSession(dir, hopSession, payload) {
|
|
|
86
107
|
const prev = JSON.parse(prevRaw);
|
|
87
108
|
if (prev && prev.sessionId && prev.sessionId !== sessionId && prev.cwd && prev.cwd !== cwd) {
|
|
88
109
|
try {
|
|
89
|
-
|
|
110
|
+
writeDurable(path.join(dir, `${hopSession}.other.json`), record);
|
|
90
111
|
} catch { /* ignore */ }
|
|
91
112
|
return;
|
|
92
113
|
}
|
|
93
114
|
} catch { /* no incumbent (or unreadable) — record normally */ }
|
|
94
115
|
try {
|
|
95
|
-
|
|
116
|
+
writeDurable(file, record);
|
|
96
117
|
} catch { /* ignore */ }
|
|
97
118
|
}
|
|
98
119
|
|
|
@@ -109,7 +130,7 @@ function bumpTurn(dir, hopSession, payload) {
|
|
|
109
130
|
at: new Date().toISOString()
|
|
110
131
|
});
|
|
111
132
|
try {
|
|
112
|
-
|
|
133
|
+
writeDurable(file, record);
|
|
113
134
|
} catch { /* ignore */ }
|
|
114
135
|
}
|
|
115
136
|
|
package/scripts/hay-host.js
CHANGED
|
@@ -378,6 +378,17 @@ async function main() {
|
|
|
378
378
|
);
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
// Periodic notekeeping: the shutdown handler only runs on a GRACEFUL
|
|
382
|
+
// exit, so a crash (thermal sleep, power cut, SIGKILL) used to leave no
|
|
383
|
+
// buffers at all — `hop restore` had screens to replay only when the
|
|
384
|
+
// stop was polite. Flush the same bounded tails every few minutes; a
|
|
385
|
+
// crash now costs at most the last interval of scrollback.
|
|
386
|
+
const bufferFlushTimer = setInterval(() => {
|
|
387
|
+
if (shuttingDown) return;
|
|
388
|
+
try { persistRoomBuffers(rooms); } catch (e) { /* next tick */ }
|
|
389
|
+
}, 4 * 60 * 1000);
|
|
390
|
+
bufferFlushTimer.unref?.();
|
|
391
|
+
|
|
381
392
|
const shutdown = () => {
|
|
382
393
|
shuttingDown = true; // keep restore records for rooms killed by this shutdown
|
|
383
394
|
// Persist tail buffers BEFORE closeAll() kills the PTYs, so a graceful
|
|
@@ -584,3 +584,29 @@ test('a display name freed by a later rename can be reused', async () => {
|
|
|
584
584
|
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Final', internalName: 'AliasOne' });
|
|
585
585
|
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Freed', internalName: 'AliasTwo' });
|
|
586
586
|
});
|
|
587
|
+
|
|
588
|
+
// Regression: an undeclared Bearer-token creation is automation, not a human.
|
|
589
|
+
// Agents' raw scripts rarely remember x-hop-actor, and every one that forgot
|
|
590
|
+
// landed its sessions in the USER tab (SelectProbe). Cookie-auth (the web UI)
|
|
591
|
+
// and the hop CLI's via stamp still default to user; explicit headers win.
|
|
592
|
+
test('bare token API creations default to agent origin', async () => {
|
|
593
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
594
|
+
{ name: 'BareTokenProbe', type: 'terminal', cwd: tempDir, startRuntime: true });
|
|
595
|
+
await delay(400);
|
|
596
|
+
const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
597
|
+
const s = list.data.sessions.find(x => x.internalName === 'BareTokenProbe');
|
|
598
|
+
assert.ok(s, 'session exists');
|
|
599
|
+
assert.equal(s.createdBy, 'agent', `undeclared token caller must be agent, got ${s.createdBy}`);
|
|
600
|
+
|
|
601
|
+
// The hop CLI's own via stamp keeps humans human.
|
|
602
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
603
|
+
{ name: 'CliUserProbe', type: 'terminal', cwd: tempDir, startRuntime: true },
|
|
604
|
+
{ 'X-Hop-Via': 'cli' });
|
|
605
|
+
await delay(400);
|
|
606
|
+
const list2 = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
607
|
+
const s2 = list2.data.sessions.find(x => x.internalName === 'CliUserProbe');
|
|
608
|
+
assert.equal(s2 && s2.createdBy, 'user', 'CLI-stamped caller stays user');
|
|
609
|
+
|
|
610
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
|
|
611
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CliUserProbe', internalName: 'CliUserProbe' });
|
|
612
|
+
});
|