dsh-home-hosted 0.2.2 → 0.3.2

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.
@@ -16,7 +16,70 @@ export interface DshFacts {
16
16
  profile: string | null;
17
17
  dshHome: string;
18
18
  launch: CliLaunch | null;
19
+ /**
20
+ * The stable launcher a local/clone entry must boot through, when one exists.
21
+ * Null keeps the older shape: the resolved program and its own argv.
22
+ */
23
+ launcherPath?: string | null;
19
24
  }
20
- /** Where the running harness's own CLI entry is, if we can tell. */
21
- export declare function resolveDshLaunch(): Promise<CliLaunch | null>;
25
+ export interface DshLaunch extends CliLaunch {
26
+ /** The stable launcher written for this launch, or null for `dsh` on PATH. */
27
+ launcherPath: string | null;
28
+ }
29
+ export interface ResolveDshLaunchOptions {
30
+ /** The plugin state dir the launcher is written into; defaults to `$DSH_HOME/dsh-home-hosted`. */
31
+ stateDir?: string;
32
+ /** The harness home the resolver records as the launcher's search root. */
33
+ dshHome?: string;
34
+ /** Test seam: what this process was launched as; defaults to `process.argv[1]`. */
35
+ argv1?: string | null;
36
+ /** Test seam: locate a command on PATH. */
37
+ findOnPath?: (command: string) => Promise<string | null>;
38
+ }
39
+ /**
40
+ * A local clone's built entry, reconstructed from where a clone keeps its build.
41
+ *
42
+ * A clone installs dsh in its own tree, so `argv[1]` names a *build output*
43
+ * (`lib/bin.js` inside `node_modules`, and after a rebuild a different one): the
44
+ * install root is derived from it and every known build location is checked, so a
45
+ * rebuild does not require a new boot entry.
46
+ */
47
+ export declare function cloneEntryFrom(argv1: string): string | null;
48
+ /** The best dsh entry installed under a harness home, flat or pnpm. */
49
+ export declare function findDshEntry(dshHome: string): string | null;
50
+ /**
51
+ * Where the running harness's own CLI entry is.
52
+ *
53
+ * A local clone's build path is not stable enough to bake into a boot entry, so
54
+ * the clone case records the entry in a generated launcher and returns that
55
+ * launcher's path; the launcher re-finds dsh at boot. A plain `dsh` on PATH is
56
+ * already resolved to a stable entry and keeps its current shape.
57
+ */
58
+ export declare function resolveDshLaunch(options?: ResolveDshLaunchOptions): Promise<DshLaunch | null>;
59
+ /**
60
+ * Re-point an entry that boots a local build directly at the stable launcher.
61
+ *
62
+ * Only this shape qualifies: the entry runs the same script this process does,
63
+ * and that script is not already the launcher. An upgrade cannot repair the
64
+ * entry it created in an earlier release otherwise — the plugin never rewrites
65
+ * an existing entry's command.
66
+ */
67
+ /**
68
+ * Whether an entry *could* need that repair, without resolving anything.
69
+ *
70
+ * Resolving a local dsh walks the harness home, so the write path asks this
71
+ * first: a global `dsh` on PATH, or an entry already on the launcher, is
72
+ * settled here and never triggers the walk.
73
+ */
74
+ export declare function needsLauncherRepair(config: {
75
+ command?: string;
76
+ args?: string[];
77
+ }): boolean;
78
+ export declare function launcherRepair(config: {
79
+ command?: string;
80
+ args?: string[];
81
+ }, launch: DshLaunch | null): {
82
+ command: string;
83
+ args: string[];
84
+ } | null;
22
85
  export declare function buildDshEntry(facts: DshFacts): ServerEntry;
@@ -35,4 +35,52 @@ export declare function versionOfEntry(entry: string): string | null;
35
35
  export declare function candidatesUnder(root: string): string[];
36
36
  /** Highest satisfying candidate among explicit roots; exported for tests. */
37
37
  export declare function findCandidate(roots: string[], minVersion: string): string | null;
