@monster-orange/den 0.4.18 → 0.4.20
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/CHANGELOG.md +160 -0
- package/README.md +14 -13
- package/docs/distributions.md +148 -30
- package/docs/reference.md +1 -1
- package/package.json +2 -1
- package/skills/install-den/SKILL.md +35 -75
- package/src/shared/agents/den-claude.ts +40 -6
- package/src/shared/agents/guardrails/stop-hook.ts +30 -4
- package/src/shared/agents/guardrails.ts +40 -4
- package/src/shared/agents/handback.ts +22 -4
- package/src/shared/agents/personas.ts +32 -5
- package/src/shared/agents/runtime.ts +8 -0
- package/src/shared/agents/server-agent.ts +57 -0
- package/src/shared/agents/server-pack/server.md +45 -0
- package/src/shared/environment-manager/workspaces.ts +4 -0
- package/src/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/ui/cli/agent-ops.ts +41 -6
- package/src/ui/cli/create.ts +2 -2
- package/src/ui/cli/doctor-command.ts +54 -2
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/repo-command.ts +8 -1
- package/src/ui/cli/repo-detect.ts +58 -0
- package/src/ui/cli/server-agent-ops.ts +411 -0
- package/src/ui/mcp/server.ts +29 -12
- package/src/ui/mcp/tools.ts +4 -2
- package/src/ui/server/agent-routes.ts +1 -1
- package/src/ui/server/held-actions.ts +35 -7
- package/src/ui/server/index.ts +25 -6
- package/src/ui/server/public/agents-page.js +10 -1
- package/src/ui/server/public/error-page.js +39 -1
- package/src/ui/server/public/graph.css +21 -0
- package/src/ui/server/public/graph.js +49 -6
- package/src/ui/server/public/header.js +29 -7
- package/src/ui/server/public/i18n/es.js +38 -0
- package/src/ui/server/public/i18n/ja.js +40 -0
- package/src/ui/server/public/local-dev.html +22 -1
- package/src/ui/server/public/persona-ui.css +6 -0
- package/src/ui/server/public/server-agent-page.js +415 -0
- package/src/ui/server/public/server-agent.html +222 -0
- package/src/ui/server/public/settings-registry.js +22 -0
- package/src/ui/server/public/shell.js +67 -6
- package/src/ui/server/public/svc-panel.js +27 -14
- package/src/ui/server/public/topbar.js +13 -1
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/server-agent-routes.ts +176 -0
- package/src/ui/server/session-routes.ts +12 -1
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +3 -0
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"svp.startHere": "Start agent here",
|
|
201
201
|
"svp.openSvc": "Open in Services",
|
|
202
202
|
"svp.openGraph": "Open in Graph",
|
|
203
|
+
"graph.outside": "{s} is in {ws}, outside this View.",
|
|
204
|
+
"graph.showWs": "Show {ws}",
|
|
203
205
|
"svp.close": "Close",
|
|
204
206
|
"ag.new.folder": "Folder (optional)",
|
|
205
207
|
"ag.new.folderHelp":
|
|
@@ -428,6 +430,10 @@
|
|
|
428
430
|
"err.signin.step2":
|
|
429
431
|
"This page signs you in by itself once approved. The code expires in 10 minutes.",
|
|
430
432
|
"err.signin.waiting": "Waiting for approval…",
|
|
433
|
+
"err.signin.windowsSteps":
|
|
434
|
+
"Open Windows Terminal or PowerShell, paste this, and press Enter.",
|
|
435
|
+
"err.signin.nothingOpened":
|
|
436
|
+
"Nothing opened? Copy the command below and paste it into your terminal app.",
|
|
431
437
|
"err.signin.noTerminal":
|
|
432
438
|
"Don't have a terminal on {host}? From this computer, run:",
|
|
433
439
|
"err.signin.openTerminal": "Open Terminal",
|
|
@@ -510,6 +516,7 @@
|
|
|
510
516
|
"set.cli.web_port": "Your web port",
|
|
511
517
|
"set.cli.signin": "Browser sign-in",
|
|
512
518
|
"set.cli.sudo": "Admin password",
|
|
519
|
+
"set.cli.op_token": "1Password token source",
|
|
513
520
|
"set.access.owner": "Owner",
|
|
514
521
|
"set.access.devices": "Linked devices",
|
|
515
522
|
"ident.local": "Local",
|
|
@@ -691,6 +698,8 @@
|
|
|
691
698
|
"ap.do.repo_add.new":
|
|
692
699
|
'Add the repo at {path} to a new workspace "{workspace}"',
|
|
693
700
|
"ap.do.service_try": "Start {service}",
|
|
701
|
+
"ap.do.service_try.activates":
|
|
702
|
+
"Start {service} (this activates {workspace})",
|
|
694
703
|
"ap.says": "Agent says:",
|
|
695
704
|
"ap.review": "Review",
|
|
696
705
|
"ap.hide": "Hide details",
|
|
@@ -833,6 +842,34 @@
|
|
|
833
842
|
"ag.ws": "Workspace",
|
|
834
843
|
"ag.pal": "Agents › {ws} / {name}",
|
|
835
844
|
"ag.palStart": "Start agent…",
|
|
845
|
+
// ON-6: the server agent (server-agent-page.js, the rail, ⌘K).
|
|
846
|
+
"n.serverAgent": "__DEN_TITLE__ agent",
|
|
847
|
+
"ag.server": "Server",
|
|
848
|
+
"pal.askServer": "Ask __DEN_TITLE__",
|
|
849
|
+
"sa.sub.fresh":
|
|
850
|
+
"Let's set up __DEN_TITLE__. It reads only until you allow a write.",
|
|
851
|
+
"sa.sub":
|
|
852
|
+
"For anything about this server: add a repo, fix a failing service, change a setting, or explain what's running.",
|
|
853
|
+
"sa.login":
|
|
854
|
+
"If Claude asks you to log in, log in here. Then {name} starts.",
|
|
855
|
+
"sa.trust": "Claude asks whether to trust this folder. Choose Yes.",
|
|
856
|
+
"sa.model": "Model",
|
|
857
|
+
"sa.readsOnly":
|
|
858
|
+
"It acts only through __DEN_TITLE__'s own tools. Each change asks you first, in this browser.",
|
|
859
|
+
"sa.starting": "Starting the agent…",
|
|
860
|
+
"sa.idle": "{name} isn't running.",
|
|
861
|
+
"sa.start": "Start",
|
|
862
|
+
"sa.failed": "It didn't start: {why}. {cli} agents doctor says more.",
|
|
863
|
+
"sa.unread": "Couldn't read the agent's status. Is the web server running?",
|
|
864
|
+
"sa.none.h": "No runtime can run the agent yet",
|
|
865
|
+
"sa.none":
|
|
866
|
+
"The agent runs on an agent CLI from the runtime catalog. Install one, log in to it once, then reload this page.",
|
|
867
|
+
"sa.noTools": "can't use __DEN_TITLE__'s tools yet",
|
|
868
|
+
"sa.manual.h": "Or set it up yourself",
|
|
869
|
+
"sa.manual": "Every step the agent takes is also a command. In a terminal:",
|
|
870
|
+
"sa.runsOn": "Runs on",
|
|
871
|
+
"sa.openAgents": "Open in Agents",
|
|
872
|
+
"sa.saved": "Saved. The agent uses it from its next start.",
|
|
836
873
|
"ag.cap": "Up to 4 panes: {id}'s closed to make room (it keeps running)",
|
|
837
874
|
// AG-4b: the Agents tab's layouts and named tabs.
|
|
838
875
|
"ag.layout": "Layout",
|
|
@@ -953,6 +990,9 @@
|
|
|
953
990
|
agents:
|
|
954
991
|
'<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 10l3 2-3 2M12 15h5"/>',
|
|
955
992
|
edge: '<path d="M12 3l8 4v5c0 4.5-3.4 8.3-8 9-4.6-.7-8-4.5-8-9V7z"/><path d="M8 12h8M12 8v8"/>',
|
|
993
|
+
// ON-6: the server agent, a speech bubble with a spark.
|
|
994
|
+
assistant:
|
|
995
|
+
'<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-8l-4 3v-3H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M12 8v4M10 10h4"/>',
|
|
956
996
|
// SEC-2a: who this browser is (the rail footer's identity chip).
|
|
957
997
|
person:
|
|
958
998
|
'<circle cx="12" cy="8" r="3.5"/><path d="M5 20c.8-3.6 3.6-5.5 7-5.5s6.2 1.9 7 5.5"/>',
|
|
@@ -1813,6 +1853,16 @@
|
|
|
1813
1853
|
href: "/agents",
|
|
1814
1854
|
keywords: ["sessions", "terminal"],
|
|
1815
1855
|
},
|
|
1856
|
+
// ON-6: the server agent, this Den's own: one click away, after Agents.
|
|
1857
|
+
{
|
|
1858
|
+
id: "server-agent",
|
|
1859
|
+
label: "n.serverAgent",
|
|
1860
|
+
icon: "assistant",
|
|
1861
|
+
group: "develop",
|
|
1862
|
+
order: 18,
|
|
1863
|
+
href: "/server-agent",
|
|
1864
|
+
keywords: ["server agent", "ask", "setup", "onboarding"],
|
|
1865
|
+
},
|
|
1816
1866
|
{
|
|
1817
1867
|
id: "services",
|
|
1818
1868
|
label: "n.services",
|
|
@@ -1966,11 +2016,16 @@
|
|
|
1966
2016
|
"</div>" +
|
|
1967
2017
|
chips +
|
|
1968
2018
|
"</header>" +
|
|
2019
|
+
// The rail's box: no box of its own (display: contents). As ☰'s
|
|
2020
|
+
// drawer it is the modal dialog the rail sits in, so the rail stays a
|
|
2021
|
+
// nav (a <nav> may not be a dialog).
|
|
2022
|
+
'<div class="den-rail-box" id="den-rail-box">' +
|
|
1969
2023
|
'<nav class="den-rail" id="den-rail" aria-label="' +
|
|
1970
2024
|
esc(t("pages")) +
|
|
1971
|
-
'"></nav>';
|
|
2025
|
+
'"></nav></div>';
|
|
1972
2026
|
|
|
1973
2027
|
var nav = host.querySelector(".den-rail");
|
|
2028
|
+
var railBox = host.querySelector(".den-rail-box");
|
|
1974
2029
|
|
|
1975
2030
|
// ── the scope parts (R4) ───────────────────────────────────────────────
|
|
1976
2031
|
// Each part's menu comes from its model, set by header.js: setOrgs (the
|
|
@@ -2593,8 +2648,9 @@
|
|
|
2593
2648
|
function openDrawer() {
|
|
2594
2649
|
document.body.classList.add("den-rail-open");
|
|
2595
2650
|
if (menu) menu.setAttribute("aria-expanded", "true");
|
|
2596
|
-
|
|
2597
|
-
|
|
2651
|
+
railBox.setAttribute("role", "dialog");
|
|
2652
|
+
railBox.setAttribute("aria-modal", "true");
|
|
2653
|
+
railBox.setAttribute("aria-label", t("pages"));
|
|
2598
2654
|
if (!scrim) {
|
|
2599
2655
|
scrim = document.createElement("div");
|
|
2600
2656
|
scrim.className = "den-scrim";
|
|
@@ -2614,8 +2670,9 @@
|
|
|
2614
2670
|
if (!drawerOpen()) return;
|
|
2615
2671
|
document.body.classList.remove("den-rail-open");
|
|
2616
2672
|
if (menu) menu.setAttribute("aria-expanded", "false");
|
|
2617
|
-
|
|
2618
|
-
|
|
2673
|
+
railBox.removeAttribute("role");
|
|
2674
|
+
railBox.removeAttribute("aria-modal");
|
|
2675
|
+
railBox.removeAttribute("aria-label");
|
|
2619
2676
|
if (scrim && scrim.remove) scrim.remove();
|
|
2620
2677
|
setInert(false);
|
|
2621
2678
|
if (refocus && menu) menu.focus();
|
|
@@ -4334,7 +4391,10 @@
|
|
|
4334
4391
|
}
|
|
4335
4392
|
function apWhat(a) {
|
|
4336
4393
|
var p = a.params || {};
|
|
4337
|
-
var key =
|
|
4394
|
+
var key =
|
|
4395
|
+
"ap.do." +
|
|
4396
|
+
a.action +
|
|
4397
|
+
(p.isNew ? ".new" : p.activates ? ".activates" : "");
|
|
4338
4398
|
var s = t(key, p);
|
|
4339
4399
|
if (s !== key) return s;
|
|
4340
4400
|
s = String(a.summary || a.action);
|
|
@@ -4966,6 +5026,7 @@
|
|
|
4966
5026
|
// ◐ ▾ (the prototype's user menu, an .ib: appearance.js)
|
|
4967
5027
|
".den-top .den-ib[aria-expanded='true'] { background: var(--sunken); color: var(--text); }",
|
|
4968
5028
|
// Rail (.rail)
|
|
5029
|
+
".den-rail-box { display: contents; }",
|
|
4969
5030
|
".den-rail { position: fixed; left: 0; top: var(--den-top-h); bottom: 0; width: var(--den-rail-cw);",
|
|
4970
5031
|
" box-sizing: content-box; display: flex; flex-direction: column; padding: 10px 8px;",
|
|
4971
5032
|
" background: var(--surface); border-right: 1px solid var(--border); overflow-y: auto; z-index: 15;",
|
|
@@ -43,8 +43,6 @@ const REFRESH_MS = 5_000;
|
|
|
43
43
|
* does (lifecycle-exec.ts, readyTimeoutMs).
|
|
44
44
|
*/
|
|
45
45
|
const START_TIMEOUT_MS = 120_000;
|
|
46
|
-
/** A down check this soon after Start is the service still coming up. */
|
|
47
|
-
const START_GRACE_MS = 10_000;
|
|
48
46
|
/** Restart: an up check counts after a down one, or after this long. */
|
|
49
47
|
const RESTART_MIN_MS = 5_000;
|
|
50
48
|
/** Phone width: the panel is a bottom sheet. */
|
|
@@ -60,8 +58,9 @@ const S = {
|
|
|
60
58
|
sessions: [],
|
|
61
59
|
/**
|
|
62
60
|
* Pin ids starting from here (Start or Restart): id → { at, cmd, sawDown,
|
|
63
|
-
* timer }. "starting…" follows the health feed, not the exec
|
|
64
|
-
* dev server's `up` never ends.
|
|
61
|
+
* ready, timer }. "starting…" follows the health feed, not the exec
|
|
62
|
+
* stream: a dev server's `up` never ends. While it is set, a down from
|
|
63
|
+
* health reads "starting…" too.
|
|
65
64
|
*/
|
|
66
65
|
busy: new Map(),
|
|
67
66
|
/** Pin ids with tests in flight from here. */
|
|
@@ -77,6 +76,7 @@ function startBusy(id, cmd) {
|
|
|
77
76
|
at: Date.now(),
|
|
78
77
|
cmd,
|
|
79
78
|
sawDown: false,
|
|
79
|
+
ready: false,
|
|
80
80
|
timer: setTimeout(() => {
|
|
81
81
|
endBusy(id);
|
|
82
82
|
sync();
|
|
@@ -91,23 +91,35 @@ function endBusy(id) {
|
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
93
|
* A health update for the pins starting from here: up ends "starting…"
|
|
94
|
-
* (after Restart, once the old instance went down
|
|
95
|
-
*
|
|
94
|
+
* (after Restart, once the old instance went down, after a moment, or once
|
|
95
|
+
* the server's ready watch said so). A down never ends it: the service is
|
|
96
|
+
* still coming up until the first up or the timeout.
|
|
96
97
|
*/
|
|
97
98
|
function settleBusy() {
|
|
98
99
|
for (const [id, b] of [...S.busy]) {
|
|
99
100
|
const s = serviceOf(id);
|
|
100
101
|
const age = Date.now() - b.at;
|
|
101
|
-
if (s.ok === false)
|
|
102
|
-
|
|
103
|
-
if (age >= START_GRACE_MS) endBusy(id);
|
|
104
|
-
} else if (
|
|
102
|
+
if (s.ok === false) b.sawDown = true;
|
|
103
|
+
else if (
|
|
105
104
|
s.ok === true &&
|
|
106
|
-
(b.cmd !== "restart" || b.sawDown || age >= RESTART_MIN_MS)
|
|
105
|
+
(b.cmd !== "restart" || b.ready || b.sawDown || age >= RESTART_MIN_MS)
|
|
107
106
|
)
|
|
108
107
|
endBusy(id);
|
|
109
108
|
}
|
|
110
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The exec stream's ready (the server's own health check passed) or its
|
|
112
|
+
* end. This page's health results can still hold a down from before (the
|
|
113
|
+
* forced refresh hasn't answered yet), so ready ends "starting…" only when
|
|
114
|
+
* they already say up, or there is no check; otherwise the next up does.
|
|
115
|
+
* An exec that ended in failure ends it now.
|
|
116
|
+
*/
|
|
117
|
+
function readyBusy(id, ok = true) {
|
|
118
|
+
const b = S.busy.get(id);
|
|
119
|
+
if (!b) return;
|
|
120
|
+
if (!ok || serviceOf(id).ok !== false) endBusy(id);
|
|
121
|
+
else b.ready = true;
|
|
122
|
+
}
|
|
111
123
|
|
|
112
124
|
/** The primary service of a repo from the health results (entrypoint first). */
|
|
113
125
|
function primaryOf(repo) {
|
|
@@ -501,8 +513,9 @@ function exec(s, cmd) {
|
|
|
501
513
|
if (cmd !== "down") startBusy(s.id, cmd);
|
|
502
514
|
if (cmd === "down") window.denShell?.toast?.(t("svp.stopping", { s: s.id }));
|
|
503
515
|
sync();
|
|
504
|
-
const settled = () => {
|
|
505
|
-
|
|
516
|
+
const settled = (ok = true) => {
|
|
517
|
+
readyBusy(s.id, ok);
|
|
518
|
+
sync();
|
|
506
519
|
S.deps.refreshHealth?.(env(), { force: true });
|
|
507
520
|
void refresh();
|
|
508
521
|
};
|
|
@@ -510,7 +523,7 @@ function exec(s, cmd) {
|
|
|
510
523
|
`/api/service/exec?${params}`,
|
|
511
524
|
`${s.repo}/${s.service}`,
|
|
512
525
|
`${cmd} ${s.repo}/${s.service}`,
|
|
513
|
-
{ again: () => exec(s, cmd), ready: settled },
|
|
526
|
+
{ again: () => exec(s, cmd), ready: () => settled(true) },
|
|
514
527
|
settled,
|
|
515
528
|
);
|
|
516
529
|
}
|
|
@@ -885,6 +885,8 @@ function paletteItems() {
|
|
|
885
885
|
});
|
|
886
886
|
}
|
|
887
887
|
for (const x of palSessions) {
|
|
888
|
+
// ON-6: the server agent is "Ask <name>" below, not a workspace's.
|
|
889
|
+
if (x.server) continue;
|
|
888
890
|
// AG-4b: the name a person gave the agent, else its persona.
|
|
889
891
|
const base =
|
|
890
892
|
x.kind === "agent" ? x.displayName || (x.persona ?? x.label) : x.label;
|
|
@@ -923,7 +925,7 @@ function paletteItems() {
|
|
|
923
925
|
for (const ws of s?.workspaceNames?.() ?? [])
|
|
924
926
|
if (!byWs.has(ws)) byWs.set(ws, []);
|
|
925
927
|
for (const x of palSessions) {
|
|
926
|
-
if (!x.workspace) continue;
|
|
928
|
+
if (!x.workspace || x.server) continue;
|
|
927
929
|
const names = byWs.get(x.workspace) ?? [];
|
|
928
930
|
names.push(x.label, x.displayName ?? "", x.persona ?? "");
|
|
929
931
|
byWs.set(x.workspace, names);
|
|
@@ -947,6 +949,16 @@ function paletteItems() {
|
|
|
947
949
|
keywords: ["agents", "start", "persona"],
|
|
948
950
|
run: () => go("/agents?start=1"),
|
|
949
951
|
});
|
|
952
|
+
// ON-6: Ask <name>, the server agent's page.
|
|
953
|
+
const ask = s?.nav?.().find((n) => n.id === "server-agent");
|
|
954
|
+
if (ask)
|
|
955
|
+
items.push({
|
|
956
|
+
key: "agent:ask-server",
|
|
957
|
+
kind: "action",
|
|
958
|
+
label: t("pal.askServer"),
|
|
959
|
+
keywords: ["ask", "server agent", "agent", "help", "setup"],
|
|
960
|
+
run: () => go(ask.href),
|
|
961
|
+
});
|
|
950
962
|
for (const c of s?.commands?.() ?? [])
|
|
951
963
|
items.push({
|
|
952
964
|
key: `cmd:${c.id}`,
|
|
@@ -73,6 +73,8 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
73
73
|
"/graph.html",
|
|
74
74
|
"/agents",
|
|
75
75
|
"/agents.html",
|
|
76
|
+
"/server-agent",
|
|
77
|
+
"/server-agent.html",
|
|
76
78
|
"/tests",
|
|
77
79
|
"/index.html",
|
|
78
80
|
"/services",
|
|
@@ -174,6 +176,10 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
174
176
|
"/api/sessions",
|
|
175
177
|
"lists the sessions and agents (names and states, never their output)",
|
|
176
178
|
),
|
|
179
|
+
R(
|
|
180
|
+
"/api/server-agent",
|
|
181
|
+
"the server agent's name, runtime and state (ON-6): reads only",
|
|
182
|
+
),
|
|
177
183
|
R("/api/health", "runs the manifests' health probes: read-only checks"),
|
|
178
184
|
R("/api/test-sources", "git rev-parse in each repo: read-only"),
|
|
179
185
|
R("/api/manifest/validate", "GET validates a file at a path: read-only"),
|
|
@@ -389,6 +395,21 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
389
395
|
"/api/workspaces/:ws/agents",
|
|
390
396
|
"starts an agent: a runtime in a PTY",
|
|
391
397
|
),
|
|
398
|
+
S(
|
|
399
|
+
["POST"],
|
|
400
|
+
"/api/server-agent/start",
|
|
401
|
+
"starts the server agent (ON-6): a runtime in a PTY; never an agent's token",
|
|
402
|
+
),
|
|
403
|
+
M(
|
|
404
|
+
["POST"],
|
|
405
|
+
"/api/server-agent/runtime",
|
|
406
|
+
"the person's choice of the server agent's runtime (user.yml); never an agent's token",
|
|
407
|
+
),
|
|
408
|
+
M(
|
|
409
|
+
["POST"],
|
|
410
|
+
"/api/server-agent/model",
|
|
411
|
+
"the person's choice of the server agent's model (user.yml); never an agent's token",
|
|
412
|
+
),
|
|
392
413
|
S(
|
|
393
414
|
["POST"],
|
|
394
415
|
"/api/workspaces/:ws/conversations/:id/import",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server agent's routes (ON-6):
|
|
3
|
+
*
|
|
4
|
+
* GET /api/server-agent what it is, its runtime (or why none),
|
|
5
|
+
* whether it runs, whether this Den is
|
|
6
|
+
* fresh, and the manual path
|
|
7
|
+
* POST /api/server-agent/start { runtime? }: start it, or bring it
|
|
8
|
+
* back (resume, relaunch); a person only
|
|
9
|
+
* POST /api/server-agent/runtime { runtime: id | null }: the person's
|
|
10
|
+
* choice (user.yml agents.server_runtime)
|
|
11
|
+
* POST /api/server-agent/model { model: id | null }: the person's
|
|
12
|
+
* model, one of the runtime's own
|
|
13
|
+
* (user.yml agents.server_model)
|
|
14
|
+
*
|
|
15
|
+
* An agent never starts the server agent or picks its runtime: any agent
|
|
16
|
+
* credential or claim is refused (403).
|
|
17
|
+
*/
|
|
18
|
+
import type * as http from "http";
|
|
19
|
+
import { HostError } from "../../shared/sessions/client.js";
|
|
20
|
+
import { AgentError, type AgentsDeps } from "../cli/agent-ops.js";
|
|
21
|
+
import {
|
|
22
|
+
serverAgentStatus,
|
|
23
|
+
setServerModel,
|
|
24
|
+
setServerRuntime,
|
|
25
|
+
startServerAgent,
|
|
26
|
+
type ServerAgentDeps,
|
|
27
|
+
} from "../cli/server-agent-ops.js";
|
|
28
|
+
import { webDeps } from "./agent-routes.js";
|
|
29
|
+
import { recordAction } from "./audit.js";
|
|
30
|
+
import { readBody, sendJson } from "./http-helpers.js";
|
|
31
|
+
import {
|
|
32
|
+
agentPrincipalOf,
|
|
33
|
+
auditActorOf,
|
|
34
|
+
principalOf,
|
|
35
|
+
} from "./request-guard.js";
|
|
36
|
+
|
|
37
|
+
export interface ServerAgentRouteDeps {
|
|
38
|
+
agents?: AgentsDeps;
|
|
39
|
+
server?: ServerAgentDeps;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_BODY = 4 * 1024;
|
|
43
|
+
|
|
44
|
+
function sendError(res: http.ServerResponse, e: unknown): void {
|
|
45
|
+
if (e instanceof AgentError) {
|
|
46
|
+
sendJson(
|
|
47
|
+
res,
|
|
48
|
+
{
|
|
49
|
+
ok: false,
|
|
50
|
+
error: e.message,
|
|
51
|
+
...(e.code ? { code: e.code } : {}),
|
|
52
|
+
...(e.hints.length ? { hints: e.hints } : {}),
|
|
53
|
+
},
|
|
54
|
+
e.status,
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (e instanceof HostError) {
|
|
59
|
+
sendJson(res, { ok: false, error: e.message, code: "session_host" }, 503);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
sendJson(res, { ok: false, error: (e as Error).message }, 500);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function body(
|
|
66
|
+
req: http.IncomingMessage,
|
|
67
|
+
): Promise<{ runtime?: unknown; model?: unknown }> {
|
|
68
|
+
const raw = await readBody(req);
|
|
69
|
+
if (raw.length > MAX_BODY)
|
|
70
|
+
throw new AgentError("body too large", { status: 413 });
|
|
71
|
+
if (!raw.trim()) return {};
|
|
72
|
+
let b: unknown;
|
|
73
|
+
try {
|
|
74
|
+
b = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new AgentError("body must be JSON", { usage: true });
|
|
77
|
+
}
|
|
78
|
+
if (!b || typeof b !== "object" || Array.isArray(b))
|
|
79
|
+
throw new AgentError("body must be an object", { usage: true });
|
|
80
|
+
return b as { runtime?: unknown; model?: unknown };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function handleServerAgentRoutes(
|
|
84
|
+
req: http.IncomingMessage,
|
|
85
|
+
res: http.ServerResponse,
|
|
86
|
+
pathname: string,
|
|
87
|
+
deps: ServerAgentRouteDeps = {},
|
|
88
|
+
): Promise<boolean> {
|
|
89
|
+
if (
|
|
90
|
+
pathname !== "/api/server-agent" &&
|
|
91
|
+
pathname !== "/api/server-agent/start" &&
|
|
92
|
+
pathname !== "/api/server-agent/runtime" &&
|
|
93
|
+
pathname !== "/api/server-agent/model"
|
|
94
|
+
)
|
|
95
|
+
return false;
|
|
96
|
+
const method = req.method ?? "GET";
|
|
97
|
+
const d = webDeps(deps.agents);
|
|
98
|
+
if (pathname === "/api/server-agent") {
|
|
99
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
100
|
+
res.setHeader("Allow", "GET");
|
|
101
|
+
sendJson(res, { ok: false, error: "method not allowed" }, 405);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
sendJson(res, await serverAgentStatus(d, deps.server));
|
|
106
|
+
} catch (e) {
|
|
107
|
+
sendError(res, e);
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (method !== "POST") {
|
|
112
|
+
res.setHeader("Allow", "POST");
|
|
113
|
+
sendJson(res, { ok: false, error: "method not allowed" }, 405);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const actor = auditActorOf(req);
|
|
117
|
+
const verb = pathname.endsWith("/start")
|
|
118
|
+
? "start"
|
|
119
|
+
: pathname.endsWith("/model")
|
|
120
|
+
? "model"
|
|
121
|
+
: "runtime";
|
|
122
|
+
const audit = (detail: string, reason?: string) =>
|
|
123
|
+
recordAction(
|
|
124
|
+
actor,
|
|
125
|
+
{
|
|
126
|
+
verb: "server-agent",
|
|
127
|
+
detail,
|
|
128
|
+
...(reason ? { outcome: "refused" as const, reason } : {}),
|
|
129
|
+
},
|
|
130
|
+
principalOf(req),
|
|
131
|
+
);
|
|
132
|
+
try {
|
|
133
|
+
// The person's: an agent's credential, or a claim to be one, never.
|
|
134
|
+
if (agentPrincipalOf(req) || actor)
|
|
135
|
+
throw new AgentError(
|
|
136
|
+
"only a person can start the server agent or pick its runtime or model",
|
|
137
|
+
{ status: 403 },
|
|
138
|
+
);
|
|
139
|
+
const b = await body(req);
|
|
140
|
+
if (
|
|
141
|
+
b.runtime !== undefined &&
|
|
142
|
+
b.runtime !== null &&
|
|
143
|
+
typeof b.runtime !== "string"
|
|
144
|
+
)
|
|
145
|
+
throw new AgentError("runtime must be a runtime id", { usage: true });
|
|
146
|
+
if (verb === "model") {
|
|
147
|
+
if (b.model !== null && typeof b.model !== "string")
|
|
148
|
+
throw new AgentError("model must be a model id or null", {
|
|
149
|
+
usage: true,
|
|
150
|
+
});
|
|
151
|
+
const id = (b.model as string | null) ?? null;
|
|
152
|
+
await setServerModel(d, id, deps.server);
|
|
153
|
+
audit(id ? `model ${id}` : "model cleared");
|
|
154
|
+
sendJson(res, { ok: true, ...(await serverAgentStatus(d, deps.server)) });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (verb === "runtime") {
|
|
158
|
+
const id = (b.runtime as string | null | undefined) ?? null;
|
|
159
|
+
setServerRuntime(id, deps.server);
|
|
160
|
+
audit(id ? `runtime ${id}` : "runtime cleared");
|
|
161
|
+
sendJson(res, { ok: true, ...(await serverAgentStatus(d, deps.server)) });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const s = await startServerAgent(
|
|
165
|
+
d,
|
|
166
|
+
typeof b.runtime === "string" ? { runtime: b.runtime } : {},
|
|
167
|
+
deps.server,
|
|
168
|
+
);
|
|
169
|
+
audit(`${s.how} on ${s.runtime}`);
|
|
170
|
+
sendJson(res, { ok: true, ...s });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
audit(verb, (e as Error).message);
|
|
173
|
+
sendError(res, e);
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* with the actor from the credential.
|
|
36
36
|
*/
|
|
37
37
|
import type * as http from "http";
|
|
38
|
+
import {
|
|
39
|
+
SERVER_SCOPE,
|
|
40
|
+
isServerScope,
|
|
41
|
+
} from "../../shared/agents/server-agent.js";
|
|
38
42
|
import type { Duplex } from "stream";
|
|
39
43
|
import {
|
|
40
44
|
getActiveWorkspaces,
|
|
@@ -136,6 +140,8 @@ export interface ListedSession {
|
|
|
136
140
|
/** `<dist>-<ws>:<name>`. */
|
|
137
141
|
name: string;
|
|
138
142
|
workspace: string;
|
|
143
|
+
/** ON-6: the server agent's row (its workspace is the server scope). */
|
|
144
|
+
server?: boolean;
|
|
139
145
|
kind: "agent" | "session";
|
|
140
146
|
/** The `<name>` part: an agent's id, a plain session's name. */
|
|
141
147
|
label: string;
|
|
@@ -204,6 +210,8 @@ export function listedSessions(
|
|
|
204
210
|
const dist = d.dist();
|
|
205
211
|
const active = new Set(d.activeWorkspaces());
|
|
206
212
|
if (also) active.add(also);
|
|
213
|
+
// ON-6: the server agent belongs to no workspace, and is always listed.
|
|
214
|
+
active.add(SERVER_SCOPE);
|
|
207
215
|
const mine = sessions.filter((s) => {
|
|
208
216
|
const p = parseSessionName(s.name, dist);
|
|
209
217
|
return p !== null && active.has(p.workspace);
|
|
@@ -224,6 +232,7 @@ export function listedSessions(
|
|
|
224
232
|
id: s?.ref.id ?? rec.session.id,
|
|
225
233
|
name: s?.name ?? rec.sessionName,
|
|
226
234
|
workspace: rec.workspace,
|
|
235
|
+
...(isServerScope(rec.workspace) ? { server: true } : {}),
|
|
227
236
|
kind: "agent",
|
|
228
237
|
label: rec.id,
|
|
229
238
|
agent: rec.id,
|
|
@@ -376,7 +385,9 @@ function servedSession(
|
|
|
376
385
|
const p = parseSessionName(s.name, d.dist());
|
|
377
386
|
return (
|
|
378
387
|
p !== null &&
|
|
379
|
-
(active.has(p.workspace) ||
|
|
388
|
+
(active.has(p.workspace) ||
|
|
389
|
+
isServerScope(p.workspace) ||
|
|
390
|
+
d.workspaceExists(p.workspace)) &&
|
|
380
391
|
(s.ref.id === target || s.name === target)
|
|
381
392
|
);
|
|
382
393
|
}
|
|
@@ -48,6 +48,8 @@ export const UI_MODULES: readonly string[] = [
|
|
|
48
48
|
"/i18n/es.js",
|
|
49
49
|
// 0.4.17: one org and workspace rule for Environments and Releases.
|
|
50
50
|
"/view-scope.js",
|
|
51
|
+
// ON-6: the server agent's page.
|
|
52
|
+
"/server-agent-page.js",
|
|
51
53
|
// 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
|
|
52
54
|
"/svc-panel.js",
|
|
53
55
|
// 0.4.18: where a service's live log comes from (Logs page and panel).
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
10
|
+
import { reservedWorkspaceError } from "../../shared/agents/server-agent.js";
|
|
10
11
|
import { loadApplicationTree } from "../../shared/environment-manager/load-applications.js";
|
|
11
12
|
import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
|
|
12
13
|
import * as http from "http";
|
|
@@ -358,6 +359,8 @@ export function workspaceCreateRefusal(
|
|
|
358
359
|
if (/["'\\/<>\x00-\x1f]/.test(name))
|
|
359
360
|
return "name must not contain quotes, slashes, or control characters";
|
|
360
361
|
if (name.length > 64) return "name must be 64 characters or fewer";
|
|
362
|
+
const reserved = reservedWorkspaceError(name);
|
|
363
|
+
if (reserved) return reserved;
|
|
361
364
|
if (readWorkspacesFile().workspaces[name])
|
|
362
365
|
return `workspace "${name}" already exists`;
|
|
363
366
|
return null;
|