@ddtcorex/dsh-maestro-supervisor 0.7.4 → 0.7.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/lib/dsh-session.d.ts +26 -0
- package/lib/dsh-session.js +45 -0
- package/lib/supervisor.d.ts +12 -1
- package/lib/supervisor.js +21 -3
- package/package.json +1 -1
- package/skills/dsh-safe-restart/SKILL.md +14 -10
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface LaunchTarget {
|
|
2
|
+
port: number;
|
|
3
|
+
token: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Parse the newest `dsh web: http://127.0.0.1:<port>/?token=<tok>` boot line
|
|
7
|
+
* from the dsh web log. The launch token and the raw-webserver port (which is
|
|
8
|
+
* 3082 on the local-pin-gate topology, 3080 before it) both live there.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseLaunchTarget(logContent: string): LaunchTarget | undefined;
|
|
11
|
+
export interface MintCookieOpts {
|
|
12
|
+
/** Path to the dsh web log holding the launch line; defaults to `~/.dsh/dsh-web.log`. */
|
|
13
|
+
logPath?: string;
|
|
14
|
+
/** Raw-webserver base URL; overrides the log-derived port (tests). */
|
|
15
|
+
upstreamUrl?: string;
|
|
16
|
+
/** Injectable file reader (tests). */
|
|
17
|
+
readFileImpl?: (path: string) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
export declare function defaultLogPath(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Mint the `dsh-auth-*` session cookie by trading the boot launch token on the
|
|
22
|
+
* raw webserver (index fence: `?token=` -> 303 + Set-Cookie). Returns the
|
|
23
|
+
* `name=value` cookie pair, or undefined when the log is unreadable, no boot
|
|
24
|
+
* line exists, or the exchange does not mint a cookie (upstream 401 etc.).
|
|
25
|
+
*/
|
|
26
|
+
export declare function mintDshSessionCookie(fetchFn: (url: string, init: RequestInit) => Promise<Response>, opts?: MintCookieOpts): Promise<string | undefined>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Parse the newest `dsh web: http://127.0.0.1:<port>/?token=<tok>` boot line
|
|
6
|
+
* from the dsh web log. The launch token and the raw-webserver port (which is
|
|
7
|
+
* 3082 on the local-pin-gate topology, 3080 before it) both live there.
|
|
8
|
+
*/
|
|
9
|
+
export function parseLaunchTarget(logContent) {
|
|
10
|
+
const matches = [...logContent.matchAll(/dsh web: http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9._-]+)/g)];
|
|
11
|
+
const last = matches.at(-1);
|
|
12
|
+
if (last === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
return { port: Number(last[1]), token: last[2] };
|
|
15
|
+
}
|
|
16
|
+
export function defaultLogPath() {
|
|
17
|
+
return join(homedir(), '.dsh', 'dsh-web.log');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Mint the `dsh-auth-*` session cookie by trading the boot launch token on the
|
|
21
|
+
* raw webserver (index fence: `?token=` -> 303 + Set-Cookie). Returns the
|
|
22
|
+
* `name=value` cookie pair, or undefined when the log is unreadable, no boot
|
|
23
|
+
* line exists, or the exchange does not mint a cookie (upstream 401 etc.).
|
|
24
|
+
*/
|
|
25
|
+
export async function mintDshSessionCookie(fetchFn, opts = {}) {
|
|
26
|
+
const read = opts.readFileImpl ?? ((p) => readFile(p, 'utf8'));
|
|
27
|
+
let target;
|
|
28
|
+
try {
|
|
29
|
+
target = parseLaunchTarget(await read(opts.logPath ?? defaultLogPath()));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
target = undefined;
|
|
33
|
+
}
|
|
34
|
+
if (target === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
const upstream = opts.upstreamUrl ?? `http://127.0.0.1:${target.port}`;
|
|
37
|
+
const response = await fetchFn(`${upstream}/?token=${encodeURIComponent(target.token)}`, {
|
|
38
|
+
redirect: 'manual',
|
|
39
|
+
headers: { accept: 'text/html' },
|
|
40
|
+
});
|
|
41
|
+
if (response.status !== 303 && response.status !== 302)
|
|
42
|
+
return undefined;
|
|
43
|
+
const pair = (response.headers.get('set-cookie') ?? '').split(';')[0]?.trim() ?? '';
|
|
44
|
+
return pair.startsWith('dsh-auth-') ? pair : undefined;
|
|
45
|
+
}
|
package/lib/supervisor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HealthState } from './health-poller.js';
|
|
2
2
|
import type { RestartRequest } from './restart-guards.js';
|
|
3
|
+
import { type MintCookieOpts } from './dsh-session.js';
|
|
3
4
|
export interface SupervisorDeps {
|
|
4
5
|
pollHealth: () => Promise<HealthState>;
|
|
5
6
|
writeLKG: () => Promise<{
|
|
@@ -45,7 +46,17 @@ export interface SupervisorDeps {
|
|
|
45
46
|
readRestartRequest?: () => RestartRequest | undefined;
|
|
46
47
|
onRestartRequestHandled?: (req: RestartRequest) => void;
|
|
47
48
|
}
|
|
48
|
-
export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>): Promise<{
|
|
49
|
+
export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>, extraHeaders?: Record<string, string>): Promise<{
|
|
50
|
+
resumed: string[];
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Daemon default resume path: mint the `dsh-auth-*` session cookie first (the
|
|
54
|
+
* `/resume` RPC sits behind the raw webserver's browser-trust fence, which 401s
|
|
55
|
+
* cookie-less loopback calls since the local-pin-gate topology moved the
|
|
56
|
+
* webserver behind the PIN proxies) and attach it to the POST. Falls back to an
|
|
57
|
+
* unauthenticated POST when no boot token is readable — old behavior preserved.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resumeViaRpcWithSession(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>, opts?: MintCookieOpts): Promise<{
|
|
49
60
|
resumed: string[];
|
|
50
61
|
}>;
|
|
51
62
|
export declare class Supervisor {
|
package/lib/supervisor.js
CHANGED
|
@@ -6,12 +6,13 @@ import * as os from 'node:os';
|
|
|
6
6
|
import { resolveHarnessRoot } from './paths.js';
|
|
7
7
|
import { readSupervisorConfig } from './config.js';
|
|
8
8
|
import { writePlannedRestart as defaultWritePlannedRestart, checkPlannedRestart as defaultCheckPlannedRestart, clearPlannedRestart, PLANNED_RESTART_TTL_MS } from './restart-guards.js';
|
|
9
|
+
import { mintDshSessionCookie } from './dsh-session.js';
|
|
9
10
|
import { buildKillStalePortsCommand } from './restart-guards.js';
|
|
10
|
-
export async function resumeViaRpc(ids, fetchFn = globalThis.fetch) {
|
|
11
|
+
export async function resumeViaRpc(ids, fetchFn = globalThis.fetch, extraHeaders = {}) {
|
|
11
12
|
const rpcId = crypto.randomUUID();
|
|
12
13
|
const response = await fetchFn('http://127.0.0.1:3080/dsh-maestro-supervisor-resume/resume', {
|
|
13
14
|
method: 'POST',
|
|
14
|
-
headers: { 'content-type': 'application/json' },
|
|
15
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
15
16
|
body: JSON.stringify({ type: 'client-request', rpcId, method: 'resume', payload: { ids } }),
|
|
16
17
|
});
|
|
17
18
|
if (!response.ok)
|
|
@@ -27,6 +28,23 @@ export async function resumeViaRpc(ids, fetchFn = globalThis.fetch) {
|
|
|
27
28
|
throw new Error('resume RPC returned an invalid result');
|
|
28
29
|
return { resumed };
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Daemon default resume path: mint the `dsh-auth-*` session cookie first (the
|
|
33
|
+
* `/resume` RPC sits behind the raw webserver's browser-trust fence, which 401s
|
|
34
|
+
* cookie-less loopback calls since the local-pin-gate topology moved the
|
|
35
|
+
* webserver behind the PIN proxies) and attach it to the POST. Falls back to an
|
|
36
|
+
* unauthenticated POST when no boot token is readable — old behavior preserved.
|
|
37
|
+
*/
|
|
38
|
+
export async function resumeViaRpcWithSession(ids, fetchFn = globalThis.fetch, opts = {}) {
|
|
39
|
+
let cookie;
|
|
40
|
+
try {
|
|
41
|
+
cookie = await mintDshSessionCookie(fetchFn, opts);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
cookie = undefined;
|
|
45
|
+
}
|
|
46
|
+
return resumeViaRpc(ids, fetchFn, cookie === undefined ? {} : { cookie });
|
|
47
|
+
}
|
|
30
48
|
export class Supervisor {
|
|
31
49
|
deps;
|
|
32
50
|
lastRollback = 0;
|
|
@@ -86,7 +104,7 @@ export class Supervisor {
|
|
|
86
104
|
return this.deps.findInterrupted ?? defaultFindInterrupted;
|
|
87
105
|
}
|
|
88
106
|
getResumeSessions() {
|
|
89
|
-
return this.deps.resumeSessions ??
|
|
107
|
+
return this.deps.resumeSessions ?? resumeViaRpcWithSession;
|
|
90
108
|
}
|
|
91
109
|
getAutoResumeEnabled() {
|
|
92
110
|
// Priority: env > supervisor config.json > maestro settings.json > default true (enabled)
|
package/package.json
CHANGED
|
@@ -8,11 +8,14 @@ compatibility: dsh
|
|
|
8
8
|
|
|
9
9
|
## Purpose
|
|
10
10
|
|
|
11
|
-
One `dsh web` process
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
the
|
|
15
|
-
|
|
11
|
+
One `dsh web` process owns the whole Web surface: the raw webserver on
|
|
12
|
+
`127.0.0.1:3082` (launch-token fence) plus the two Maestro proxies created by
|
|
13
|
+
`dsh-maestro-remote` — the LAN PIN gate on `:3080` (canonical local/LAN URL)
|
|
14
|
+
and the public tunnel proxy on `:3081` — and the `/hooks/gitlab-mr*` webhook
|
|
15
|
+
intake (port 3000 is deprecated/unbound). A host restart drops the live
|
|
16
|
+
socket and the in-flight turn, although sessions rehydrate from the
|
|
17
|
+
append-only log when the browser reconnects. Treat a restart as disruptive:
|
|
18
|
+
validate first and get explicit user consent before a real swap.
|
|
16
19
|
|
|
17
20
|
## Classify the change before touching the live process
|
|
18
21
|
|
|
@@ -30,8 +33,8 @@ explicit user consent before a real swap.
|
|
|
30
33
|
carries the expected marker.
|
|
31
34
|
2. Dry-boot the candidate on an ephemeral port with an isolated `DSH_HOME`.
|
|
32
35
|
Keep the live process and its sessions/settings untouched. If the review
|
|
33
|
-
webhook conflicts on port
|
|
34
|
-
run a no-server composition check instead.
|
|
36
|
+
webhook conflicts on a bound port, exclude that provider for the candidate
|
|
37
|
+
or run a no-server composition check instead.
|
|
35
38
|
3. Verify HTTP 200 and the new marker on the candidate. Retain last-known-good
|
|
36
39
|
assets until the real swap has passed post-swap checks.
|
|
37
40
|
4. Ask for explicit consent and timing. “restart đi” is consent; silence is
|
|
@@ -63,9 +66,10 @@ and refuses to launch if ports are still occupied. `--dry-run` never runs
|
|
|
63
66
|
|
|
64
67
|
Do not read the top of an old append-only log as liveness evidence. Instead:
|
|
65
68
|
|
|
66
|
-
1. Confirm exactly one healthy
|
|
67
|
-
`ss -tlnp
|
|
68
|
-
2. Confirm HTTP 200 from port 3080
|
|
69
|
+
1. Confirm exactly one healthy process owns the listener tree 3080/3081 +
|
|
70
|
+
127.0.0.1:3082 with `ss -tlnp` (port 3000 is no longer bound).
|
|
71
|
+
2. Confirm HTTP 200 from port 3080 (the Maestro PIN login page is 200; the
|
|
72
|
+
app itself loads after the PIN).
|
|
69
73
|
3. Check served bytes contain the **new**, unique marker; or make a fresh
|
|
70
74
|
browser/Playwright probe for the changed UI. Shared third-party markers are
|
|
71
75
|
not sufficient proof.
|