38
+ export interface DshLauncherOptions {
39
+ stateDir: string;
40
+ dshHome: string;
41
+ /** Where the dsh entry is right now; recorded as the fast path. */
42
+ resolvedEntry: string | null;
43
+ /** The extension the launcher must match: a `.cjs` entry gets a `.cjs` launcher. */
44
+ entryExtension?: string | null;
45
+ /**
46
+ * Install roots that are neither the harness home nor PATH — a clone, or a
47
+ * project whose own `node_modules` carries dsh. Recorded and searched, so a
48
+ * moved build is re-found on the next boot.
49
+ */
50
+ searchRoots?: string[] | null;
51
+ }
52
+ export interface DshLauncherWrite {
53
+ path: string;
54
+ changed: boolean;
55
+ }
56
+ interface DshLauncherRecord {
57
+ entry: string | null;
58
+ /** Where that entry's install sat, kept across rewrites while it stays valid. */
59
+ roots: string[];
60
+ writtenAt: number;
61
+ }
62
+ /** The recorded entry a boot entry baked in, for the launcher's fast path. */
63
+ export declare function dshLauncherRecordPath(stateDir: string): string;
64
+ /** `bin/dsh.mjs`, or `bin/dsh.cjs` when the recorded entry is CommonJS. */
65
+ export declare function dshLauncherPath(stateDir: string, entryExtension?: string | null): string;
66
+ export declare function readDshLauncherRecord(stateDir: string): DshLauncherRecord | null;
67
+ /**
68
+ * The dsh launcher's source.
69
+ *
70
+ * Like the home-hosted launcher, this exists because a clone's build output path
71
+ * (or a versioned pnpm path) moves on the next install/rebuild: the boot entry
72
+ * points at this stable script and the script re-finds dsh at every boot. A
73
+ * `.cjs` entry gets a CommonJS launcher, so node loads it as one.
74
+ */
75
+ export declare function buildDshLauncherSource(options: DshLauncherOptions): string;
76
+ /** Write the launcher and record what `resolveDshLaunch` found. */
77
+ export declare function writeDshLauncher(options: DshLauncherOptions): DshLauncherWrite;
78
+ /** A dsh entry under one root: a flat install, or either pnpm store layout. */
79
+ export declare function dshCandidatesUnder(root: string): string[];
80
+ /**
81
+ * The install root a dsh entry belongs to: the directory whose `node_modules`
82
+ * carries it — a clone root, or the project that installed dsh. That root is
83
+ * what a rebuilt or upgraded install keeps.
84
+ */
85
+ export declare function dshSearchRoot(entry: string | null | undefined): string | null;
38
86
  export {};
@@ -40,5 +40,17 @@ export declare class PanelClient {
40
40
  stopped?: number[];
41
41
  }>;
42
42
  }
43
+ /**
44
+ * What a token probe measured: `ok` — the panel accepted it; `refused` — the
45
+ * panel answered and rejected it; `unreachable` — the panel was not there to
46
+ * ask, which is not the same as a refusal.
47
+ */
48
+ export type TokenProbe = 'ok' | 'refused' | 'unreachable';
49
+ /**
50
+ * Prove a token with a non-mutating `listServers()`, and say whether the panel
51
+ * refused it or merely was not answering. A timeout or connection error must
52
+ * never be reported as a stale token.
53
+ */
54
+ export declare function probeToken(baseUrl: string, token: string, timeoutMs?: number): Promise<TokenProbe>;
43
55
  /** Prove a token works, without needing any write permission. */
44
56
  export declare function verifyToken(baseUrl: string, token: string, timeoutMs?: number): Promise<boolean>;
@@ -1,7 +1,7 @@
1
1
  import type { CliStatus } from '../shared/contracts.js';
2
2
  import type { CliLaunch } from './launch.js';
3
3
  /** The range the plugin ships and is tested against. */
4
- export declare const EXPECTED_RANGE = "^0.6.4";
4
+ export declare const EXPECTED_RANGE = "^0.6.6";
5
5
  /** Oldest release whose API and config schema this plugin relies on. */
