@oar-sh/oar 0.9.3 → 0.9.5
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 +98 -0
- package/README.md +76 -14
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +385 -43
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- 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/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -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-session-worker.mjs +5 -0
- 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 +100 -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/steered-turn-merge.mjs +10 -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 +264 -11
- 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 +98 -19
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +84 -31
- 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 +346 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1743 -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 +148 -14
- package/shared/remote-relay-contract.mjs +556 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
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. */
|
|
@@ -2669,10 +2812,13 @@
|
|
|
2669
2812
|
kind='absorbed' and the steered message carries the continuing reply. A
|
|
2670
2813
|
steer cut off by Stop settles as kind='stopped' with a Resend button.
|
|
2671
2814
|
Markers are notes, not answers, so they render muted and compact.
|
|
2672
|
-
steered-turn-merge.mjs adds the adjacency classes.
|
|
2815
|
+
steered-turn-merge.mjs adds the adjacency classes.
|
|
2816
|
+
kind='answered-elsewhere' is no steer's marker but looks like one: it
|
|
2817
|
+
closes a message whose answer is on a background-continuation row. */
|
|
2673
2818
|
.msg.assistant.msg-absorbed .msg-bubble,
|
|
2674
2819
|
.msg.assistant.msg-folded .msg-bubble,
|
|
2675
|
-
.msg.assistant.msg-steer-stopped .msg-bubble
|
|
2820
|
+
.msg.assistant.msg-steer-stopped .msg-bubble,
|
|
2821
|
+
.msg.assistant.msg-answered-elsewhere .msg-bubble {
|
|
2676
2822
|
font-size: var(--fs-meta-md); color: var(--muted); font-style: italic;
|
|
2677
2823
|
padding: 6px 12px; opacity: 0.85;
|
|
2678
2824
|
}
|
|
@@ -2681,7 +2827,8 @@
|
|
|
2681
2827
|
.msg.assistant.msg-folded,
|
|
2682
2828
|
.msg.assistant.msg-steer-stopped { margin-top: -8px; }
|
|
2683
2829
|
.msg.assistant.msg-folded .msg-label,
|
|
2684
|
-
.msg.assistant.msg-steer-stopped .msg-label
|
|
2830
|
+
.msg.assistant.msg-steer-stopped .msg-label,
|
|
2831
|
+
.msg.assistant.msg-answered-elsewhere .msg-label { display: none; }
|
|
2685
2832
|
.msg.assistant.msg-steer-stopped .msg-bubble-actions { font-style: normal; }
|
|
2686
2833
|
.msg.user.msg-steered .msg-bubble { border-style: dashed; }
|
|
2687
2834
|
.msg.user.msg-steered .msg-label::after { content: ' · steered mid-turn'; }
|
|
@@ -5106,6 +5253,7 @@
|
|
|
5106
5253
|
<div id="chat-actions-menu-backdrop" onclick="window.closeChatActionsMenu && window.closeChatActionsMenu()"></div>
|
|
5107
5254
|
<div id="font-scale-indicator" aria-live="polite" aria-atomic="true" hidden>100%</div>
|
|
5108
5255
|
<div id="relay-toast" role="status" aria-live="polite"></div>
|
|
5256
|
+
<div id="pending-action-banner" role="status" aria-live="polite" hidden></div>
|
|
5109
5257
|
|
|
5110
5258
|
<!-- Main App -->
|
|
5111
5259
|
<div id="app">
|
|
@@ -5186,6 +5334,7 @@
|
|
|
5186
5334
|
<div id="image-edit-target" aria-live="polite"></div>
|
|
5187
5335
|
<div id="emoji-popup" aria-hidden="true"></div>
|
|
5188
5336
|
<div id="slash-autocomplete-popup" aria-hidden="true" role="listbox"></div>
|
|
5337
|
+
<div id="mention-autocomplete-popup" aria-hidden="true" role="listbox" aria-label="Remote relays"></div>
|
|
5189
5338
|
<details id="background-tasks-panel" hidden>
|
|
5190
5339
|
<summary id="background-tasks-summary"></summary>
|
|
5191
5340
|
<div id="background-panel-body">
|
|
@@ -5196,7 +5345,7 @@
|
|
|
5196
5345
|
<div class="input-row">
|
|
5197
5346
|
<input id="image-input" type="file" multiple hidden onchange="handleAttachmentInput(this.files); this.value='';">
|
|
5198
5347
|
<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>
|
|
5348
|
+
<textarea id="msg-input" placeholder="Message Copilot…" rows="1" aria-autocomplete="list" onkeydown="handleKey(event)" oninput="autoResize(this); updateComposerSlashMenu(this)"></textarea>
|
|
5200
5349
|
<select id="mode-select" title="Mode">
|
|
5201
5350
|
<option value="agent" selected>Agent</option>
|
|
5202
5351
|
<option value="ask">Ask</option>
|
|
@@ -5333,6 +5482,16 @@
|
|
|
5333
5482
|
tabindex="-1"
|
|
5334
5483
|
data-settings-tab="providers"
|
|
5335
5484
|
>Providers</button>
|
|
5485
|
+
<button
|
|
5486
|
+
id="settings-tab-relays"
|
|
5487
|
+
class="settings-tab"
|
|
5488
|
+
type="button"
|
|
5489
|
+
role="tab"
|
|
5490
|
+
aria-selected="false"
|
|
5491
|
+
aria-controls="settings-panel-relays"
|
|
5492
|
+
tabindex="-1"
|
|
5493
|
+
data-settings-tab="relays"
|
|
5494
|
+
>Relays</button>
|
|
5336
5495
|
<button
|
|
5337
5496
|
id="settings-tab-previews"
|
|
5338
5497
|
class="settings-tab"
|
|
@@ -5448,16 +5607,16 @@
|
|
|
5448
5607
|
</div>
|
|
5449
5608
|
</div>
|
|
5450
5609
|
<div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
5451
|
-
<label for="pwa-app-name-input">
|
|
5610
|
+
<label for="pwa-app-name-input">Relay name</label>
|
|
5452
5611
|
<input
|
|
5453
5612
|
id="pwa-app-name-input"
|
|
5454
5613
|
type="text"
|
|
5455
5614
|
maxlength="60"
|
|
5456
5615
|
placeholder="OAR"
|
|
5457
|
-
aria-label="
|
|
5616
|
+
aria-label="Relay name"
|
|
5458
5617
|
onchange="updatePwaAppName(this.value)"
|
|
5459
5618
|
>
|
|
5460
|
-
<div class="settings-help">
|
|
5619
|
+
<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
5620
|
</div>
|
|
5462
5621
|
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
5463
5622
|
<label for="update-auto-check-toggle">Check for updates automatically</label>
|
|
@@ -5949,6 +6108,96 @@
|
|
|
5949
6108
|
</div>
|
|
5950
6109
|
</div>
|
|
5951
6110
|
</div>
|
|
6111
|
+
<div
|
|
6112
|
+
id="settings-panel-relays"
|
|
6113
|
+
class="settings-panel"
|
|
6114
|
+
role="tabpanel"
|
|
6115
|
+
tabindex="0"
|
|
6116
|
+
aria-labelledby="settings-tab-relays"
|
|
6117
|
+
data-settings-panel="relays"
|
|
6118
|
+
hidden
|
|
6119
|
+
>
|
|
6120
|
+
<div id="remote-relays-self-section" class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
6121
|
+
<label>This relay</label>
|
|
6122
|
+
<div class="remote-relays-self-name-row">
|
|
6123
|
+
<span id="remote-relays-self-name" class="remote-relays-self-name">—</span>
|
|
6124
|
+
<span class="settings-help">Change it under General → Relay name.</span>
|
|
6125
|
+
</div>
|
|
6126
|
+
</div>
|
|
6127
|
+
<div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
6128
|
+
<label for="remote-relays-public-url-input">Public address</label>
|
|
6129
|
+
<input
|
|
6130
|
+
id="remote-relays-public-url-input"
|
|
6131
|
+
type="url"
|
|
6132
|
+
inputmode="url"
|
|
6133
|
+
autocomplete="off"
|
|
6134
|
+
autocapitalize="off"
|
|
6135
|
+
spellcheck="false"
|
|
6136
|
+
placeholder="https://…"
|
|
6137
|
+
aria-label="Public address of this relay"
|
|
6138
|
+
onchange="saveRemoteRelayPublicUrl(this.value)"
|
|
6139
|
+
>
|
|
6140
|
+
<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>
|
|
6141
|
+
<div id="remote-relays-self-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
|
|
6142
|
+
</div>
|
|
6143
|
+
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
6144
|
+
<label for="remote-relays-inbound-toggle">Accept prompts from other relays' agents</label>
|
|
6145
|
+
<input
|
|
6146
|
+
id="remote-relays-inbound-toggle"
|
|
6147
|
+
type="checkbox"
|
|
6148
|
+
onchange="toggleRemoteRelayInbound(this.checked)"
|
|
6149
|
+
aria-label="Accept prompts from other relays' agents"
|
|
6150
|
+
checked
|
|
6151
|
+
>
|
|
6152
|
+
</div>
|
|
6153
|
+
<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>
|
|
6154
|
+
<div id="remote-relays-list-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
|
|
6155
|
+
<label>Remote relays</label>
|
|
6156
|
+
<div id="remote-relays-list"></div>
|
|
6157
|
+
<div id="remote-relays-empty" class="settings-help">Loading remote relays…</div>
|
|
6158
|
+
<div id="remote-relays-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
|
|
6159
|
+
<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>
|
|
6160
|
+
</div>
|
|
6161
|
+
<div id="remote-relays-add-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
|
|
6162
|
+
<label for="remote-relays-add-url-input">Add a relay</label>
|
|
6163
|
+
<input
|
|
6164
|
+
id="remote-relays-add-url-input"
|
|
6165
|
+
type="url"
|
|
6166
|
+
inputmode="url"
|
|
6167
|
+
autocomplete="off"
|
|
6168
|
+
autocapitalize="off"
|
|
6169
|
+
spellcheck="false"
|
|
6170
|
+
placeholder="Paste the other relay's web address"
|
|
6171
|
+
aria-label="Paste the other relay's web address"
|
|
6172
|
+
onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
|
|
6173
|
+
>
|
|
6174
|
+
<div id="remote-relays-add-token-row" class="remote-relays-add-token-row" hidden>
|
|
6175
|
+
<label for="remote-relays-add-token-input">Token of the other relay</label>
|
|
6176
|
+
<input
|
|
6177
|
+
id="remote-relays-add-token-input"
|
|
6178
|
+
type="password"
|
|
6179
|
+
autocomplete="off"
|
|
6180
|
+
autocapitalize="off"
|
|
6181
|
+
spellcheck="false"
|
|
6182
|
+
placeholder="Only needed when it differs from this relay's token"
|
|
6183
|
+
aria-label="Token of the other relay"
|
|
6184
|
+
onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
|
|
6185
|
+
>
|
|
6186
|
+
</div>
|
|
6187
|
+
<button id="remote-relays-use-token-btn" class="remote-relays-link-btn" type="button" onclick="showRemoteRelayTokenField()">Use a different token</button>
|
|
6188
|
+
<label class="remote-relays-pair-back">
|
|
6189
|
+
<input id="remote-relays-pair-back-toggle" type="checkbox" checked>
|
|
6190
|
+
Also add this relay there
|
|
6191
|
+
</label>
|
|
6192
|
+
<div id="remote-relays-pair-back-hint" class="settings-help">
|
|
6193
|
+
The other relay gets this relay's public address and, if it uses a different token, this
|
|
6194
|
+
relay's token too: its agents can then work here. Removing a relay here does not revoke
|
|
6195
|
+
that; rotate this relay's token to lock it out.
|
|
6196
|
+
</div>
|
|
6197
|
+
<button id="remote-relays-add-btn" class="chat-title-action-btn" type="button" onclick="addRemoteRelayFromForm()">Add</button>
|
|
6198
|
+
<div id="remote-relays-add-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
|
|
6199
|
+
</div>
|
|
6200
|
+
</div>
|
|
5952
6201
|
<div
|
|
5953
6202
|
id="settings-panel-previews"
|
|
5954
6203
|
class="settings-panel"
|
|
@@ -6018,6 +6267,10 @@
|
|
|
6018
6267
|
<label for="push-event-cli-offline-toggle">CLI went offline</label>
|
|
6019
6268
|
<input id="push-event-cli-offline-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" disabled>
|
|
6020
6269
|
</div>
|
|
6270
|
+
<div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
|
|
6271
|
+
<label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
|
|
6272
|
+
<input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
|
|
6273
|
+
</div>
|
|
6021
6274
|
<label style="margin-top: 10px;">Devices</label>
|
|
6022
6275
|
<div id="push-device-list" style="width: 100%;"></div>
|
|
6023
6276
|
<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);
|