Repository navigation
Expand file tree
/
Copy pathsettings.html
More file actions
783 lines (719 loc) · 34.2 KB
/
Copy pathsettings.html
File metadata and controls
783 lines (719 loc) · 34.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings — Hugin</title>
<meta name="description" content="Pair the Hugin web app with your local agent or forty-two-watts instance to unlock LAN scanning and live driver testing.">
<link href="https://api.fontshare.com/css?f[]=satoshi@300,400,500,700,900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
:root {
--color-ground-paper: #0A0A0A;
--color-ground-cream: #161616;
--color-ground-ink: #F5F2E1;
--color-signal-orange: #E85D1F;
--color-signal-orange-600: #FF7A3D;
--color-confirmed-green: #22C55E;
--color-text-near-black: #F5F2E1;
--color-text-muted: #9CA3AF;
--color-border-whisper: rgba(245, 242, 225, 0.12);
--color-border-subtle: rgba(245, 242, 225, 0.06);
}
html { font-family: "Satoshi", system-ui, sans-serif; background: var(--color-ground-paper); color: var(--color-text-near-black); color-scheme: dark; }
body { background: var(--color-ground-paper); }
.font-mono { font-family: "JetBrains Mono", monospace; }
.micro-label { font-family: "JetBrains Mono", monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; }
[x-cloak] { display: none !important; }
/* Pulsing dot for the live status panel */
.status-dot { width: 8px; height: 8px; border-radius: 9999px; display: inline-block; }
.status-dot.green { background: var(--color-confirmed-green); box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); animation: pulse-green 2s infinite; }
.status-dot.orange { background: var(--color-signal-orange); }
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); }
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
</style>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
paper: 'var(--color-ground-paper)',
cream: 'var(--color-ground-cream)',
ink: 'var(--color-ground-ink)',
orange: {
50:'#FFF4ED',100:'#FFE8D9',200:'#FFD1B3',300:'#FFBA8C',400:'#FF9966',
500:'#E85D1F',600:'#FF7A3D',700:'#C0470F',800:'#8C3409',900:'#582106',950:'#3B1205',
},
'confirmed-green':'var(--color-confirmed-green)',
'destructive-red':'#FF6B6B',
'text-primary':'var(--color-text-near-black)',
'text-muted':'var(--color-text-muted)',
'border-whisper':'var(--color-border-whisper)',
'border-subtle':'var(--color-border-subtle)',
},
fontFamily: {
sans: '"Satoshi", system-ui, sans-serif',
mono: '"JetBrains Mono", monospace',
},
maxWidth: { prose: '65ch' },
},
},
};
</script>
</head>
<body class="min-h-dvh flex flex-col bg-paper text-text-primary antialiased"
x-data="settings()" x-init="init()">
<nav class="max-w-7xl mx-auto w-full px-6 py-6 flex items-center justify-between gap-4 flex-shrink-0">
<a href="/" class="flex items-center gap-2 text-lg font-bold tracking-tight text-ink no-underline">
<span class="w-3 h-3 bg-orange-500 inline-block"></span>
Hugin
</a>
<div class="flex items-center gap-6">
<a href="/catalog.html" class="hidden sm:inline text-sm font-medium text-text-muted hover:text-ink transition-colors no-underline">Catalog</a>
<a href="/workbench.html" class="hidden sm:inline text-sm font-medium text-text-muted hover:text-ink transition-colors no-underline">Workbench</a>
<a href="/settings.html" class="hidden sm:inline text-sm font-medium text-orange-500 transition-colors no-underline">Settings</a>
</div>
</nav>
<header class="max-w-3xl mx-auto w-full px-6 pt-12 pb-10">
<div class="micro-label text-orange-500 mb-3">Settings</div>
<h1 class="text-4xl md:text-6xl font-black tracking-tight leading-[1.05]">
Connect your local agent.
</h1>
<p class="mt-6 text-base md:text-lg text-text-muted max-w-prose leading-relaxed">
A Hugin agent runs on your network — your laptop, a Pi, or an existing
<a href="https://github.com/frahlg/forty-two-watts" class="text-ink underline decoration-orange-500 underline-offset-4 hover:text-orange-500">forty-two-watts</a>
box — and gives the web app real LAN access: ARP scanning, Modbus probes,
live Lua driver testing.
</p>
<p class="mt-3 text-sm text-text-muted max-w-prose">
It's optional. Browsing the catalog and authoring drivers via AI works
without one. Pairing unlocks the on-the-wire side.
</p>
</header>
<!-- Pair form -->
<section class="max-w-3xl mx-auto w-full px-6 pb-12">
<div class="border border-border-whisper bg-paper p-6 md:p-8">
<div class="micro-label text-text-muted mb-4">Pair an agent</div>
<div class="space-y-5">
<div>
<label class="block micro-label text-text-muted mb-2" for="agent-url">Agent URL</label>
<input id="agent-url"
type="url"
x-model="form.url"
@input="resetTest()"
placeholder="http://localhost:19090"
autocomplete="off"
spellcheck="false"
class="w-full bg-transparent font-mono text-sm text-ink placeholder:text-text-muted/60 py-3 px-0 border-0 border-b border-border-whisper focus:border-orange-500 focus:outline-none focus:ring-0 transition-colors">
<p class="mt-2 text-xs text-text-muted">Default for Hugin Agent is <span class="font-mono text-ink">http://localhost:19090</span>. Forty-two-watts boxes typically run on <span class="font-mono text-ink">http://<host>:8080</span>.</p>
</div>
<div>
<label class="block micro-label text-text-muted mb-2" for="agent-token">Pairing token</label>
<input id="agent-token"
type="password"
x-model="form.token"
@input="resetTest()"
placeholder="paste from terminal"
autocomplete="off"
spellcheck="false"
class="w-full bg-transparent font-mono text-sm text-ink placeholder:text-text-muted/60 py-3 px-0 border-0 border-b border-border-whisper focus:border-orange-500 focus:outline-none focus:ring-0 transition-colors">
<p class="mt-2 text-xs text-text-muted">The agent prints this on first launch. Stays in your browser — never sent to Sourceful's servers.</p>
</div>
<div class="flex flex-wrap items-center gap-3 pt-2">
<button @click="testConnection()"
:disabled="!form.url || testing"
class="bg-ink text-paper px-5 py-2.5 text-sm font-medium rounded-none hover:bg-orange-500 hover:text-paper transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
<span x-show="!testing">Test connection</span>
<span x-show="testing" x-cloak>Testing…</span>
</button>
<button @click="savePairing()"
:disabled="!testResult.ok || !form.token || saving"
class="bg-orange-500 hover:bg-orange-600 text-paper px-5 py-2.5 text-sm font-medium rounded-none transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
<span x-show="!saving">Save pairing</span>
<span x-show="saving" x-cloak>Verifying…</span>
</button>
<button @click="clearPairing()"
x-show="paired.kind"
x-cloak
class="text-sm font-medium text-text-muted hover:text-destructive-red transition-colors">
Clear pairing
</button>
</div>
</div>
<!-- Test connection result -->
<div x-show="testResult.ok" x-cloak class="mt-6 border border-confirmed-green/50 bg-confirmed-green/5 p-4">
<div class="flex items-center gap-2">
<span class="status-dot green"></span>
<span class="micro-label text-confirmed-green">Reachable</span>
</div>
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div>
<div class="micro-label text-text-muted mb-1">Name</div>
<div class="font-mono text-ink" x-text="testResult.info?.name || '—'"></div>
</div>
<div>
<div class="micro-label text-text-muted mb-1">Version</div>
<div class="font-mono text-ink" x-text="testResult.info?.version || '—'"></div>
</div>
<div>
<div class="micro-label text-text-muted mb-1">Protocol</div>
<div class="font-mono text-ink" x-text="'v' + (testResult.info?.protocol_version ?? '?')"></div>
</div>
<div>
<div class="micro-label text-text-muted mb-1">Capabilities</div>
<div class="flex flex-wrap gap-1.5 mt-0.5">
<template x-for="c in (testResult.info?.capabilities || [])" :key="c">
<span class="text-[10px] font-mono uppercase tracking-wider px-2 py-0.5 border border-orange-500 text-orange-500" x-text="c"></span>
</template>
<span x-show="!(testResult.info?.capabilities?.length)" class="text-text-muted font-mono">none</span>
</div>
</div>
</div>
</div>
<!-- Test/save error -->
<div x-show="error" x-cloak class="mt-6 border border-destructive-red/50 bg-destructive-red/5 p-4">
<div class="micro-label text-destructive-red mb-1">Error</div>
<div class="font-mono text-xs text-destructive-red break-words" x-text="error"></div>
</div>
<!-- Save success -->
<div x-show="savedFlash" x-cloak class="mt-6 border border-confirmed-green p-4 flex flex-wrap items-center justify-between gap-3">
<div>
<span class="micro-label text-confirmed-green">Paired</span>
<span class="ml-2 font-mono text-xs text-text-muted">stored locally in this browser</span>
</div>
<a href="/workbench.html" class="text-sm font-medium text-orange-500 hover:text-ink no-underline">Open the workbench →</a>
</div>
</div>
</section>
<!-- =========================================================
Pair a REMOTE agent (cloud-relayed via NATS).
========================================================= -->
<section class="max-w-3xl mx-auto w-full px-6 pb-12">
<div class="border border-border-whisper bg-paper p-6 md:p-8">
<div class="micro-label text-text-muted mb-4">…or pair a remote agent</div>
<p class="text-sm text-text-muted leading-relaxed mb-5 max-w-prose">
For agents that don't live on your laptop: the agent itself
registers, then hands off back to this page. The workbench
connects over WSS, your agent stays on its own network. No
port-forwarding, no inbound firewall holes.
</p>
<!-- Two states: actively completing a handoff, or waiting for one. -->
<template x-if="remotePairing">
<div class="border border-border-whisper p-4 font-mono text-sm text-ink">
<span class="status-dot orange"></span>
<span class="ml-2">Connecting to agent over NATS… minting owner credentials…</span>
</div>
</template>
<template x-if="!remotePairing">
<div class="space-y-4">
<ol class="text-sm text-text-muted leading-relaxed space-y-2 list-decimal pl-5">
<li>Install <code class="font-mono text-ink">hugin-agent</code> on the machine that has LAN access to your device.</li>
<li>Run <code class="font-mono text-ink">hugin-agent --register --gh-token=…</code> there.</li>
<li>It opens this page automatically with the pairing info — no copy-paste.</li>
</ol>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
<div class="border border-border-whisper bg-cream p-3">
<div class="micro-label text-orange-500 mb-1.5">macOS · Linux</div>
<code class="font-mono text-ink break-all">brew install srcfl/tap/hugin-agent</code>
</div>
<div class="border border-border-whisper bg-cream p-3">
<div class="micro-label text-orange-500 mb-1.5">Windows</div>
<code class="font-mono text-ink break-all">scoop install srcfl/hugin-agent</code>
</div>
<div class="border border-border-whisper bg-cream p-3">
<div class="micro-label text-orange-500 mb-1.5">Docker (e.g. 42W)</div>
<code class="font-mono text-ink break-all">ghcr.io/srcfl/hugin-agent:latest</code>
</div>
</div>
<p class="text-xs text-text-muted">
Full setup walkthrough in
<a href="/docs/" class="text-ink underline decoration-orange-500 underline-offset-4 hover:text-orange-500">the docs</a>.
<template x-if="!ghTokenPresent">
<span class="block mt-2 text-destructive-red">
You need a GitHub token first —
<a href="/workbench.html" class="text-ink underline decoration-destructive-red underline-offset-4 hover:text-orange-500">sign in on the workbench</a>
before running <code class="font-mono">--register</code>.
</span>
</template>
</p>
</div>
</template>
<div x-show="remoteError" x-cloak class="mt-4 border border-destructive-red/50 bg-destructive-red/5 p-4">
<div class="micro-label text-destructive-red mb-1">Error</div>
<div class="font-mono text-xs text-destructive-red break-words" x-text="remoteError"></div>
</div>
</div>
</section>
<!-- Status panel -->
<section class="max-w-3xl mx-auto w-full px-6 pb-12">
<div class="micro-label text-text-muted mb-3">Status</div>
<div class="border border-border-whisper bg-cream p-5 flex flex-wrap items-center gap-x-6 gap-y-3">
<template x-if="paired.kind === 'local'">
<div class="flex items-center gap-3 flex-1 min-w-0">
<span class="status-dot green flex-shrink-0"></span>
<div class="min-w-0">
<div class="text-sm">
Local agent at <span class="font-mono text-ink break-all" x-text="paired.url"></span>
</div>
<div class="mt-1 flex flex-wrap gap-1.5">
<template x-for="c in paired.capabilities" :key="c">
<span class="text-[10px] font-mono uppercase tracking-wider px-2 py-0.5 border border-border-whisper text-text-muted" x-text="c"></span>
</template>
<span x-show="!paired.capabilities?.length" class="text-[10px] font-mono uppercase tracking-wider text-text-muted">capabilities unknown</span>
</div>
</div>
</div>
</template>
<template x-if="paired.kind === 'remote'">
<div class="flex items-center gap-3 flex-1 min-w-0">
<span class="status-dot green flex-shrink-0"></span>
<div class="min-w-0">
<div class="text-sm">
Remote agent <span class="font-mono text-ink break-all" x-text="paired.agentId"></span>
</div>
<div class="text-xs text-text-muted font-mono break-all mt-0.5" x-text="paired.natsUrl"></div>
<div class="mt-1 flex flex-wrap gap-1.5">
<template x-for="c in paired.capabilities" :key="c">
<span class="text-[10px] font-mono uppercase tracking-wider px-2 py-0.5 border border-border-whisper text-text-muted" x-text="c"></span>
</template>
<span x-show="!paired.capabilities?.length" class="text-[10px] font-mono uppercase tracking-wider text-text-muted">capabilities unknown</span>
</div>
</div>
</div>
</template>
<template x-if="!paired.kind">
<div class="flex items-center gap-3">
<span class="status-dot orange"></span>
<span class="text-sm text-text-muted">No agent paired. The web app runs in browser-only mode.</span>
</div>
</template>
</div>
</section>
<!-- How to get an agent -->
<section class="max-w-3xl mx-auto w-full px-6 pb-24">
<div class="micro-label text-text-muted mb-3">How to get an agent</div>
<div class="grid gap-px bg-border-whisper border border-border-whisper md:grid-cols-2">
<article class="bg-paper p-6 flex flex-col gap-3">
<div class="micro-label text-orange-500">Option 1</div>
<h3 class="text-xl font-bold leading-snug">Hugin Agent</h3>
<p class="text-sm text-text-muted leading-relaxed">
Lightweight Go binary. Runs on your laptop or a Raspberry Pi. ~5 MB,
no dependencies, single static binary. Prints a pairing token on
first start.
</p>
<a href="https://github.com/srcfl/hugin-agent"
target="_blank" rel="noopener"
class="mt-auto pt-3 border-t border-border-subtle font-mono text-xs text-text-muted hover:text-orange-500 break-all no-underline">
github.com/srcfl/hugin-agent →
</a>
</article>
<article class="bg-paper p-6 flex flex-col gap-3">
<div class="micro-label text-orange-500">Option 2</div>
<h3 class="text-xl font-bold leading-snug">forty-two-watts</h3>
<p class="text-sm text-text-muted leading-relaxed">
Already running 42W on a home server? It speaks the same agent
protocol. Point Hugin at <span class="font-mono text-ink">http://<host>:8080</span>
and reuse the existing token. One stack, two surfaces.
</p>
<a href="https://github.com/frahlg/forty-two-watts"
target="_blank" rel="noopener"
class="mt-auto pt-3 border-t border-border-subtle font-mono text-xs text-text-muted hover:text-orange-500 break-all no-underline">
github.com/frahlg/forty-two-watts →
</a>
</article>
</div>
<p class="mt-6 text-xs text-text-muted max-w-prose leading-relaxed">
Both are open source. Sourceful never sees your LAN traffic — the agent
talks to your browser directly over <span class="font-mono text-ink">http://localhost</span>
or your private network. Tokens are stored only in this browser's
localStorage; clearing the pairing wipes them.
</p>
</section>
<footer class="mt-auto border-t border-border-whisper">
<div class="max-w-7xl mx-auto w-full px-6 py-8 flex flex-wrap justify-between gap-4 text-sm text-text-muted">
<div>
<span class="font-bold text-ink">Hugin</span> · made by <a href="https://sourceful.energy" class="text-text-muted hover:text-ink no-underline">Sourceful</a> ·
drivers land in <a href="https://github.com/frahlg/forty-two-watts" class="text-text-muted hover:text-ink no-underline">forty-two-watts</a>
</div>
<div class="flex gap-4 items-center flex-wrap">
<a href="/docs/" class="hover:text-ink no-underline">Docs</a>
<a href="/privacy.html" class="hover:text-ink no-underline">Privacy</a>
<a href="/terms.html" class="hover:text-ink no-underline">Terms</a>
<a href="https://github.com/srcfl/hugin-drivers" class="hover:text-ink no-underline">GitHub</a>
<a href="https://api.hugin.sourceful-labs.net/v1/health" class="font-mono text-xs hover:text-ink no-underline">api status →</a>
</div>
</div>
</footer>
<script>
// localStorage contract — these exact keys are read by workbench.html and
// any sibling page that wants to talk to a paired agent.
//
// Local-mode keys:
// hugin.agent_url HTTP base
// hugin.agent_token HTTP bearer
// hugin.agent_capabilities JSON array (set in both modes)
//
// Remote-mode keys:
// hugin.agent_id NATS agent id
// hugin.agent_nats_url wss:// URL
// hugin.agent_creds {jwt, seed} JSON
//
// Shared:
// hugin.agent_kind 'local' | 'remote'
// hugin.gh_token GitHub OAuth token (workbench-owned)
const LS_URL = 'hugin.agent_url';
const LS_TOK = 'hugin.agent_token';
const LS_CAPS = 'hugin.agent_capabilities';
const LS_KIND = 'hugin.agent_kind';
const LS_AGENT_ID = 'hugin.agent_id';
const LS_NATS_URL = 'hugin.agent_nats_url';
const LS_CREDS = 'hugin.agent_creds';
const LS_GH_TOKEN = 'hugin.gh_token';
const API_BASE = 'https://api.hugin.sourceful-labs.net';
function settings() {
return {
form: { url: '', token: '' },
testing: false,
saving: false,
testResult: { ok: false, info: null },
error: null,
savedFlash: false,
// paired tracks whichever mode is currently active. `kind` drives
// the Status panel + which keys are populated.
paired: {
kind: null, // 'local' | 'remote' | null
url: null, // local
token: null, // local
agentId: null, // remote
natsUrl: null, // remote
capabilities: [],
},
// When true, a successful Test should auto-Save and redirect. Set only
// by the agent-handoff fragment path so manual pasting still requires
// explicit clicks.
autoPair: false,
// Remote-mode state.
remotePairing: false, // button busy spinner
remoteError: null,
ghTokenPresent: false, // gates the Connect button
init() {
// Hydrate from localStorage so the form pre-fills if already paired.
this.ghTokenPresent = !!localStorage.getItem(LS_GH_TOKEN);
this.refreshPaired();
if (this.paired.kind === 'local') {
this.form.url = this.paired.url || '';
this.form.token = this.paired.token || '';
}
// Hand-off from `hugin-agent`. Two fragment shapes:
//
// Local pair (HTTP):
// #agent_url=…&token=…
// The agent's HTTP-mode startup opens us; we Test then Save.
//
// Remote pair (NATS):
// #pair_agent_id=…&kind=remote
// The agent's --register flow opens us with the agent_id it
// just registered. We call /v1/agents/{id}/owner-creds to mint
// matching browser-side creds.
//
// Fragment never reaches the server. We scrub the URL after
// reading so tokens don't sit in browser history.
const frag = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: '';
if (frag) {
const params = new URLSearchParams(frag);
// Scrub the URL before doing anything async.
history.replaceState(null, '', window.location.pathname);
const pairAgentId = (params.get('pair_agent_id') || '').trim();
const kind = (params.get('kind') || '').trim();
if (pairAgentId && kind === 'remote') {
// Remote-pair handoff. Auto-execute against the new
// /v1/agents/{id}/owner-creds endpoint.
this.$nextTick(() => this.applyRemotePairFragment(pairAgentId));
return;
}
const urlFromAgent = params.get('agent_url');
const tokFromAgent = params.get('token');
if (urlFromAgent) this.form.url = urlFromAgent;
if (tokFromAgent) this.form.token = tokFromAgent;
if (urlFromAgent && tokFromAgent) {
// The agent-handoff fragment is a trusted, one-click flow:
// the local agent itself opened this page. Auto-Test, then
// on success auto-Save and bounce to the workbench.
this.autoPair = true;
this.$nextTick(() => this.testConnection());
}
}
},
// refreshPaired reads localStorage into the reactive paired
// object. Called on init + after every save/clear so the
// Status panel always reflects what sibling pages will see.
refreshPaired() {
const kind = localStorage.getItem(LS_KIND);
let caps = [];
try { caps = JSON.parse(localStorage.getItem(LS_CAPS) || '[]'); }
catch (_) { caps = []; }
if (kind === 'remote' && localStorage.getItem(LS_AGENT_ID)) {
this.paired = {
kind: 'remote',
url: null,
token: null,
agentId: localStorage.getItem(LS_AGENT_ID),
natsUrl: localStorage.getItem(LS_NATS_URL),
capabilities: caps,
};
return;
}
const url = localStorage.getItem(LS_URL);
if (url) {
this.paired = {
kind: 'local',
url,
token: localStorage.getItem(LS_TOK),
agentId: null,
natsUrl: null,
capabilities: caps,
};
return;
}
this.paired = { kind: null, url: null, token: null, agentId: null, natsUrl: null, capabilities: [] };
},
// Strip trailing slash so '/v1/info' doesn't double up.
normalizeUrl(u) {
return (u || '').trim().replace(/\/+$/, '');
},
resetTest() {
// Any field edit invalidates the prior test.
this.testResult = { ok: false, info: null };
this.error = null;
this.savedFlash = false;
// If the user starts editing fields, cancel any pending auto-pair
// — they're taking control, don't redirect on them.
this.autoPair = false;
this._postSaveRedirect = false;
},
async fetchWithTimeout(url, opts = {}, ms = 5000) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), ms);
try {
return await fetch(url, { ...opts, signal: ctl.signal });
} finally {
clearTimeout(t);
}
},
async testConnection() {
const base = this.normalizeUrl(this.form.url);
if (!base) {
this.error = 'Agent URL is required.';
return;
}
this.testing = true;
this.error = null;
this.testResult = { ok: false, info: null };
try {
const r = await this.fetchWithTimeout(base + '/v1/info', {
method: 'GET',
// No auth — /v1/info is unauthenticated by protocol.
});
if (!r.ok) {
const txt = await r.text().catch(() => '');
throw new Error('Agent returned HTTP ' + r.status + (txt ? ': ' + txt.slice(0, 200) : ''));
}
const info = await r.json();
this.testResult = { ok: true, info };
// One-click pairing: when arrived via the agent fragment, chain
// straight into Save once Test passes — only if we have a token
// (the manual-paste path won't hit this because autoPair is false).
if (this.autoPair && (this.form.token || '').trim()) {
this.autoPair = false; // single-shot; don't loop on edits
this._postSaveRedirect = true; // savePairing() will bounce on success
await this.savePairing();
}
} catch (e) {
if (e.name === 'AbortError') {
this.error = 'Timed out after 5s. Is the agent running at ' + base + '?';
} else if (e instanceof TypeError) {
// Browser network error — CORS, DNS, refused, etc.
this.error = 'Could not reach the agent. Check the URL, that the agent is running, and that it allows requests from this origin (CORS).';
} else {
this.error = e.message || String(e);
}
} finally {
this.testing = false;
}
},
async savePairing() {
const base = this.normalizeUrl(this.form.url);
const token = (this.form.token || '').trim();
if (!base) { this.error = 'Agent URL is required.'; return; }
if (!token) { this.error = 'Pairing token is required.'; return; }
if (!this.testResult.ok) { this.error = 'Run "Test connection" first.'; return; }
this.saving = true;
this.error = null;
this.savedFlash = false;
try {
const r = await this.fetchWithTimeout(base + '/v1/health', {
method: 'GET',
headers: { 'Authorization': 'Bearer ' + token },
});
if (r.status === 401 || r.status === 403) {
throw new Error('Token rejected by agent (HTTP ' + r.status + ').');
}
if (!r.ok) {
const txt = await r.text().catch(() => '');
throw new Error('Agent /v1/health returned HTTP ' + r.status + (txt ? ': ' + txt.slice(0, 200) : ''));
}
// Health passed — persist. Capabilities come from the earlier
// /v1/info probe; sibling pages read this array verbatim.
//
// We also flip kind to 'local' and wipe any remote-mode keys
// so the two pairings stay mutually exclusive (workbench
// dispatches on kind).
const caps = this.testResult.info?.capabilities || [];
localStorage.setItem(LS_URL, base);
localStorage.setItem(LS_TOK, token);
localStorage.setItem(LS_CAPS, JSON.stringify(caps));
localStorage.setItem(LS_KIND, 'local');
localStorage.removeItem(LS_AGENT_ID);
localStorage.removeItem(LS_NATS_URL);
localStorage.removeItem(LS_CREDS);
this.refreshPaired();
this.savedFlash = true;
// If Save was kicked off by the auto-pair flow, send the user
// straight to the workbench. The manual-paste flow leaves the
// user on this page so they can read confirmation.
if (this._postSaveRedirect) {
this._postSaveRedirect = false;
setTimeout(() => { window.location.replace('/workbench.html'); }, 800);
}
} catch (e) {
if (e.name === 'AbortError') {
this.error = 'Timed out verifying token. Agent unreachable.';
} else if (e instanceof TypeError) {
this.error = 'Could not reach the agent to verify the token.';
} else {
this.error = e.message || String(e);
}
} finally {
this.saving = false;
}
},
clearPairing() {
// Wipe BOTH modes — keeping only one paired at a time is the
// contract.
for (const k of [LS_URL, LS_TOK, LS_CAPS, LS_KIND, LS_AGENT_ID, LS_NATS_URL, LS_CREDS]) {
localStorage.removeItem(k);
}
this.refreshPaired();
this.form = { url: '', token: '' };
this.testResult = { ok: false, info: null };
this.error = null;
this.remoteError = null;
this.savedFlash = false;
},
// applyRemotePairFragment — complete the agent's --register handoff.
//
// Fired when the fragment carries #pair_agent_id=…&kind=remote
// (set by `hugin-agent --register`). The agent has already
// registered itself and printed this URL; the browser side now
// mints matching OWNER-role creds.
//
// Steps:
// 1. Take the GitHub OAuth token from localStorage (set by the
// workbench sign-in). Bail with an actionable error if absent.
// 2. POST /v1/agents/{id}/owner-creds → owner JWT + seed scoped
// to *this exact agent_id*, with 30d TTL. Crucially this is
// not /v1/agents/register, which would mint a fresh id with
// no responder (the Sprint A phantom-agent bug).
// 3. Probe req.info via NATS to learn the agent's capabilities.
// Best-effort; agent may not yet have started its
// `hugin-agent --no-listen --no-browser` run.
// 4. Persist with kind='remote' + the agent_id from the URL.
//
// nats-client.js is loaded lazily.
async applyRemotePairFragment(agentId) {
if (this.remotePairing) return;
const ghToken = (localStorage.getItem(LS_GH_TOKEN) || '').trim();
if (!ghToken) {
this.remoteError = 'Sign in on the workbench first, then re-run `hugin-agent --register --gh-token=…` so it can hand off here again.';
return;
}
this.remotePairing = true;
this.remoteError = null;
this.savedFlash = false;
let conn = null;
try {
// 1. Mint owner-role creds bound to the agent_id from the URL.
const credsResp = await fetch(
`${API_BASE}/v1/agents/${encodeURIComponent(agentId)}/owner-creds`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${ghToken}`,
'Content-Type': 'application/json',
},
body: '{}',
},
);
if (!credsResp.ok) {
const body = await credsResp.text().catch(() => '');
throw new Error(`owner-creds: ${credsResp.status} ${body || credsResp.statusText}`);
}
const ownerCreds = await credsResp.json();
const natsUrl = ownerCreds.nats_url;
const creds = { jwt: ownerCreds.user_jwt, seed: ownerCreds.user_seed };
if (!natsUrl || !creds.jwt || !creds.seed) {
throw new Error('owner-creds response missing fields');
}
// 2. Probe req.info for capabilities. Short timeout; the
// agent may not yet be running its NATS-mode subscriber.
let caps = ['scan', 'probe', 'run-lua'];
try {
const mod = await import('/nats-client.js');
conn = await mod.connect({ natsUrl, creds });
const { response } = await conn.request(
`agent.${agentId}.req.info`,
{},
{ timeout: 5_000 },
);
if (Array.isArray(response?.capabilities) && response.capabilities.length) {
caps = response.capabilities;
}
} catch (_probeErr) {
// Agent offline; keep the default capabilities. The
// workbench shows "remote (offline)" until the user runs
// `hugin-agent` (no flags) to start its NATS subscriber.
}
// 3. Persist + flip kind to 'remote'. Clear local-mode keys
// so the two pairings stay mutually exclusive.
localStorage.setItem(LS_KIND, 'remote');
localStorage.setItem(LS_AGENT_ID, agentId);
localStorage.setItem(LS_NATS_URL, natsUrl);
localStorage.setItem(LS_CREDS, JSON.stringify(creds));
localStorage.setItem(LS_CAPS, JSON.stringify(caps));
localStorage.removeItem(LS_URL);
localStorage.removeItem(LS_TOK);
this.refreshPaired();
this.savedFlash = true;
// The fragment handoff is one-click: bounce to workbench.
setTimeout(() => { window.location.replace('/workbench.html'); }, 800);
} catch (e) {
this.remoteError = e?.message || String(e);
} finally {
if (conn) {
try { await conn.close(); } catch (_) {}
}
this.remotePairing = false;
}
},
};
}
</script>
</body>
</html>