6
6
  export declare const MIN_SUPPORTED_VERSION = "0.4.1";
7
7
  /** Oldest release whose config schema accepts `onPortConflict: kill`. */
@@ -11,6 +11,15 @@ export interface EnsureTokenResult {
11
11
  enrolled: boolean;
12
12
  detail: string;
13
13
  }
14
+ export interface ReclaimTokenOptions {
15
+ home: string;
16
+ stateDir: string;
17
+ exec: CliExecutor;
18
+ }
19
+ export interface ReclaimTokenResult {
20
+ token: string | null;
21
+ detail: string;
22
+ }
14
23
  export declare function storedTokenPath(stateDir: string): string;
15
24
  export declare function readStoredToken(stateDir: string): string | null;
16
25
  export declare function storeToken(stateDir: string, token: string): void;
@@ -18,3 +27,14 @@ export declare function generateToken(): string;
18
27
  /** Whether home-hosted already holds an API token (only its hash is on disk). */
19
28
  export declare function apiTokenEnrolled(home: string): boolean;
20
29
  export declare function ensureToken(options: EnsureTokenOptions): Promise<EnsureTokenResult>;
30
+ /**
31
+ * Replace the panel's API token with a fresh one.
32
+ *
33
+ * home-hosted keeps only the token's hash, so a token this plugin does not hold
34
+ * cannot be recovered: a fresh token is minted, enrolled through the same
35
+ * `HHOSTED_TOKEN` path `ensureToken` uses — one write, since `set-token` replaces
36
+ * whatever hash is there — and only then is the plaintext stored 0600. The
37
+ * attempted write carries no risk to the stored token: a failure replaced
38
+ * nothing, so the previous plaintext is kept.
39
+ */
40
+ export declare function reclaimToken(options: ReclaimTokenOptions): Promise<ReclaimTokenResult>;
@@ -9,6 +9,7 @@
9
9
  import { Service, type Context } from '@deepseek-ai/cordis';
10
10
  import type { BootMechanism, BootStatus, HomeHostedStatus, ManagedEntryStatus, PanelControlResult, PanelStatus, RpcEndpoint, UiAction, UiResult } from './shared/contracts.js';
11
11
  import type { BootSpec } from './boot/types.js';
12
+ import type { DshLaunch } from './home-hosted/dsh-entry.js';
12
13
  import type { SettingsStore } from './settings.js';
13
14
  import type { RunResult } from './util/exec.js';
14
15
  /** What a boot install/uninstall answers with; mirrors the boot module's shape. */
@@ -42,13 +43,23 @@ export interface HomeHostedServiceOptions {
42
43
  execCli?: (args: string[], env: Record<string, string | undefined>) => Promise<RunResult>;
43
44
  /** Test seam: supply the boot ladder instead of probing the real OS. */
44
45
  createLadder?: () => BootLadderLike;
46
+ /** Test seam: resolve the running harness instead of reading this process. */
47
+ resolveDsh?: (options: {
48
+ dshHome: string;
49
+ stateDir: string;
50
+ }) => Promise<DshLaunch | null>;
45
51
  }
