hop2 0.11.13 → 0.11.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.13",
3
+ "version": "0.11.15",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hop-session
3
- description: Conventions for agents working inside a hop terminal session (the HOP_SESSION environment variable is set). Humans watch these terminals live, often from a phone. Covers rendering math with hop math, publishing viewable files (HTML/PDF/images) with hop view, exposing a running local web server with hop port, ringing the bell for attention, and phone-friendly output.
3
+ description: Conventions for agents working inside a hop terminal session (the HOP_SESSION environment variable is set). Humans watch these terminals live, often from a phone. Covers rendering math with hop math, handing over results a terminal cannot render — plots, PDFs, images, rendered markdown write-ups — with hop view (always with --title), exposing a running local web server with hop port, ringing the bell for attention, and phone-friendly output.
4
4
  ---
5
5
 
6
6
  # Working in a hop terminal
@@ -24,21 +24,42 @@ formula in your response; do not repeat the raw LaTeX next to it. When you
24
24
  need the rendered text inside a file or message, capture it:
25
25
  `hop math '<latex>'` and copy the command's output verbatim.
26
26
 
27
- ## Showing files a terminal cannot render (HTML, PDF, images)
27
+ ## Showing results a terminal cannot render (plots, PDFs, write-ups)
28
28
 
29
- When the deliverable is a plot, a figure, an HTML report, a PDF, or any
30
- image, do not describe it — publish it and hand over the link:
29
+ When the deliverable is a plot, a figure, a report, a PDF, an image, or a
30
+ write-up worth reading as a document, do not describe it — publish it and
31
+ hand over the link:
31
32
 
32
33
  ```bash
33
- hop view results/roc_curve.png
34
- hop view report.html analysis.pdf
34
+ hop view --title "ROC curve: new model vs baseline" results/roc_curve.png
35
+ hop view --title "Q3 regression analysis" report.html analysis.pdf
35
36
  ```
36
37
 
37
- `hop view` copies the file behind hop's auth and prints a `View:` URL. The
38
- human taps it and their browser renders it — on the desktop wall or from the
39
- phone app, which opens these in-app. It rings the bell once for you, since a
40
- published file is usually the thing the human is waiting on. Re-running
41
- it after updating the file re-publishes under the same link.
38
+ **Always pass `--title`.** It is what the human sees in the session's Views
39
+ list and in the "new result" chip — a filename like `roc_curve.png` makes
40
+ them open it to find out what it is; a title means they already know. One
41
+ title describes the whole handoff, so publishing three plots under
42
+ "before/after/diff for the cache change" reads correctly.
43
+
44
+ Markdown is a first-class result, and you can pipe it straight from stdout
45
+ with no temp file — it is **rendered** (headings, tables, fenced code,
46
+ quotes), not dumped as raw text:
47
+
48
+ ```bash
49
+ generate_summary | hop view --name findings.md --title "What the profiler found" -
50
+ ```
51
+
52
+ Reach for this whenever the answer is long enough that reading it in a
53
+ terminal is worse than reading it as a page: comparisons, tables of numbers,
54
+ anything with structure. A table in the scrollback of a phone terminal is
55
+ close to unreadable; the same table as a view is not.
56
+
57
+ `hop view` copies the file behind hop's auth and prints a `View:` URL. It
58
+ rings the bell once for you, since a published result is usually the thing
59
+ the human is waiting on, and the session's Views surface shows it as new.
60
+ Re-running after updating the file re-publishes under the same link.
61
+ `hop view --list` shows what this session has published; `hop view --rm
62
+ <name>` unpublishes.
42
63
 
43
64
  ## Showing a running web server (dev server, local app)
44
65
 
@@ -734,3 +734,87 @@ test('an idle agent terminal attach is reaped after the TTL', async () => {
734
734
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
735
735
  { name: 'idle-agent-probe', internalName: s.internalName });
736
736
  });
737
+
738
+ // Dropping a file into the web terminal uploads its BYTES (a browser never
739
+ // reveals the real path) and hands back a host path the session can open.
740
+ // The filename is attacker-shaped input on its way to becoming a path.
741
+ function requestUpload(port, secret, reqPath, buffer) {
742
+ return new Promise((resolve, reject) => {
743
+ const req = http.request({
744
+ hostname: '127.0.0.1',
745
+ port,
746
+ path: reqPath,
747
+ method: 'POST',
748
+ headers: {
749
+ Authorization: `Bearer ${secret}`,
750
+ 'Content-Type': 'application/octet-stream',
751
+ 'Content-Length': buffer.length
752
+ }
753
+ }, (res) => {
754
+ let data = '';
755
+ res.on('data', (chunk) => data += chunk);
756
+ res.on('end', () => {
757
+ try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : null }); }
758
+ catch (e) { resolve({ status: res.statusCode, data }); }
759
+ });
760
+ });
761
+ req.on('error', reject);
762
+ req.end(buffer);
763
+ });
764
+ }
765
+
766
+ test('a dropped file uploads to host staging and never escapes it', async () => {
767
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
768
+ name: 'upload-probe',
769
+ cwd: tempDir
770
+ }, agentHeaders);
771
+ assert.equal(create.status, 200);
772
+ const session = create.data.sessionName;
773
+
774
+ // Every byte value, so a text-mode body handler would visibly corrupt it.
775
+ const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
776
+ const up = await requestUpload(
777
+ state.port, state.sessionSecret,
778
+ `/api/sessions/upload?name=${encodeURIComponent(session)}&filename=shot.png`,
779
+ bytes
780
+ );
781
+ assert.equal(up.status, 200);
782
+ assert.ok(up.data.path, 'returns the host path');
783
+ const landed = await fs.readFile(up.data.path);
784
+ assert.ok(landed.equals(bytes), 'bytes survive the round trip unmodified');
785
+ assert.equal(path.basename(up.data.path), 'shot.png');
786
+ assert.ok(up.data.path.startsWith(path.join(hopHome, 'uploads')),
787
+ `staged under HOP_HOME/uploads, got ${up.data.path}`);
788
+
789
+ // A second drop of the same name is a second file, not an overwrite.
790
+ const again = await requestUpload(
791
+ state.port, state.sessionSecret,
792
+ `/api/sessions/upload?name=${encodeURIComponent(session)}&filename=shot.png`,
793
+ Buffer.from('second')
794
+ );
795
+ assert.equal(again.status, 200);
796
+ assert.notEqual(again.data.path, up.data.path, 'collision gets its own path');
797
+ assert.ok((await fs.readFile(up.data.path)).equals(bytes), 'first upload untouched');
798
+
799
+ // Traversal in the filename must collapse to a basename inside staging.
800
+ const evil = await requestUpload(
801
+ state.port, state.sessionSecret,
802
+ `/api/sessions/upload?name=${encodeURIComponent(session)}&filename=${encodeURIComponent('../../../../etc/hop-pwned')}`,
803
+ Buffer.from('nope')
804
+ );
805
+ assert.equal(evil.status, 200);
806
+ assert.ok(evil.data.path.startsWith(path.join(hopHome, 'uploads')),
807
+ `traversal stayed in staging, got ${evil.data.path}`);
808
+ assert.equal(path.basename(evil.data.path), 'hop-pwned');
809
+
810
+ // An unknown session is not a place to write files.
811
+ const nowhere = await requestUpload(
812
+ state.port, state.sessionSecret,
813
+ '/api/sessions/upload?name=no-such-session-here&filename=x.txt',
814
+ Buffer.from('x')
815
+ );
816
+ assert.equal(nowhere.status, 404);
817
+
818
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
819
+ { name: 'upload-probe', internalName: session });
820
+ });