@ddtcorex/dsh-maestro-supervisor 0.7.4 → 0.7.6

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.
@@ -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
+ }
@@ -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 ?? resumeViaRpc;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -8,11 +8,14 @@ compatibility: dsh
8
8
 
9
9
  ## Purpose
10
10
 
11
- One `dsh web` process serves both the live Web UI on port 3080 and the
12
- GitLab-webhook service on port 3000. A host restart drops the live socket and
13
- the in-flight turn, although sessions rehydrate from the append-only log when
14
- the browser reconnects. Treat a restart as disruptive: validate first and get
15
- explicit user consent before a real swap.
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 3000, exclude that provider for the candidate or
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 listener tree owns ports 3000 and 3080 with
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.
@@ -15,7 +15,15 @@ marker="${DSH_SUPERVISOR_MARKER:-$HOME/.dsh/.supervisor/planned-restart}"
15
15
  # the script's own path — never a hard-coded location.
16
16
  dsh_home="${DSH_HOME:-$HOME/.dsh}"
17
17
  export SESSIONS_ROOT="${SESSIONS_ROOT:-$dsh_home/sessions}"
18
- PLUGIN_DIR="${DSH_SUPERVISOR_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
18
+ # Resolve the script's real location first: when invoked through a symlinked
19
+ # skill path (e.g. ~/.agents/skills/dsh-safe-restart/...), BASH_SOURCE keeps the
20
+ # link and "../../.." lands in ~/.agents — breaking the session-health preflight
21
+ # import (lib/session-health.js). readlink -f recovers the package checkout.
22
+ SCRIPT_SRC="${BASH_SOURCE[0]}"
23
+ if resolved="$(readlink -f "$SCRIPT_SRC" 2>/dev/null)" && [ -n "$resolved" ]; then
24
+ SCRIPT_SRC="$resolved"
25
+ fi
26
+ PLUGIN_DIR="${DSH_SUPERVISOR_PLUGIN_DIR:-$(cd "$(dirname "$SCRIPT_SRC")/../../.." && pwd)}"
19
27
  confirmed=false
20
28
  dry_run=false
21
29
  auto_mode=false