46
52
  export declare class HomeHostedService extends Service {
47
53
  private readonly options;
48
54
  private clientCache;
55
+ /** The last token this service proved against a panel, so a poll does not re-probe it. */
56
+ private tokenProof;
49
57
  private tokenDetail;
58
+ /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
59
+ private entryRecoveryAt;
50
60
  private readonly snapshotsFile;
51
61
  constructor(ctx: Context, options: HomeHostedServiceOptions);
62
+ private resolveDsh;
52
63
  private runtime;
53
64
  /** The entry id this very process was started as, when the panel supervises us. */
54
65
  selfEntryId(): string | null;
@@ -69,13 +80,36 @@ export declare class HomeHostedService extends Service {
69
80
  private configuredPort;
70
81
  /** Write the chosen panel port into the state the panel boots from. */
71
82
  private applyPanelPort;
83
+ /** Whether this exact token was already proved against this exact panel. */
84
+ private tokenProven;
85
+ private proveToken;
72
86
  panelStatus(): Promise<PanelStatus>;
87
+ /**
88
+ * Replace the panel's API token with a fresh one and prove it.
89
+ *
90
+ * home-hosted keeps only a hash, so a token this plugin does not hold cannot
91
+ * be recovered: a new token replaces the old hash in one CLI write, and it is
92
+ * proved against the answering panel before being reported.
93
+ *
94
+ * The panel is not required to be *answering*: a missing or refused token is a
95
+ * common reason it cannot be reached at all, and refusing the repair for that
96
+ * reason would leave no way out from the page. Only a panel that was never
97
+ * started is refused — there is nothing to enrol a token against yet.
98
+ */
99
+ reclaimPanelToken(): Promise<HomeHostedStatus>;
73
100
  private tryClient;
74
101
  private requireClient;
75
102
  private snapshots;
76
103
  private saveSnapshots;
77
104
  private liveEntries;
78
105
  private createEntry;
106
+ /**
107
+ * An entry written before the launcher existed still runs a clone's build
108
+ * output directly. Point it at the stable launcher, or the fix would only
109
+ * ever apply to freshly created entries — the plugin never rewrites an
110
+ * existing entry's command.
111
+ */
112
+ private dshCommandRepair;
79
113
  private writeOwned;
80
114
  private panelRunning;
81
115
  /**
@@ -133,6 +167,18 @@ export declare class HomeHostedService extends Service {
133
167
  * an explicit click or an approved tool call, not as a side effect of startup.
134
168
  */
135
169
  reconcile(): Promise<void>;
170
+ /**
171
+ * Put back a managed entry that no longer exists, while its intent still wants
172
+ * autostart. A paused intent is a deliberate stop and is left alone.
173
+ *
174
+ * Called from startup `reconcile()` as well as the status read, because a person
175
+ * who deletes the entry from the panel is looking at the page right then — not
176
+ * at the next plugin start. A failed attempt is not repeated for a while, so a
177
+ * config that cannot be written does not turn every poll into a write.
178
+ */
179
+ private ensureManagedEntry;
180
+ /** Re-assert the boot entry when the OS still has it but it stopped working. */
181
+ private repairBootEntry;
136
182
  status(): Promise<HomeHostedStatus>;
137
183
  call(endpoint: RpcEndpoint, payload: unknown): Promise<unknown>;
138
184
  }
@@ -21,7 +21,7 @@ export type Envelope<T> = {
21
21
  error: RpcError;
22
22
  };
23
23
  /** Endpoints the browser and the agent tools both speak. */
24
- export type RpcEndpoint = 'status' | 'servers.list' | 'servers.get' | 'servers.create' | 'servers.update' | 'servers.delete' | 'servers.start' | 'servers.stop' | 'servers.restart' | 'servers.freePort' | 'entries.apply' | 'entries.remove' | 'entries.restore' | 'boot.install' | 'boot.uninstall' | 'boot.verify' | 'panel.start' | 'panel.takeover' | 'cli.installGlobal' | 'ui.manage' | 'settings.update';
24
+ export type RpcEndpoint = 'status' | 'servers.list' | 'servers.get' | 'servers.create' | 'servers.update' | 'servers.delete' | 'servers.start' | 'servers.stop' | 'servers.restart' | 'servers.freePort' | 'entries.apply' | 'entries.remove' | 'entries.restore' | 'boot.install' | 'boot.uninstall' | 'boot.verify' | 'panel.start' | 'panel.takeover' | 'panel.reclaimToken' | 'cli.installGlobal' | 'ui.manage' | 'settings.update';
25
25
  /** A settings write sends only the changed subtree; the host merges group by group. */
26
26
  export interface SettingsPatch {
27
27
  autostart?: Partial<PluginSettings['autostart']>;
@@ -29,6 +29,7 @@ export interface SettingsPatch {
29
29
  entries?: PluginSettings['entries'];
30
30
  panel?: Partial<PluginSettings['panel']>;
31
31
  authNotice?: boolean;
32
+ reclaimToken?: boolean;
32
33
  uiStyle?: UiStyle;
33
34
  agentTools?: Partial<PluginSettings['agentTools']>;
34
35
  cli?: Partial<PluginSettings['cli']>;
@@ -87,6 +88,11 @@ export interface EndpointPayloads {
87
88
  'panel.takeover': {
88
89
  force?: boolean;
89
90
  };
91
+ /**
92
+ * Mint a fresh panel API token and enrol it, replacing one the panel refuses.
93
+ * Clearing first is what makes it work when home-hosted already holds a hash.
94
+ */
95
+ 'panel.reclaimToken': Record<string, never>;
90
96
  /** Install the pinned range as a global CLI, so the `global` preference can use it. */
91
97
  'cli.installGlobal': Record<string, never>;
92
98
  /** Drive the panel's own UI: status, update, revert, or switch to a local build. */
@@ -143,7 +149,7 @@ export interface EntryIntent {
143
149
  stopKillPortHolders: boolean;
144
150
  }
145
151
  /** Keys an intent owns: a patch touches these and nothing else. */
146
- export declare const OWNED_ENTRY_KEYS: readonly ["autostart", "onPortConflict", "stop"];
152
+ export declare const OWNED_ENTRY_KEYS: readonly ["autostart", "onPortConflict", "persistent", "stop"];
147
153
  export interface ServerEntryView {
148
154
  id: string;
149
155
  status: string;
@@ -161,7 +167,12 @@ export interface ManagedEntryStatus {
161
167
  live: ServerEntryView | null;
162
168
  snapshot: ServerEntry | null;
163
169
  }
164
- export type TokenState = 'enrolled' | 'present' | 'absent' | 'unknown';
170
+ /**
171
+ * `enrolled` — the plugin holds a token; `present` — home-hosted has one this
172
+ * plugin does not hold; `absent` — none exists yet; `stale` — the plugin holds
173
+ * one the panel just refused; `unknown` — the panel was not reachable to ask.
174
+ */
175
+ export type TokenState = 'enrolled' | 'present' | 'absent' | 'stale' | 'unknown';
165
176
  export interface PanelStatus {
166
177
  /** `$HHOSTED_HOME` this plugin resolved, whether or not the panel answers. */
167
178
  home: string;
@@ -174,6 +185,8 @@ export interface PanelStatus {
174
185
  /** How writes are authenticated right now. */
175
186
  writeVia: 'api' | 'file' | 'none';
176
187
  token: TokenState;
188
+ /** True when the panel was asked and accepted the token, so `token` is measured, not assumed. */
189
+ tokenVerified?: boolean;
177
190
  detail: string;
178
191
  }
179
192
  /** Which home-hosted CLI the plugin drives, and where it came from. */
@@ -280,6 +293,12 @@ export interface PluginSettings {
280
293
  };
281
294
  /** Point dsh web's sign-in page at the panel log that holds the tokenised URL. */
282
295
  authNotice: boolean;
296
+ /**
297
+ * When the panel refuses the plugin's API token, re-enrol one on the spot
298
+ * instead of failing the call. On by default: a token that went stale (someone
299
+ * cleared it, or minted their own) should not keep an agent tool from working.
300
+ */
301
+ reclaimToken: boolean;
283
302
  /** `detailed` shows cards and open disclosures; `compact` folds both away. */
284
303
  uiStyle: UiStyle;
285
304
  cli: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-home-hosted",
3
- "version": "0.2.2",
3
+ "version": "0.3.2",
4
4
  "description": "DeepSeek Harness (dsh) plugin: start your dsh web server automatically at boot, and manage home-hosted's panel and servers from inside dsh.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -117,6 +117,6 @@
117
117
  "vitest": "^2.1.8"
118
118
  },
119
119
  "dependencies": {
120
- "home-hosted": "^0.6.4"
120
+ "home-hosted": "^0.6.6"
121
121
  }
122
122
  }