@monster-orange/den 0.4.3 → 0.4.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/CHANGELOG.md +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +540 -33
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: GitHub's device flow, for a GitHub App with only its public client
|
|
3
|
+
* id (no secret), and `GET /user` for who approved it.
|
|
4
|
+
*
|
|
5
|
+
* 1. POST /login/device/code (client_id): a device code (kept here), a
|
|
6
|
+
* user code (shown to the person), how long both last, and the poll
|
|
7
|
+
* interval. No scope is asked for: identity needs none, and
|
|
8
|
+
* `offline_access` would force an expiring token.
|
|
9
|
+
* 2. POST /login/oauth/access_token (client_id, device_code, grant_type
|
|
10
|
+
* device_code) every `interval` seconds until it answers a token, or
|
|
11
|
+
* `authorization_pending` (keep going), `slow_down` (wait longer),
|
|
12
|
+
* `expired_token` or `access_denied` (stop).
|
|
13
|
+
* 3. GET /user with the token: its numeric `id` and `login`, nothing else.
|
|
14
|
+
* The caller drops the token right after.
|
|
15
|
+
*
|
|
16
|
+
* A token answer with `expires_in` or a `refresh_token` means the app has
|
|
17
|
+
* "Expire user authorization tokens" on: the result says so (`expiring`),
|
|
18
|
+
* for the caller's one warning; the sign-in still completes.
|
|
19
|
+
*
|
|
20
|
+
* Every call is one request on a fresh connection (no keep-alive), with a
|
|
21
|
+
* timeout. Nothing here logs; a token, device code or user code is never in
|
|
22
|
+
* an error message.
|
|
23
|
+
*/
|
|
24
|
+
import * as http from "http";
|
|
25
|
+
import * as https from "https";
|
|
26
|
+
import { githubEndpoints, type GithubEndpoints } from "./github-app.js";
|
|
27
|
+
|
|
28
|
+
export interface DeviceCode {
|
|
29
|
+
deviceCode: string;
|
|
30
|
+
userCode: string;
|
|
31
|
+
/** Seconds the codes last. */
|
|
32
|
+
expiresIn: number;
|
|
33
|
+
/** Seconds between polls. */
|
|
34
|
+
interval: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PollResult =
|
|
38
|
+
| {
|
|
39
|
+
kind: "token";
|
|
40
|
+
token: string;
|
|
41
|
+
/**
|
|
42
|
+
* The answer carried `expires_in` or a `refresh_token`: the app has
|
|
43
|
+
* "Expire user authorization tokens" on, a misconfiguration (Den
|
|
44
|
+
* never refreshes; it uses the token once). Never the token itself.
|
|
45
|
+
*/
|
|
46
|
+
expiring: boolean;
|
|
47
|
+
}
|
|
48
|
+
| { kind: "pending" }
|
|
49
|
+
| { kind: "slow_down"; interval?: number }
|
|
50
|
+
| { kind: "expired" }
|
|
51
|
+
| { kind: "denied" }
|
|
52
|
+
| { kind: "error"; message: string };
|
|
53
|
+
|
|
54
|
+
export interface GithubIdentity {
|
|
55
|
+
id: number;
|
|
56
|
+
login: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The three calls, swappable in a unit test. */
|
|
60
|
+
export interface GithubDeviceClient {
|
|
61
|
+
start(clientId: string): Promise<DeviceCode>;
|
|
62
|
+
poll(clientId: string, deviceCode: string): Promise<PollResult>;
|
|
63
|
+
user(token: string): Promise<GithubIdentity>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TIMEOUT_MS = 15_000;
|
|
67
|
+
const UA = "den-device-flow";
|
|
68
|
+
|
|
69
|
+
function request(
|
|
70
|
+
url: string,
|
|
71
|
+
opts: { method: string; headers: Record<string, string>; body?: string },
|
|
72
|
+
): Promise<{ status: number; body: string }> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const u = new URL(url);
|
|
75
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
76
|
+
const req = lib.request(
|
|
77
|
+
u,
|
|
78
|
+
{
|
|
79
|
+
method: opts.method,
|
|
80
|
+
agent: false,
|
|
81
|
+
timeout: TIMEOUT_MS,
|
|
82
|
+
headers: {
|
|
83
|
+
"user-agent": UA,
|
|
84
|
+
connection: "close",
|
|
85
|
+
...opts.headers,
|
|
86
|
+
...(opts.body !== undefined
|
|
87
|
+
? { "content-length": String(Buffer.byteLength(opts.body)) }
|
|
88
|
+
: {}),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
(res) => {
|
|
92
|
+
let out = "";
|
|
93
|
+
res.setEncoding("utf-8");
|
|
94
|
+
res.on("data", (d: string) => (out += d));
|
|
95
|
+
res.on("end", () =>
|
|
96
|
+
resolve({ status: res.statusCode ?? 0, body: out }),
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
req.on("timeout", () => req.destroy(new Error("GitHub did not answer")));
|
|
101
|
+
req.on("error", (e) =>
|
|
102
|
+
reject(new Error(`GitHub unreachable: ${e.message}`)),
|
|
103
|
+
);
|
|
104
|
+
req.end(opts.body);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parse(body: string): Record<string, unknown> {
|
|
109
|
+
try {
|
|
110
|
+
const v = JSON.parse(body) as unknown;
|
|
111
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const form = (o: Record<string, string>) => new URLSearchParams(o).toString();
|
|
118
|
+
const FORM_HEADERS = {
|
|
119
|
+
accept: "application/json",
|
|
120
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** The real GitHub (or a test's fake one, githubEndpoints). */
|
|
124
|
+
export function githubDeviceClient(
|
|
125
|
+
ep: GithubEndpoints = githubEndpoints(),
|
|
126
|
+
): GithubDeviceClient {
|
|
127
|
+
return {
|
|
128
|
+
async start(clientId) {
|
|
129
|
+
const r = await request(ep.deviceCode, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: FORM_HEADERS,
|
|
132
|
+
body: form({ client_id: clientId }),
|
|
133
|
+
});
|
|
134
|
+
const j = parse(r.body);
|
|
135
|
+
if (
|
|
136
|
+
r.status !== 200 ||
|
|
137
|
+
typeof j.device_code !== "string" ||
|
|
138
|
+
typeof j.user_code !== "string"
|
|
139
|
+
) {
|
|
140
|
+
const why =
|
|
141
|
+
typeof j.error === "string"
|
|
142
|
+
? j.error === "device_flow_disabled"
|
|
143
|
+
? "the GitHub App's device flow is off (tick “Enable Device Flow”)"
|
|
144
|
+
: j.error === "unauthorized_client" ||
|
|
145
|
+
j.error === "incorrect_client_credentials"
|
|
146
|
+
? "GitHub doesn't know this client id (web.github_client_id)"
|
|
147
|
+
: `GitHub answered ${j.error}`
|
|
148
|
+
: `GitHub answered ${r.status}`;
|
|
149
|
+
throw new Error(why);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
deviceCode: j.device_code,
|
|
153
|
+
userCode: j.user_code,
|
|
154
|
+
expiresIn: Number(j.expires_in) > 0 ? Number(j.expires_in) : 900,
|
|
155
|
+
interval: Number(j.interval) >= 0 ? Number(j.interval) : 5,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
async poll(clientId, deviceCode) {
|
|
159
|
+
let r: { status: number; body: string };
|
|
160
|
+
try {
|
|
161
|
+
r = await request(ep.accessToken, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: FORM_HEADERS,
|
|
164
|
+
body: form({
|
|
165
|
+
client_id: clientId,
|
|
166
|
+
device_code: deviceCode,
|
|
167
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { kind: "error", message: (e as Error).message };
|
|
172
|
+
}
|
|
173
|
+
const j = parse(r.body);
|
|
174
|
+
if (typeof j.access_token === "string" && j.access_token)
|
|
175
|
+
return {
|
|
176
|
+
kind: "token",
|
|
177
|
+
token: j.access_token,
|
|
178
|
+
expiring: j.expires_in !== undefined || j.refresh_token !== undefined,
|
|
179
|
+
};
|
|
180
|
+
switch (j.error) {
|
|
181
|
+
case "authorization_pending":
|
|
182
|
+
return { kind: "pending" };
|
|
183
|
+
case "slow_down":
|
|
184
|
+
return {
|
|
185
|
+
kind: "slow_down",
|
|
186
|
+
...(Number(j.interval) > 0 ? { interval: Number(j.interval) } : {}),
|
|
187
|
+
};
|
|
188
|
+
case "expired_token":
|
|
189
|
+
return { kind: "expired" };
|
|
190
|
+
case "access_denied":
|
|
191
|
+
return { kind: "denied" };
|
|
192
|
+
default:
|
|
193
|
+
return {
|
|
194
|
+
kind: "error",
|
|
195
|
+
message:
|
|
196
|
+
typeof j.error === "string"
|
|
197
|
+
? `GitHub answered ${j.error}`
|
|
198
|
+
: `GitHub answered ${r.status}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
async user(token) {
|
|
203
|
+
const r = await request(ep.user, {
|
|
204
|
+
method: "GET",
|
|
205
|
+
headers: {
|
|
206
|
+
accept: "application/vnd.github+json",
|
|
207
|
+
authorization: `Bearer ${token}`,
|
|
208
|
+
"x-github-api-version": "2022-11-28",
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
const j = parse(r.body);
|
|
212
|
+
if (
|
|
213
|
+
r.status !== 200 ||
|
|
214
|
+
typeof j.id !== "number" ||
|
|
215
|
+
!Number.isSafeInteger(j.id) ||
|
|
216
|
+
typeof j.login !== "string"
|
|
217
|
+
)
|
|
218
|
+
throw new Error(`GitHub's /user answered ${r.status}`);
|
|
219
|
+
return { id: j.id, login: j.login };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `github:<numeric id>`: how Den names a GitHub account. */
|
|
225
|
+
export function githubPrincipal(id: number): `github:${number}` {
|
|
226
|
+
return `github:${id}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type DeviceFlowOutcome =
|
|
230
|
+
| { kind: "ok"; identity: GithubIdentity; expiringToken?: boolean }
|
|
231
|
+
| { kind: "expired" }
|
|
232
|
+
| { kind: "denied" }
|
|
233
|
+
| { kind: "error"; message: string };
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The whole flow in one call (the CLI's `web owner link`): start, show the
|
|
237
|
+
* code (`onCode`), poll at GitHub's interval (`sleep`, injected in a test),
|
|
238
|
+
* read the identity, drop the token. `minIntervalMs` is the floor between
|
|
239
|
+
* polls (GitHub's own interval is never shorter than it says).
|
|
240
|
+
*/
|
|
241
|
+
export async function runDeviceFlow(opts: {
|
|
242
|
+
clientId: string;
|
|
243
|
+
client?: GithubDeviceClient;
|
|
244
|
+
onCode: (code: DeviceCode) => void;
|
|
245
|
+
sleep?: (ms: number) => Promise<void>;
|
|
246
|
+
now?: () => number;
|
|
247
|
+
minIntervalMs?: number;
|
|
248
|
+
}): Promise<DeviceFlowOutcome> {
|
|
249
|
+
const client = opts.client ?? githubDeviceClient();
|
|
250
|
+
const sleep =
|
|
251
|
+
opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
|
252
|
+
const now = opts.now ?? Date.now;
|
|
253
|
+
const code = await client.start(opts.clientId);
|
|
254
|
+
opts.onCode(code);
|
|
255
|
+
const deadline = now() + code.expiresIn * 1000;
|
|
256
|
+
const floor = opts.minIntervalMs ?? 1000;
|
|
257
|
+
let interval = Math.max(code.interval * 1000, floor);
|
|
258
|
+
let errors = 0;
|
|
259
|
+
for (;;) {
|
|
260
|
+
await sleep(interval);
|
|
261
|
+
if (now() >= deadline) return { kind: "expired" };
|
|
262
|
+
const r = await client.poll(opts.clientId, code.deviceCode);
|
|
263
|
+
if (r.kind === "token") {
|
|
264
|
+
// The token lives only for this one call.
|
|
265
|
+
const identity = await client.user(r.token);
|
|
266
|
+
return {
|
|
267
|
+
kind: "ok",
|
|
268
|
+
identity,
|
|
269
|
+
...(r.expiring ? { expiringToken: true } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (r.kind === "pending") continue;
|
|
273
|
+
if (r.kind === "slow_down") {
|
|
274
|
+
interval = Math.max(
|
|
275
|
+
r.interval !== undefined ? r.interval * 1000 : interval + 5000,
|
|
276
|
+
floor,
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (r.kind === "error") {
|
|
281
|
+
if (++errors >= 5) return { kind: "error", message: r.message };
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
return r;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-2.4: why a service has no health verdict, in words. A row with `ok:
|
|
3
|
+
* null` and no reason reads as "broken" to an agent (it fell back to
|
|
4
|
+
* `docker ps`); every unchecked row says which of these it is. One rule for
|
|
5
|
+
* the web health row (/api/health) and the MCP `status` tool, which derives
|
|
6
|
+
* it from a row an older web server served without one.
|
|
7
|
+
*/
|
|
8
|
+
import { product } from "../product.js";
|
|
9
|
+
import type { Runs } from "../manifest/placement.js";
|
|
10
|
+
|
|
11
|
+
export interface NotCheckedInput {
|
|
12
|
+
/** The env being viewed. */
|
|
13
|
+
env: string;
|
|
14
|
+
runs: Runs | undefined;
|
|
15
|
+
/** A remote service's target (its env), when it has one. */
|
|
16
|
+
target?: { env?: string } | null;
|
|
17
|
+
/** false when the repo has no manifest at all. */
|
|
18
|
+
manifest?: boolean;
|
|
19
|
+
/** The conventional probe tried (and not answered 2xx), if any. */
|
|
20
|
+
conventional?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function notCheckedReason(o: NotCheckedInput): string {
|
|
24
|
+
if (o.manifest === false)
|
|
25
|
+
return `not checked: the repo has no ${product().manifestYml}`;
|
|
26
|
+
if (o.runs === "remote") {
|
|
27
|
+
const where = o.target?.env ?? o.env;
|
|
28
|
+
return (
|
|
29
|
+
`not checked: remote (runs in ${where}), and the manifest declares ` +
|
|
30
|
+
`no health check or target.url for ${where}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (o.runs === "none" || o.runs === undefined)
|
|
34
|
+
return (
|
|
35
|
+
`not checked: not deployed in ${o.env} (the manifest has no ` +
|
|
36
|
+
`commands and no health check for ${o.env})`
|
|
37
|
+
);
|
|
38
|
+
return (
|
|
39
|
+
`not checked: no health check in the manifest for ${o.env}` +
|
|
40
|
+
(o.conventional ? `, and ${o.conventional} did not answer 2xx` : "")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -17,6 +17,8 @@ export interface HealthSignal {
|
|
|
17
17
|
isEntrypoint?: boolean;
|
|
18
18
|
/** Not answered yet this cycle: the repo stays unknown until it has. */
|
|
19
19
|
awaiting?: boolean;
|
|
20
|
+
/** The check answered with a redirect (ok null): counted as a redirect. */
|
|
21
|
+
redirect?: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface RepoRollup {
|
|
@@ -29,6 +31,8 @@ export interface RepoRollup {
|
|
|
29
31
|
unchecked: number;
|
|
30
32
|
/** Checks that haven't answered yet; any holds the state at unknown. */
|
|
31
33
|
awaiting: number;
|
|
34
|
+
/** Checks that answered with a redirect (not in `unchecked`); absent at 0. */
|
|
35
|
+
redirected?: number;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export const STATE_RANK: Record<RepoState, number> = rollup.STATE_RANK;
|
|
@@ -86,6 +86,10 @@ export function spawnDaemon(
|
|
|
86
86
|
// Stamp the log so the tail starts reading from a clean boundary.
|
|
87
87
|
const ts = new Date().toISOString();
|
|
88
88
|
fs.appendFileSync(logPath, `\n\n─── ${product().cli} start ${ts} ───\n`);
|
|
89
|
+
// Where this run's output starts. The tail reads from here, not from "the
|
|
90
|
+
// end when tail opened": a fast command (a refused sudo, a typo) can write
|
|
91
|
+
// and exit before tail starts, and `-n 0` would drop those lines.
|
|
92
|
+
const startOffset = fs.statSync(logPath).size;
|
|
89
93
|
|
|
90
94
|
// Open the log fd in the parent so we can hand it to the child.
|
|
91
95
|
// MUST stay "a" (O_APPEND): rotation truncates this file in place, and only
|
|
@@ -120,9 +124,10 @@ export function spawnDaemon(
|
|
|
120
124
|
fs.closeSync(logFd); // parent closes its copy; child keeps its own fd
|
|
121
125
|
|
|
122
126
|
// Tail the log for streaming back to the caller.
|
|
123
|
-
// -F follows by name (handles log rotation). -
|
|
124
|
-
// after
|
|
125
|
-
|
|
127
|
+
// -F follows by name (handles log rotation). -c +N starts at this run's
|
|
128
|
+
// first byte (just after the stamp), so nothing the service wrote before
|
|
129
|
+
// tail opened the file is lost.
|
|
130
|
+
const tailProc = spawn("tail", ["-F", "-c", `+${startOffset + 1}`, logPath], {
|
|
126
131
|
stdio: ["ignore", "pipe", "ignore"],
|
|
127
132
|
});
|
|
128
133
|
|
|
@@ -52,11 +52,34 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Overwrite the field; an item that isn't there yet is created (a Password item). */
|
|
55
56
|
async set(ref: SecretRef, value: string): Promise<void> {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
const vault = ref.vault ? ["--vault", ref.vault] : [];
|
|
58
|
+
const field = `${ref.field}[password]=${value}`;
|
|
59
|
+
try {
|
|
60
|
+
await execFileAsync("op", ["item", "edit", ref.item, ...vault, field], {
|
|
61
|
+
timeout: this.timeout,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
try {
|
|
65
|
+
await execFileAsync(
|
|
66
|
+
"op",
|
|
67
|
+
[
|
|
68
|
+
"item",
|
|
69
|
+
"create",
|
|
70
|
+
"--category",
|
|
71
|
+
"password",
|
|
72
|
+
"--title",
|
|
73
|
+
ref.item,
|
|
74
|
+
...vault,
|
|
75
|
+
field,
|
|
76
|
+
],
|
|
77
|
+
{ timeout: this.timeout },
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
async preflight(): Promise<PreflightResult> {
|
|
@@ -76,7 +76,8 @@ export class HostClient extends EventEmitter {
|
|
|
76
76
|
const h = this.attachments.get(ev.aid);
|
|
77
77
|
if (!h) return;
|
|
78
78
|
if (ev.ev === "data") h.onData(Buffer.from(ev.data, "base64"));
|
|
79
|
-
else
|
|
79
|
+
else if (ev.ev === "size") h.onSize?.(ev.cols, ev.rows);
|
|
80
|
+
else if (ev.ev === "exit") {
|
|
80
81
|
this.attachments.delete(ev.aid);
|
|
81
82
|
h.onExit?.(ev.exitCode);
|
|
82
83
|
}
|
|
@@ -174,6 +175,9 @@ export class HostClient extends EventEmitter {
|
|
|
174
175
|
ref: id,
|
|
175
176
|
aid,
|
|
176
177
|
...(opts.replay !== undefined ? { replay: opts.replay } : {}),
|
|
178
|
+
// Only a client that handles them is sent size events: an older
|
|
179
|
+
// client would read an unknown event as the session's exit.
|
|
180
|
+
...(handlers.onSize ? { sizes: true } : {}),
|
|
177
181
|
}).then(
|
|
178
182
|
(r) => r.info as SessionInfo,
|
|
179
183
|
(e: Error) => {
|
|
@@ -63,6 +63,10 @@ interface Session {
|
|
|
63
63
|
log: ScrollbackLog;
|
|
64
64
|
/** aid → its connection. */
|
|
65
65
|
attachments: Map<string, Conn>;
|
|
66
|
+
/** The attachments that asked for size events (attach `sizes: true`). */
|
|
67
|
+
sizeAids?: Set<string>;
|
|
68
|
+
/** The PTY's size: the start's, then the last resize's (any client's). */
|
|
69
|
+
size?: { cols: number; rows: number };
|
|
66
70
|
/** The cap it counts against (start's `pool`), when one was named. */
|
|
67
71
|
pool?: SessionPool;
|
|
68
72
|
}
|
|
@@ -453,7 +457,15 @@ export class SessionHost {
|
|
|
453
457
|
const s = this.session(req.ref);
|
|
454
458
|
if (!isInt(req.cols, 1, 10_000) || !isInt(req.rows, 1, 10_000))
|
|
455
459
|
throw new Refusal("bad-request", "resize needs whole cols and rows");
|
|
456
|
-
if (s.pty && !s.info.exited)
|
|
460
|
+
if (s.pty && !s.info.exited) {
|
|
461
|
+
s.pty.resize(req.cols, req.rows);
|
|
462
|
+
// Last writer wins: every viewer that asked hears the new size,
|
|
463
|
+
// so one out of step knows to say its own again.
|
|
464
|
+
if (s.size?.cols !== req.cols || s.size?.rows !== req.rows) {
|
|
465
|
+
s.size = { cols: req.cols, rows: req.rows };
|
|
466
|
+
this.sendSize(s);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
457
469
|
return {};
|
|
458
470
|
}
|
|
459
471
|
case "signal": {
|
|
@@ -606,6 +618,7 @@ export class SessionHost {
|
|
|
606
618
|
},
|
|
607
619
|
),
|
|
608
620
|
attachments: new Map(),
|
|
621
|
+
size: { cols, rows },
|
|
609
622
|
...(pool !== undefined ? { pool: pool as SessionPool } : {}),
|
|
610
623
|
};
|
|
611
624
|
this.sessions.set(id, s);
|
|
@@ -626,6 +639,7 @@ export class SessionHost {
|
|
|
626
639
|
for (const [aid, c] of s.attachments)
|
|
627
640
|
c.send({ ev: "exit", aid, exitCode: s.info.exitCode });
|
|
628
641
|
s.attachments.clear();
|
|
642
|
+
s.sizeAids?.clear();
|
|
629
643
|
this.log(`session ${id} (${name}) exited (${s.info.exitCode})`);
|
|
630
644
|
}, EXIT_SETTLE_MS);
|
|
631
645
|
});
|
|
@@ -654,10 +668,24 @@ export class SessionHost {
|
|
|
654
668
|
} else {
|
|
655
669
|
conn.aids.add(aid);
|
|
656
670
|
s.attachments.set(aid, conn);
|
|
671
|
+
if (req.sizes === true) {
|
|
672
|
+
(s.sizeAids ??= new Set()).add(aid);
|
|
673
|
+
if (s.size) conn.send({ ev: "size", aid, ...s.size });
|
|
674
|
+
}
|
|
657
675
|
}
|
|
658
676
|
return s.info;
|
|
659
677
|
}
|
|
660
678
|
|
|
679
|
+
/** Tell each attachment that asked the session's current size. */
|
|
680
|
+
private sendSize(s: Session): void {
|
|
681
|
+
if (!s.size || !s.sizeAids?.size) return;
|
|
682
|
+
for (const aid of s.sizeAids) {
|
|
683
|
+
const c = s.attachments.get(aid);
|
|
684
|
+
if (c) c.send({ ev: "size", aid, ...s.size });
|
|
685
|
+
else s.sizeAids.delete(aid);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
661
689
|
/** Stop serving: running sessions get SIGHUP, scrollback is flushed. */
|
|
662
690
|
async close(): Promise<void> {
|
|
663
691
|
if (this.closed) return;
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* {id, ok: false, code, error}
|
|
8
8
|
* event {ev: "data", aid, data} host → client, per attachment
|
|
9
9
|
* {ev: "exit", aid, exitCode}
|
|
10
|
+
* {ev: "size", aid, cols, rows} only to an attachment that
|
|
11
|
+
* asked (attach `sizes: true`):
|
|
12
|
+
* the PTY's size on attach, and
|
|
13
|
+
* each time it changes
|
|
10
14
|
*
|
|
11
15
|
* ops: hello, start, list, attach, detach, write, resize, signal,
|
|
12
16
|
* scrollback, forget, shutdown. A request whose `v` is not the host's is
|
|
@@ -47,7 +51,8 @@ export type Reply =
|
|
|
47
51
|
|
|
48
52
|
export type HostEvent =
|
|
49
53
|
| { ev: "data"; aid: string; data: string }
|
|
50
|
-
| { ev: "exit"; aid: string; exitCode?: number }
|
|
54
|
+
| { ev: "exit"; aid: string; exitCode?: number }
|
|
55
|
+
| { ev: "size"; aid: string; cols: number; rows: number };
|
|
51
56
|
|
|
52
57
|
/** Splits a byte stream into lines; refuses one that grows too long. */
|
|
53
58
|
export class LineReader {
|
|
@@ -55,6 +55,12 @@ export interface AttachHandlers {
|
|
|
55
55
|
onData(data: Buffer): void;
|
|
56
56
|
/** The session ended (at once, after the replay, when it already had). */
|
|
57
57
|
onExit?(exitCode: number | undefined): void;
|
|
58
|
+
/**
|
|
59
|
+
* The PTY's size: once on attach (after the replay), then each time any
|
|
60
|
+
* client resizes it. The size is last-writer-wins across every viewer,
|
|
61
|
+
* so a viewer learns here that another one changed it.
|
|
62
|
+
*/
|
|
63
|
+
onSize?(cols: number, rows: number): void;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** One attachment; a session has any number, each gets all the output. */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* The CLI prints it; the web server serves it at /api/stacks (and through it
|
|
4
4
|
* the MCP `stacks` tool).
|
|
5
5
|
*/
|
|
6
|
+
import { product } from "../product.js";
|
|
6
7
|
import { spawnSync } from "child_process";
|
|
7
8
|
import * as fs from "fs";
|
|
8
9
|
import * as path from "path";
|
|
@@ -125,6 +126,29 @@ export function rejoinRunningStacks(
|
|
|
125
126
|
return rejoinEdgeNetworks(contexts, onLine);
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Why a workspace has no stacks (MCP-2.4): an empty list with no reason
|
|
131
|
+
* reads as "docker is broken". A stack is a `stacks:` entry in the org
|
|
132
|
+
* config, or a repo of the workspace whose manifest has a `compose:` block.
|
|
133
|
+
*/
|
|
134
|
+
export function emptyStacksReason(o: {
|
|
135
|
+
workspace: string;
|
|
136
|
+
workspaceExists: boolean;
|
|
137
|
+
/** How many stacks the org config itself declares. */
|
|
138
|
+
configStacks: number;
|
|
139
|
+
}): string {
|
|
140
|
+
const cfg = `the org config (${product().configFile}) declares no stacks:`;
|
|
141
|
+
if (!o.workspaceExists)
|
|
142
|
+
return (
|
|
143
|
+
`no stacks: workspace "${o.workspace}" does not exist here, so no ` +
|
|
144
|
+
`repo manifest was read, and ${cfg}`
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
`no stacks: ${cfg}, and no repo of workspace "${o.workspace}" has a ` +
|
|
148
|
+
`compose: block in its ${product().manifestYml}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
/** Each stack's name, project, routes and (optionally) container health. */
|
|
129
153
|
export function stackStatuses(
|
|
130
154
|
ctx: StackContext,
|
|
@@ -44,6 +44,11 @@ export function registerStrategy<K extends string, C>(
|
|
|
44
44
|
byId.set(entry.id, entry as StrategyEntry);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Remove strategy `id` of `kind` (no-op when it isn't there). */
|
|
48
|
+
export function unregisterStrategy(kind: string, id: string): void {
|
|
49
|
+
registries.get(kind)?.delete(id);
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
/** The strategy `id` of `kind`, or undefined. */
|
|
48
53
|
export function getStrategy<K extends string, C>(
|
|
49
54
|
kind: K,
|
|
@@ -667,6 +667,12 @@ export interface WorkspaceServiceEntry {
|
|
|
667
667
|
}
|
|
668
668
|
|
|
669
669
|
export interface WorkspaceConfig {
|
|
670
|
+
/**
|
|
671
|
+
* Agents in this workspace, on this machine (AG-0.5):
|
|
672
|
+
* `agents.runtimes.<id>.bin`, a runtime's CLI for this workspace's
|
|
673
|
+
* agents, which wins over user.yml's and the search.
|
|
674
|
+
*/
|
|
675
|
+
agents?: { runtimes?: Record<string, { bin?: unknown } | null> };
|
|
670
676
|
/**
|
|
671
677
|
* Service entries keyed by service address.
|
|
672
678
|
* Single-service repos: "tasks-runner"
|