@oar-sh/oar 0.9.3 → 0.9.4
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/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
package/server/public/index.html
CHANGED
|
@@ -1507,6 +1507,39 @@
|
|
|
1507
1507
|
padding: 4px 0;
|
|
1508
1508
|
}
|
|
1509
1509
|
.thinking-bubble-header:empty { display: none; margin-bottom: 0; }
|
|
1510
|
+
/* Follow buttons (thinking-follow.js): 💭 thoughts, 📃 answer, 🛠️ tools.
|
|
1511
|
+
Chips on the right of the header, Stop stays left; the armed one gets
|
|
1512
|
+
the accent ring. */
|
|
1513
|
+
.thinking-follow-group {
|
|
1514
|
+
display: inline-flex;
|
|
1515
|
+
align-items: center;
|
|
1516
|
+
gap: 4px;
|
|
1517
|
+
flex-shrink: 0;
|
|
1518
|
+
margin-left: auto; /* right edge even when there is no Stop button */
|
|
1519
|
+
}
|
|
1520
|
+
.thinking-follow-btn {
|
|
1521
|
+
display: inline-flex;
|
|
1522
|
+
align-items: center;
|
|
1523
|
+
justify-content: center;
|
|
1524
|
+
min-width: 30px;
|
|
1525
|
+
height: 26px;
|
|
1526
|
+
padding: 0 6px;
|
|
1527
|
+
font-size: var(--fs-meta-md);
|
|
1528
|
+
line-height: 1;
|
|
1529
|
+
color: var(--muted);
|
|
1530
|
+
background: transparent;
|
|
1531
|
+
border: 1px solid transparent;
|
|
1532
|
+
border-radius: 6px;
|
|
1533
|
+
cursor: pointer;
|
|
1534
|
+
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
|
1535
|
+
}
|
|
1536
|
+
.thinking-follow-btn:hover { background: rgba(127, 127, 127, 0.12); }
|
|
1537
|
+
.thinking-follow-btn.active,
|
|
1538
|
+
.thinking-follow-btn[aria-pressed="true"] {
|
|
1539
|
+
color: var(--accent);
|
|
1540
|
+
border-color: var(--accent);
|
|
1541
|
+
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1542
|
+
}
|
|
1510
1543
|
.bubble-action-btn {
|
|
1511
1544
|
display: inline-flex;
|
|
1512
1545
|
align-items: center;
|
|
@@ -2343,6 +2376,48 @@
|
|
|
2343
2376
|
cursor: pointer;
|
|
2344
2377
|
}
|
|
2345
2378
|
.relay-toast-action:hover { background: rgba(210, 153, 34, 0.2); }
|
|
2379
|
+
/* Persistent app-wide banner for a queued host suspend / relay restart
|
|
2380
|
+
(host-suspend-ui.js). Same look as the toast, one row per pending action,
|
|
2381
|
+
sits just below the toast so both stay readable; takes pointer input for
|
|
2382
|
+
its Cancel buttons. */
|
|
2383
|
+
#pending-action-banner {
|
|
2384
|
+
display: none;
|
|
2385
|
+
position: fixed;
|
|
2386
|
+
top: calc(var(--header-safe-top, 0px) + 10px);
|
|
2387
|
+
left: 50%;
|
|
2388
|
+
transform: translateX(-50%);
|
|
2389
|
+
z-index: 12590;
|
|
2390
|
+
max-width: min(640px, calc(100vw - 24px));
|
|
2391
|
+
background: var(--bg2);
|
|
2392
|
+
background: color-mix(in srgb, var(--warn) 14%, var(--bg2));
|
|
2393
|
+
border: 1px solid var(--warn);
|
|
2394
|
+
color: var(--warn);
|
|
2395
|
+
border-radius: 10px;
|
|
2396
|
+
padding: 6px 12px;
|
|
2397
|
+
font-size: var(--fs-meta-sm);
|
|
2398
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
|
2399
|
+
pointer-events: auto;
|
|
2400
|
+
}
|
|
2401
|
+
#pending-action-banner.visible { display: flex; flex-direction: column; gap: 4px; }
|
|
2402
|
+
#relay-toast.visible ~ #app #pending-action-banner,
|
|
2403
|
+
#relay-toast.visible + #pending-action-banner { top: calc(var(--header-safe-top, 0px) + 56px); }
|
|
2404
|
+
.pending-action-row {
|
|
2405
|
+
display: flex;
|
|
2406
|
+
align-items: center;
|
|
2407
|
+
justify-content: center;
|
|
2408
|
+
gap: 8px;
|
|
2409
|
+
flex-wrap: wrap;
|
|
2410
|
+
text-align: center;
|
|
2411
|
+
}
|
|
2412
|
+
.pending-action-text { overflow-wrap: anywhere; }
|
|
2413
|
+
.pending-action-cancel { margin-left: 0; }
|
|
2414
|
+
.suspend-host-blockers {
|
|
2415
|
+
margin: 6px 0 10px;
|
|
2416
|
+
padding-left: 20px;
|
|
2417
|
+
color: var(--text);
|
|
2418
|
+
font-size: var(--fs-meta-md);
|
|
2419
|
+
}
|
|
2420
|
+
.suspend-host-blockers li { margin: 2px 0; overflow-wrap: anywhere; }
|
|
2346
2421
|
#model-metadata-blocker {
|
|
2347
2422
|
display: none;
|
|
2348
2423
|
align-items: center;
|
|
@@ -2441,8 +2516,11 @@
|
|
|
2441
2516
|
gap: 6px;
|
|
2442
2517
|
}
|
|
2443
2518
|
/* Slash-command autocompletion: same composer-anchored overlay as the emoji
|
|
2444
|
-
popup, but list-shaped. Row highlight follows the arrow keys only.
|
|
2445
|
-
|
|
2519
|
+
popup, but list-shaped. Row highlight follows the arrow keys only. The
|
|
2520
|
+
@relay mention popup (mention-autocomplete.mjs) is its twin; the two are
|
|
2521
|
+
never open at once. */
|
|
2522
|
+
#slash-autocomplete-popup,
|
|
2523
|
+
#mention-autocomplete-popup {
|
|
2446
2524
|
display: none;
|
|
2447
2525
|
position: absolute;
|
|
2448
2526
|
left: 16px;
|
|
@@ -2457,7 +2535,9 @@
|
|
|
2457
2535
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
|
2458
2536
|
z-index: 30;
|
|
2459
2537
|
}
|
|
2460
|
-
#slash-autocomplete-popup.visible
|
|
2538
|
+
#slash-autocomplete-popup.visible,
|
|
2539
|
+
#mention-autocomplete-popup.visible { display: block; }
|
|
2540
|
+
.mention-item { align-items: center; }
|
|
2461
2541
|
.slash-item {
|
|
2462
2542
|
display: flex; align-items: baseline; gap: 10px;
|
|
2463
2543
|
padding: 6px 8px; border-radius: 8px; cursor: pointer;
|
|
@@ -2469,7 +2549,8 @@
|
|
|
2469
2549
|
.slash-item-hint { cursor: default; }
|
|
2470
2550
|
.slash-item-hint:hover { background: transparent; }
|
|
2471
2551
|
.slash-item-hint .slash-item-name { color: var(--muted); }
|
|
2472
|
-
body.shared-reader-mode #slash-autocomplete-popup
|
|
2552
|
+
body.shared-reader-mode #slash-autocomplete-popup,
|
|
2553
|
+
body.shared-reader-mode #mention-autocomplete-popup { display: none; }
|
|
2473
2554
|
#emoji-popup.visible {
|
|
2474
2555
|
display: grid;
|
|
2475
2556
|
}
|
|
@@ -2619,6 +2700,68 @@
|
|
|
2619
2700
|
.preview-close:hover:not(:disabled) { border-color: #f85149; color: #f85149; }
|
|
2620
2701
|
.preview-close:disabled { opacity: 0.5; cursor: default; }
|
|
2621
2702
|
#settings-previews-empty { color: var(--muted); font-size: var(--fs-meta-sm); }
|
|
2703
|
+
/* Remote relays (Settings → Relays, the @mention popup): a health dot —
|
|
2704
|
+
online green, unreachable/refused red, never checked grey. */
|
|
2705
|
+
.remote-relay-dot {
|
|
2706
|
+
width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0;
|
|
2707
|
+
background: var(--relay-tone-offline);
|
|
2708
|
+
}
|
|
2709
|
+
.remote-relay-dot[data-status="online"] { background: var(--relay-tone-online); }
|
|
2710
|
+
.remote-relay-dot[data-status="offline"],
|
|
2711
|
+
.remote-relay-dot[data-status="unauthorized"],
|
|
2712
|
+
.remote-relay-dot[data-status="error"] { background: #f85149; }
|
|
2713
|
+
#remote-relays-list { width: 100%; }
|
|
2714
|
+
.remote-relay-tag {
|
|
2715
|
+
border: 1px solid var(--border); border-radius: 999px; padding: 0 7px;
|
|
2716
|
+
font-size: var(--fs-meta-sm); color: var(--muted); background: var(--bg3); white-space: nowrap;
|
|
2717
|
+
}
|
|
2718
|
+
.remote-relay-error { color: #f85149; overflow-wrap: anywhere; }
|
|
2719
|
+
.remote-relay-http-warning { color: var(--warn); opacity: 1; }
|
|
2720
|
+
label.remote-relay-permission { display: flex; align-items: center; gap: 6px; font-size: var(--fs-meta-sm); color: var(--muted); font-weight: normal; }
|
|
2721
|
+
.remote-relay-permission select {
|
|
2722
|
+
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
|
2723
|
+
border-radius: 6px; padding: 2px 6px; font-size: var(--fs-meta-sm);
|
|
2724
|
+
}
|
|
2725
|
+
.remote-relays-self-name-row { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
2726
|
+
.remote-relays-self-name { font-family: var(--mono, monospace); color: var(--text); }
|
|
2727
|
+
#remote-relays-public-url-input,
|
|
2728
|
+
#remote-relays-add-url-input,
|
|
2729
|
+
#remote-relays-add-token-input {
|
|
2730
|
+
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
|
2731
|
+
border-radius: 6px; padding: 6px 12px; outline: none; font-size: var(--fs-meta-md);
|
|
2732
|
+
width: min(420px, 100%); box-sizing: border-box;
|
|
2733
|
+
}
|
|
2734
|
+
.remote-relays-add-token-row { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
|
2735
|
+
.remote-relays-add-token-row[hidden] { display: none; }
|
|
2736
|
+
.remote-relays-add-token-row label { font-weight: normal; color: var(--muted); font-size: var(--fs-meta-sm); }
|
|
2737
|
+
.remote-relays-link-btn {
|
|
2738
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
2739
|
+
color: var(--accent); font-size: var(--fs-meta-sm); text-decoration: underline;
|
|
2740
|
+
}
|
|
2741
|
+
.remote-relays-link-btn[hidden] { display: none; }
|
|
2742
|
+
.settings-row label.remote-relays-pair-back { display: flex; align-items: center; gap: 8px; font-weight: normal; }
|
|
2743
|
+
#remote-relays-add-status[data-state="working"] { color: var(--muted); }
|
|
2744
|
+
#remote-relays-add-status[data-state="added"],
|
|
2745
|
+
#remote-relays-add-status[data-state="paired"] { color: #3fb950; }
|
|
2746
|
+
#remote-relays-add-status[data-state="pair-failed"],
|
|
2747
|
+
#remote-relays-add-status[data-state="needs-token"] { color: var(--warn); }
|
|
2748
|
+
#remote-relays-add-status[data-state="self"],
|
|
2749
|
+
#remote-relays-add-status[data-state="error"] { color: #f85149; }
|
|
2750
|
+
/* Provenance of a prompt another relay's agent sent (remote-relay-origin-view.mjs). */
|
|
2751
|
+
.msg-origin { margin-bottom: 6px; max-width: 100%; }
|
|
2752
|
+
.msg-origin-badge {
|
|
2753
|
+
display: inline-block; max-width: 100%; box-sizing: border-box;
|
|
2754
|
+
padding: 1px 8px; border-radius: 999px; font-size: var(--fs-meta-sm); line-height: 1.5;
|
|
2755
|
+
border: 1px solid color-mix(in srgb, currentColor 40%, transparent);
|
|
2756
|
+
background: color-mix(in srgb, currentColor 12%, transparent);
|
|
2757
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
2758
|
+
}
|
|
2759
|
+
.msg.user .msg-bubble a.msg-origin-badge { text-decoration: none; }
|
|
2760
|
+
.msg.user .msg-bubble a.msg-origin-badge:hover { text-decoration: underline; }
|
|
2761
|
+
.conv-origin-marker {
|
|
2762
|
+
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
2763
|
+
color: var(--muted); font-size: var(--fs-ui-chip);
|
|
2764
|
+
}
|
|
2622
2765
|
/* Transcript card: a preview published during this turn, pinned into the
|
|
2623
2766
|
response bubble. Compact single row; closed previews keep the label but
|
|
2624
2767
|
lose the dead link. */
|
|
@@ -5106,6 +5249,7 @@
|
|
|
5106
5249
|
<div id="chat-actions-menu-backdrop" onclick="window.closeChatActionsMenu && window.closeChatActionsMenu()"></div>
|
|
5107
5250
|
<div id="font-scale-indicator" aria-live="polite" aria-atomic="true" hidden>100%</div>
|
|
5108
5251
|
<div id="relay-toast" role="status" aria-live="polite"></div>
|
|
5252
|
+
<div id="pending-action-banner" role="status" aria-live="polite" hidden></div>
|
|
5109
5253
|
|
|
5110
5254
|
<!-- Main App -->
|
|
5111
5255
|
<div id="app">
|
|
@@ -5186,6 +5330,7 @@
|
|
|
5186
5330
|
<div id="image-edit-target" aria-live="polite"></div>
|
|
5187
5331
|
<div id="emoji-popup" aria-hidden="true"></div>
|
|
5188
5332
|
<div id="slash-autocomplete-popup" aria-hidden="true" role="listbox"></div>
|
|
5333
|
+
<div id="mention-autocomplete-popup" aria-hidden="true" role="listbox" aria-label="Remote relays"></div>
|
|
5189
5334
|
<details id="background-tasks-panel" hidden>
|
|
5190
5335
|
<summary id="background-tasks-summary"></summary>
|
|
5191
5336
|
<div id="background-panel-body">
|
|
@@ -5196,7 +5341,7 @@
|
|
|
5196
5341
|
<div class="input-row">
|
|
5197
5342
|
<input id="image-input" type="file" multiple hidden onchange="handleAttachmentInput(this.files); this.value='';">
|
|
5198
5343
|
<button id="emoji-btn" onclick="toggleEmojiPicker()" title="Emoji picker (Tip: Win + .)">🙂</button>
|
|
5199
|
-
<textarea id="msg-input" placeholder="Message Copilot…" rows="1" onkeydown="handleKey(event)" oninput="autoResize(this); updateComposerSlashMenu(this)"></textarea>
|
|
5344
|
+
<textarea id="msg-input" placeholder="Message Copilot…" rows="1" aria-autocomplete="list" onkeydown="handleKey(event)" oninput="autoResize(this); updateComposerSlashMenu(this)"></textarea>
|
|
5200
5345
|
<select id="mode-select" title="Mode">
|
|
5201
5346
|
<option value="agent" selected>Agent</option>
|
|
5202
5347
|
<option value="ask">Ask</option>
|
|
@@ -5333,6 +5478,16 @@
|
|
|
5333
5478
|
tabindex="-1"
|
|
5334
5479
|
data-settings-tab="providers"
|
|
5335
5480
|
>Providers</button>
|
|
5481
|
+
<button
|
|
5482
|
+
id="settings-tab-relays"
|
|
5483
|
+
class="settings-tab"
|
|
5484
|
+
type="button"
|
|
5485
|
+
role="tab"
|
|
5486
|
+
aria-selected="false"
|
|
5487
|
+
aria-controls="settings-panel-relays"
|
|
5488
|
+
tabindex="-1"
|
|
5489
|
+
data-settings-tab="relays"
|
|
5490
|
+
>Relays</button>
|
|
5336
5491
|
<button
|
|
5337
5492
|
id="settings-tab-previews"
|
|
5338
5493
|
class="settings-tab"
|
|
@@ -5448,16 +5603,16 @@
|
|
|
5448
5603
|
</div>
|
|
5449
5604
|
</div>
|
|
5450
5605
|
<div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
5451
|
-
<label for="pwa-app-name-input">
|
|
5606
|
+
<label for="pwa-app-name-input">Relay name</label>
|
|
5452
5607
|
<input
|
|
5453
5608
|
id="pwa-app-name-input"
|
|
5454
5609
|
type="text"
|
|
5455
5610
|
maxlength="60"
|
|
5456
5611
|
placeholder="OAR"
|
|
5457
|
-
aria-label="
|
|
5612
|
+
aria-label="Relay name"
|
|
5458
5613
|
onchange="updatePwaAppName(this.value)"
|
|
5459
5614
|
>
|
|
5460
|
-
<div class="settings-help">
|
|
5615
|
+
<div class="settings-help">Shown to other relays and used as the installed app's name. On Android, a rename reaches an already-installed app after a short delay, with one confirmation prompt showing the new name.</div>
|
|
5461
5616
|
</div>
|
|
5462
5617
|
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
5463
5618
|
<label for="update-auto-check-toggle">Check for updates automatically</label>
|
|
@@ -5949,6 +6104,96 @@
|
|
|
5949
6104
|
</div>
|
|
5950
6105
|
</div>
|
|
5951
6106
|
</div>
|
|
6107
|
+
<div
|
|
6108
|
+
id="settings-panel-relays"
|
|
6109
|
+
class="settings-panel"
|
|
6110
|
+
role="tabpanel"
|
|
6111
|
+
tabindex="0"
|
|
6112
|
+
aria-labelledby="settings-tab-relays"
|
|
6113
|
+
data-settings-panel="relays"
|
|
6114
|
+
hidden
|
|
6115
|
+
>
|
|
6116
|
+
<div id="remote-relays-self-section" class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
6117
|
+
<label>This relay</label>
|
|
6118
|
+
<div class="remote-relays-self-name-row">
|
|
6119
|
+
<span id="remote-relays-self-name" class="remote-relays-self-name">—</span>
|
|
6120
|
+
<span class="settings-help">Change it under General → Relay name.</span>
|
|
6121
|
+
</div>
|
|
6122
|
+
</div>
|
|
6123
|
+
<div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
6124
|
+
<label for="remote-relays-public-url-input">Public address</label>
|
|
6125
|
+
<input
|
|
6126
|
+
id="remote-relays-public-url-input"
|
|
6127
|
+
type="url"
|
|
6128
|
+
inputmode="url"
|
|
6129
|
+
autocomplete="off"
|
|
6130
|
+
autocapitalize="off"
|
|
6131
|
+
spellcheck="false"
|
|
6132
|
+
placeholder="https://…"
|
|
6133
|
+
aria-label="Public address of this relay"
|
|
6134
|
+
onchange="saveRemoteRelayPublicUrl(this.value)"
|
|
6135
|
+
>
|
|
6136
|
+
<div class="settings-help">How other relays reach this one — the address you open this relay at from another device. Needed for "Also add this relay there".</div>
|
|
6137
|
+
<div id="remote-relays-self-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
|
|
6138
|
+
</div>
|
|
6139
|
+
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
6140
|
+
<label for="remote-relays-inbound-toggle">Accept prompts from other relays' agents</label>
|
|
6141
|
+
<input
|
|
6142
|
+
id="remote-relays-inbound-toggle"
|
|
6143
|
+
type="checkbox"
|
|
6144
|
+
onchange="toggleRemoteRelayInbound(this.checked)"
|
|
6145
|
+
aria-label="Accept prompts from other relays' agents"
|
|
6146
|
+
checked
|
|
6147
|
+
>
|
|
6148
|
+
</div>
|
|
6149
|
+
<div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still read sessions here, but cannot send prompts, start sessions, answer questions or stop turns. The relay token stays the real boundary: anyone holding it has full access.</div>
|
|
6150
|
+
<div id="remote-relays-list-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
|
|
6151
|
+
<label>Remote relays</label>
|
|
6152
|
+
<div id="remote-relays-list"></div>
|
|
6153
|
+
<div id="remote-relays-empty" class="settings-help">Loading remote relays…</div>
|
|
6154
|
+
<div id="remote-relays-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
|
|
6155
|
+
<div class="settings-help">Agents in any session here can work on these relays once you mention one in the conversation (for example <code>@name</code>). "Agents may" limits what they can do there.</div>
|
|
6156
|
+
</div>
|
|
6157
|
+
<div id="remote-relays-add-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
|
|
6158
|
+
<label for="remote-relays-add-url-input">Add a relay</label>
|
|
6159
|
+
<input
|
|
6160
|
+
id="remote-relays-add-url-input"
|
|
6161
|
+
type="url"
|
|
6162
|
+
inputmode="url"
|
|
6163
|
+
autocomplete="off"
|
|
6164
|
+
autocapitalize="off"
|
|
6165
|
+
spellcheck="false"
|
|
6166
|
+
placeholder="Paste the other relay's web address"
|
|
6167
|
+
aria-label="Paste the other relay's web address"
|
|
6168
|
+
onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
|
|
6169
|
+
>
|
|
6170
|
+
<div id="remote-relays-add-token-row" class="remote-relays-add-token-row" hidden>
|
|
6171
|
+
<label for="remote-relays-add-token-input">Token of the other relay</label>
|
|
6172
|
+
<input
|
|
6173
|
+
id="remote-relays-add-token-input"
|
|
6174
|
+
type="password"
|
|
6175
|
+
autocomplete="off"
|
|
6176
|
+
autocapitalize="off"
|
|
6177
|
+
spellcheck="false"
|
|
6178
|
+
placeholder="Only needed when it differs from this relay's token"
|
|
6179
|
+
aria-label="Token of the other relay"
|
|
6180
|
+
onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
|
|
6181
|
+
>
|
|
6182
|
+
</div>
|
|
6183
|
+
<button id="remote-relays-use-token-btn" class="remote-relays-link-btn" type="button" onclick="showRemoteRelayTokenField()">Use a different token</button>
|
|
6184
|
+
<label class="remote-relays-pair-back">
|
|
6185
|
+
<input id="remote-relays-pair-back-toggle" type="checkbox" checked>
|
|
6186
|
+
Also add this relay there
|
|
6187
|
+
</label>
|
|
6188
|
+
<div id="remote-relays-pair-back-hint" class="settings-help">
|
|
6189
|
+
The other relay gets this relay's public address and, if it uses a different token, this
|
|
6190
|
+
relay's token too: its agents can then work here. Removing a relay here does not revoke
|
|
6191
|
+
that; rotate this relay's token to lock it out.
|
|
6192
|
+
</div>
|
|
6193
|
+
<button id="remote-relays-add-btn" class="chat-title-action-btn" type="button" onclick="addRemoteRelayFromForm()">Add</button>
|
|
6194
|
+
<div id="remote-relays-add-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
|
|
6195
|
+
</div>
|
|
6196
|
+
</div>
|
|
5952
6197
|
<div
|
|
5953
6198
|
id="settings-panel-previews"
|
|
5954
6199
|
class="settings-panel"
|
|
@@ -6018,6 +6263,10 @@
|
|
|
6018
6263
|
<label for="push-event-cli-offline-toggle">CLI went offline</label>
|
|
6019
6264
|
<input id="push-event-cli-offline-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" disabled>
|
|
6020
6265
|
</div>
|
|
6266
|
+
<div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
|
|
6267
|
+
<label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
|
|
6268
|
+
<input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
|
|
6269
|
+
</div>
|
|
6021
6270
|
<label style="margin-top: 10px;">Devices</label>
|
|
6022
6271
|
<div id="push-device-list" style="width: 100%;"></div>
|
|
6023
6272
|
<div class="settings-help">Notifications are only sent while no device has the app in the foreground. Content defaults to generic text; message previews are opt-in per device.</div>
|
package/server/relay-tools.md
CHANGED
|
@@ -26,4 +26,8 @@ Use `restart: true` in the request body when the user wants a real relay restart
|
|
|
26
26
|
|
|
27
27
|
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire on their own; close them with {action:"close"} when the user is done.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
30
|
+
|
|
31
|
+
## Remote relays
|
|
32
|
+
|
|
33
|
+
If you have a `remote_relay` tool, this relay is paired with other OAR relays and the tool works on them: list and read their sessions, prompt a session, start a new one and wait for its reply. Use it only when the user asks for work on another relay or mentions one, and start with `{"action":"list_relays"}`. A relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the remote agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|
|
@@ -284,6 +284,13 @@ export function createMessageRepository(db) {
|
|
|
284
284
|
LIMIT ?
|
|
285
285
|
`),
|
|
286
286
|
countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
|
|
287
|
+
// Deferred host suspend: which conversations still hold live turns.
|
|
288
|
+
listActiveQueueCountsByConversation: db.prepare(`
|
|
289
|
+
SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
|
|
290
|
+
FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
|
|
291
|
+
WHERE q.status IN ('pending','processing','parked')
|
|
292
|
+
GROUP BY q.conversation_id
|
|
293
|
+
`),
|
|
287
294
|
countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
|
|
288
295
|
setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ?, attempt_id = ? WHERE id = ?`),
|
|
289
296
|
setProcessingWithWorkerLease: db.prepare(`
|
|
@@ -112,6 +112,9 @@ export function createQuestionRepository(db) {
|
|
|
112
112
|
FROM subagent_runs
|
|
113
113
|
WHERE queue_message_id = ? AND status = 'running'
|
|
114
114
|
`),
|
|
115
|
+
countRunningSubagentRuns: db.prepare(`
|
|
116
|
+
SELECT COUNT(*) AS cnt FROM subagent_runs WHERE status = 'running'
|
|
117
|
+
`),
|
|
115
118
|
closeRunningSubagentRunsByQueueMessage: db.prepare(`
|
|
116
119
|
UPDATE subagent_runs
|
|
117
120
|
SET status = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.mjs';
|
|
4
|
+
|
|
5
|
+
// Storage for remote relays that is not app settings: which remote relays a
|
|
6
|
+
// conversation has unlocked (the user mentioned them), and the provenance of
|
|
7
|
+
// messages and conversations another relay's agent created. Tables and
|
|
8
|
+
// columns come from migrations/0005-remote-relays.mjs.
|
|
9
|
+
|
|
10
|
+
function parseOrigin(json) {
|
|
11
|
+
if (!json) return null;
|
|
12
|
+
try {
|
|
13
|
+
return normalizeRemoteRelayOrigin(JSON.parse(json));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createRemoteRelayRepository(db) {
|
|
20
|
+
const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
|
|
21
|
+
const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
|
|
22
|
+
const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
|
|
23
|
+
const deleteUnlocksForRelay = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ?`);
|
|
24
|
+
const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
|
|
25
|
+
const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
|
|
26
|
+
const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
|
|
27
|
+
const setConversationOrigin = db.prepare(`UPDATE conversations SET origin_json = ? WHERE id = ?`);
|
|
28
|
+
const getConversationOrigin = db.prepare(`SELECT origin_json FROM conversations WHERE id = ?`);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
hasUnlock(conversationId, remoteRelayId) {
|
|
32
|
+
return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
|
|
33
|
+
},
|
|
34
|
+
/** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
|
|
35
|
+
listUnlocks(conversationId) {
|
|
36
|
+
return listUnlocks.all(String(conversationId || '')).map((row) => ({
|
|
37
|
+
remoteRelayId: row.remote_relay_id,
|
|
38
|
+
messageId: row.message_id || null,
|
|
39
|
+
createdAt: row.created_at,
|
|
40
|
+
}));
|
|
41
|
+
},
|
|
42
|
+
/** True when this call created the unlock (false when it already existed). */
|
|
43
|
+
recordUnlock(conversationId, remoteRelayId, messageId = null, nowIso = new Date().toISOString()) {
|
|
44
|
+
const conversation = String(conversationId || '').trim();
|
|
45
|
+
const relay = String(remoteRelayId || '').trim();
|
|
46
|
+
if (!conversation || !relay) return false;
|
|
47
|
+
return insertUnlock.run(conversation, relay, messageId ? String(messageId) : null, nowIso).changes > 0;
|
|
48
|
+
},
|
|
49
|
+
forgetRelay(remoteRelayId) {
|
|
50
|
+
return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
|
|
51
|
+
},
|
|
52
|
+
forgetConversation(conversationId) {
|
|
53
|
+
return deleteUnlocksForConversation.run(String(conversationId || '')).changes;
|
|
54
|
+
},
|
|
55
|
+
setMessageOrigin(messageId, origin) {
|
|
56
|
+
const normalized = normalizeRemoteRelayOrigin(origin);
|
|
57
|
+
return setMessageOrigin.run(normalized ? JSON.stringify(normalized) : null, String(messageId || '')).changes > 0;
|
|
58
|
+
},
|
|
59
|
+
getMessageOrigin(messageId) {
|
|
60
|
+
return parseOrigin(getMessageOrigin.get(String(messageId || ''))?.origin_json);
|
|
61
|
+
},
|
|
62
|
+
setConversationOrigin(conversationId, origin) {
|
|
63
|
+
const normalized = normalizeRemoteRelayOrigin(origin);
|
|
64
|
+
return setConversationOrigin.run(normalized ? JSON.stringify(normalized) : null, String(conversationId || '')).changes > 0;
|
|
65
|
+
},
|
|
66
|
+
getConversationOrigin(conversationId) {
|
|
67
|
+
return parseOrigin(getConversationOrigin.get(String(conversationId || ''))?.origin_json);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parses an `origin_json` column value read by other queries. */
|
|
73
|
+
export function parseRemoteRelayOriginJson(json) {
|
|
74
|
+
return parseOrigin(json);
|
|
75
|
+
}
|
|
@@ -9,6 +9,11 @@ export function createSessionRepository(db) {
|
|
|
9
9
|
const runtimeProviderSelect = runtimeSessionsSupportProviders
|
|
10
10
|
? 'rs.model AS runtime_model, rs.provider_type AS runtime_provider_type, rs.provider_model AS runtime_provider_model'
|
|
11
11
|
: 'rs.model AS runtime_model, NULL AS runtime_provider_type, NULL AS runtime_provider_model';
|
|
12
|
+
// Provenance of conversations another relay's agent created (migration
|
|
13
|
+
// 0005); NULL on a schema that predates it.
|
|
14
|
+
const conversationsHaveOrigin = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
15
|
+
.some((column) => String(column?.name || '').trim() === 'origin_json');
|
|
16
|
+
const conversationOriginSelect = conversationsHaveOrigin ? 'c.origin_json' : 'NULL AS origin_json';
|
|
12
17
|
return {
|
|
13
18
|
// conversations
|
|
14
19
|
getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
|
|
@@ -16,7 +21,7 @@ export function createSessionRepository(db) {
|
|
|
16
21
|
getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
|
|
17
22
|
listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
|
|
18
23
|
runtimeSessionsSupportProviders,
|
|
19
|
-
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
24
|
+
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
20
25
|
insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
|
|
21
26
|
updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
|
|
22
27
|
updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
|
|
4
|
+
import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
5
|
+
import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
|
|
4
6
|
import {
|
|
5
7
|
extractRequestedSchema,
|
|
6
8
|
normalizeSchema,
|
|
@@ -33,6 +35,21 @@ function firstKnownValue(source, keyPaths = []) {
|
|
|
33
35
|
return null;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A stored question row that is this relay's Allow / Deny card for one of its
|
|
40
|
+
* own agent's calls to another relay (remote-relay-caller-context.mjs). The
|
|
41
|
+
* context lives in the row's request envelope.
|
|
42
|
+
*/
|
|
43
|
+
function isRemoteRelayApprovalRow(row) {
|
|
44
|
+
let envelope = null;
|
|
45
|
+
try {
|
|
46
|
+
envelope = JSON.parse(String(row?.request || ''));
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return isRemoteRelayApprovalQuestion(envelope);
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function extractContinuationOwnership(rawRequest) {
|
|
37
54
|
const parsed = rawRequest && typeof rawRequest === 'object' ? rawRequest : null;
|
|
38
55
|
if (!parsed) {
|
|
@@ -99,6 +116,9 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
99
116
|
DEFAULT_RELAY_MODE,
|
|
100
117
|
sessionWorkerRegistry,
|
|
101
118
|
pushDispatchService,
|
|
119
|
+
// Remote relays: another relay's agent answering a question is refused
|
|
120
|
+
// while the inbound switch is off. Absent in older wiring (no-op).
|
|
121
|
+
remoteRelayInbound = null,
|
|
102
122
|
} = deps;
|
|
103
123
|
|
|
104
124
|
const continuationRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true;
|
|
@@ -108,16 +128,26 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
108
128
|
sessionWorkerRegistry,
|
|
109
129
|
});
|
|
110
130
|
|
|
131
|
+
// A request from another relay's agent, by the test the inbound switch
|
|
132
|
+
// applies (X-OAR-Remote-Origin or a body origin). Made even without the
|
|
133
|
+
// remote-relay deps: an approval card is only ever this relay's user's.
|
|
134
|
+
function fromRemoteRelay(req) {
|
|
135
|
+
return readRemoteRelayRequest(req, remoteRelayInbound || {}).remote;
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
app.get('/api/relay-questions', auth, (req, res) => {
|
|
112
139
|
const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
|
|
113
140
|
const status = String(req.query.status || 'pending').trim() || 'pending';
|
|
114
|
-
|
|
141
|
+
let rows = stmts.listQuestions.all(status, conversationId, conversationId);
|
|
142
|
+
if (fromRemoteRelay(req)) rows = rows.filter((row) => !isRemoteRelayApprovalRow(row));
|
|
115
143
|
res.json({ questions: rows.map(formatQuestionRow) });
|
|
116
144
|
});
|
|
117
145
|
|
|
118
146
|
app.get('/api/relay-question/:id', auth, (req, res) => {
|
|
119
147
|
const row = stmts.getQuestion.get(req.params.id);
|
|
120
|
-
if (!row
|
|
148
|
+
if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
|
|
149
|
+
return res.status(404).json({ error: 'Not found' });
|
|
150
|
+
}
|
|
121
151
|
res.json({ question: formatQuestionRow(row) });
|
|
122
152
|
});
|
|
123
153
|
|
|
@@ -220,6 +250,7 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
220
250
|
});
|
|
221
251
|
|
|
222
252
|
app.post('/api/relay-question/:id/answer', auth, (req, res) => {
|
|
253
|
+
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
223
254
|
const id = String(req.params.id || '').trim();
|
|
224
255
|
const { answer, structuredAnswer, sdk_session_id, continuation_id, continuation_question_id } = req.body;
|
|
225
256
|
const sdkSessionId = normalizeId(sdk_session_id);
|
|
@@ -228,6 +259,12 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
228
259
|
|
|
229
260
|
const row = stmts.getQuestion.get(id);
|
|
230
261
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
262
|
+
// Allow / Deny on this relay's own call to another relay is its user's
|
|
263
|
+
// decision; the relay that call went to must never make it.
|
|
264
|
+
if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
|
|
265
|
+
console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=remote-relay-approval-from-remote`);
|
|
266
|
+
return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
|
|
267
|
+
}
|
|
231
268
|
if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
|
|
232
269
|
|
|
233
270
|
const schema = parseQuestionRequest(row.request_schema);
|
|
@@ -287,6 +324,9 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
287
324
|
app.post('/api/relay-question/:id/timeout', auth, (req, res) => {
|
|
288
325
|
const row = stmts.getQuestion.get(req.params.id);
|
|
289
326
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
327
|
+
if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
|
|
328
|
+
return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
|
|
329
|
+
}
|
|
290
330
|
if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
|
|
291
331
|
|
|
292
332
|
const result = stmts.timeoutQuestion.run(row.id);
|