@phnx-labs/agents-cli 1.22.53 → 1.22.54
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 +190 -0
- package/README.md +41 -8
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +219 -173
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +10 -8
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +2 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +7 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +64 -20
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +52 -3
- package/dist/commands/share.js +262 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +18 -2
- package/dist/commands/status.js +14 -0
- package/dist/commands/view.d.ts +3 -1
- package/dist/commands/view.js +5 -4
- package/dist/lib/account-registry.js +15 -3
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +37 -1
- package/dist/lib/accounting/usage.js +71 -6
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/daemon.js +30 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +5 -5
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -1
- package/dist/lib/devices/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/exec.d.ts +28 -0
- package/dist/lib/exec.js +73 -7
- package/dist/lib/feed/feed.d.ts +1 -1
- package/dist/lib/feed/feed.js +23 -1
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- package/dist/lib/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.js +8 -1
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.js +1 -4
- package/dist/lib/session/db.d.ts +39 -4
- package/dist/lib/session/db.js +130 -29
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +119 -25
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/share/publish.d.ts +53 -5
- package/dist/lib/share/publish.js +99 -17
- package/dist/lib/share/worker-template.js +582 -57
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +16 -0
- package/dist/lib/state.js +178 -46
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/traces/classify.js +24 -19
- package/dist/lib/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +2 -0
- package/package.json +2 -1
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
|
|
15
15
|
* for paths this machine can see.
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* Convert an absolute cwd to Claude Code's own project-folder name under
|
|
19
|
+
* `~/.claude/projects/` (slashes and dots become dashes) — the encoding
|
|
20
|
+
* Claude Code itself performs to name each project's session-transcript and
|
|
21
|
+
* native-memory directory. Pure, no filesystem: session discovery and
|
|
22
|
+
* native-memory sync both derive from this so they agree on one directory
|
|
23
|
+
* for a given cwd.
|
|
24
|
+
*/
|
|
25
|
+
export declare function claudeProjectDirName(cwd: string): string;
|
|
17
26
|
/**
|
|
18
27
|
* Resolve a stable project key from a working directory, or `undefined` when
|
|
19
28
|
* the path carries nothing usable (empty, `/`, whitespace).
|
package/dist/lib/project-key.js
CHANGED
|
@@ -18,6 +18,17 @@ import * as fs from 'fs';
|
|
|
18
18
|
import * as os from 'os';
|
|
19
19
|
import * as path from 'path';
|
|
20
20
|
const WORKTREE_SEGMENT = '/.agents/worktrees/';
|
|
21
|
+
/**
|
|
22
|
+
* Convert an absolute cwd to Claude Code's own project-folder name under
|
|
23
|
+
* `~/.claude/projects/` (slashes and dots become dashes) — the encoding
|
|
24
|
+
* Claude Code itself performs to name each project's session-transcript and
|
|
25
|
+
* native-memory directory. Pure, no filesystem: session discovery and
|
|
26
|
+
* native-memory sync both derive from this so they agree on one directory
|
|
27
|
+
* for a given cwd.
|
|
28
|
+
*/
|
|
29
|
+
export function claudeProjectDirName(cwd) {
|
|
30
|
+
return cwd.replace(/[/.]/g, '-');
|
|
31
|
+
}
|
|
21
32
|
/**
|
|
22
33
|
* Resolve a stable project key from a working directory, or `undefined` when
|
|
23
34
|
* the path carries nothing usable (empty, `/`, whitespace).
|
|
@@ -101,6 +101,41 @@ export declare const ENV_KEY_PATTERN: RegExp;
|
|
|
101
101
|
export declare const BUNDLE_KEY_PATTERN: RegExp;
|
|
102
102
|
export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
|
|
103
103
|
export declare const RESERVED_ENV_NAMES: Set<string>;
|
|
104
|
+
/**
|
|
105
|
+
* The reserved FILE-BACKED bundle that holds long-lived Claude setup-tokens.
|
|
106
|
+
* Usage/probe reads authenticate with these instead of the ACL-bound login item,
|
|
107
|
+
* so they never pop Touch ID and they can cross the fleet. A keychain- or
|
|
108
|
+
* vault-backed bundle of this name is a misconfiguration: the consumer used to
|
|
109
|
+
* return null (SEC-GAP-3) and silently fall through to Touch ID.
|
|
110
|
+
*/
|
|
111
|
+
export declare const AUTH_BUNDLE_NAME = "auth";
|
|
112
|
+
export declare const AUTH_BUNDLE_BACKEND: SecretsBackend;
|
|
113
|
+
export declare const RESERVED_BUNDLE_NAMES: Set<string>;
|
|
114
|
+
export declare function isReservedBundleName(name: string): boolean;
|
|
115
|
+
/** Thrown when a reserved bundle is written or resolved on the wrong backend. */
|
|
116
|
+
export declare class ReservedBundleWrongBackendError extends Error {
|
|
117
|
+
readonly bundle: string;
|
|
118
|
+
readonly backend: SecretsBackend;
|
|
119
|
+
constructor(bundle: string, backend: SecretsBackend);
|
|
120
|
+
}
|
|
121
|
+
/** Fail loud when `name` is reserved and `backend` is not the required one. */
|
|
122
|
+
export declare function assertReservedBundleBackend(name: string, backend: SecretsBackend): void;
|
|
123
|
+
/**
|
|
124
|
+
* Presence + backend of the reserved `auth` bundle. `ok` is true when the
|
|
125
|
+
* bundle is absent (nothing to fix) or present and file-backed.
|
|
126
|
+
*/
|
|
127
|
+
export declare function inspectReservedAuthBundle(): {
|
|
128
|
+
exists: boolean;
|
|
129
|
+
backend: SecretsBackend | null;
|
|
130
|
+
ok: boolean;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* After a file-backed import, actually decrypt the keys and fail if any are
|
|
134
|
+
* unreadable. Import used to print "Imported N key(s)" from the write tally
|
|
135
|
+
* alone — ciphertext sealed under a forwarded AGENTS_SECRETS_PASSPHRASE that
|
|
136
|
+
* the destination daemon does not hold still counted as success.
|
|
137
|
+
*/
|
|
138
|
+
export declare function assertFileBundleDecryptable(name: string, keys: string[]): void;
|
|
104
139
|
export declare function bundleToEnvPrefix(name: string): string;
|
|
105
140
|
export declare function isReservedEnvName(key: string): boolean;
|
|
106
141
|
export declare function bundleKeyToEnvKey(key: string): string;
|
|
@@ -137,6 +137,82 @@ export const RESERVED_ENV_NAMES = new Set([
|
|
|
137
137
|
'TERM', 'LANG', 'LC_ALL', 'DISPLAY', 'EDITOR', 'VISUAL',
|
|
138
138
|
'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
|
|
139
139
|
]);
|
|
140
|
+
/**
|
|
141
|
+
* The reserved FILE-BACKED bundle that holds long-lived Claude setup-tokens.
|
|
142
|
+
* Usage/probe reads authenticate with these instead of the ACL-bound login item,
|
|
143
|
+
* so they never pop Touch ID and they can cross the fleet. A keychain- or
|
|
144
|
+
* vault-backed bundle of this name is a misconfiguration: the consumer used to
|
|
145
|
+
* return null (SEC-GAP-3) and silently fall through to Touch ID.
|
|
146
|
+
*/
|
|
147
|
+
export const AUTH_BUNDLE_NAME = 'auth';
|
|
148
|
+
export const AUTH_BUNDLE_BACKEND = 'file';
|
|
149
|
+
export const RESERVED_BUNDLE_NAMES = new Set([AUTH_BUNDLE_NAME]);
|
|
150
|
+
export function isReservedBundleName(name) {
|
|
151
|
+
return RESERVED_BUNDLE_NAMES.has(name.trim().toLowerCase());
|
|
152
|
+
}
|
|
153
|
+
/** Thrown when a reserved bundle is written or resolved on the wrong backend. */
|
|
154
|
+
export class ReservedBundleWrongBackendError extends Error {
|
|
155
|
+
bundle;
|
|
156
|
+
backend;
|
|
157
|
+
constructor(bundle, backend) {
|
|
158
|
+
super(`Bundle '${bundle}' is reserved for file-backed setup-tokens (headless, fleet-shareable). ` +
|
|
159
|
+
`A ${backend}-backed '${bundle}' bundle is ignored by usage/probe instead of authenticating. ` +
|
|
160
|
+
`Recreate it as file-backed: agents secrets delete ${bundle} --yes && agents secrets create ${bundle} --backend file`);
|
|
161
|
+
this.name = 'ReservedBundleWrongBackendError';
|
|
162
|
+
this.bundle = bundle;
|
|
163
|
+
this.backend = backend;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Fail loud when `name` is reserved and `backend` is not the required one. */
|
|
167
|
+
export function assertReservedBundleBackend(name, backend) {
|
|
168
|
+
if (!isReservedBundleName(name))
|
|
169
|
+
return;
|
|
170
|
+
if (backend !== AUTH_BUNDLE_BACKEND) {
|
|
171
|
+
throw new ReservedBundleWrongBackendError(name, backend);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Presence + backend of the reserved `auth` bundle. `ok` is true when the
|
|
176
|
+
* bundle is absent (nothing to fix) or present and file-backed.
|
|
177
|
+
*/
|
|
178
|
+
export function inspectReservedAuthBundle() {
|
|
179
|
+
if (!bundleExists(AUTH_BUNDLE_NAME)) {
|
|
180
|
+
return { exists: false, backend: null, ok: true };
|
|
181
|
+
}
|
|
182
|
+
const backend = bundleBackend(AUTH_BUNDLE_NAME);
|
|
183
|
+
return { exists: true, backend, ok: backend === AUTH_BUNDLE_BACKEND };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* After a file-backed import, actually decrypt the keys and fail if any are
|
|
187
|
+
* unreadable. Import used to print "Imported N key(s)" from the write tally
|
|
188
|
+
* alone — ciphertext sealed under a forwarded AGENTS_SECRETS_PASSPHRASE that
|
|
189
|
+
* the destination daemon does not hold still counted as success.
|
|
190
|
+
*/
|
|
191
|
+
export function assertFileBundleDecryptable(name, keys) {
|
|
192
|
+
if (keys.length === 0)
|
|
193
|
+
return;
|
|
194
|
+
if (bundleBackend(name) !== 'file')
|
|
195
|
+
return;
|
|
196
|
+
let env;
|
|
197
|
+
try {
|
|
198
|
+
({ env } = readAndResolveBundleEnv(name, { caller: 'import-verify', agentOnly: true, keyMode: 'storage' }));
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
throw new Error(`Imported '${name}' reported success but the file store could not decrypt it. ` +
|
|
202
|
+
`${err.message} Typically because AGENTS_SECRETS_PASSPHRASE was forwarded ` +
|
|
203
|
+
`and this process does not hold it. Re-import without that env var.`);
|
|
204
|
+
}
|
|
205
|
+
const missing = keys.filter((k) => {
|
|
206
|
+
const v = env[k];
|
|
207
|
+
return typeof v !== 'string' || v.length === 0;
|
|
208
|
+
});
|
|
209
|
+
if (missing.length === 0)
|
|
210
|
+
return;
|
|
211
|
+
throw new Error(`Imported '${name}' reported success but ${missing.length} key(s) are unreadable ` +
|
|
212
|
+
`(${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}). ` +
|
|
213
|
+
`The destination store could not decrypt them — typically because AGENTS_SECRETS_PASSPHRASE ` +
|
|
214
|
+
`was forwarded and this process does not hold it. Re-import without that env var.`);
|
|
215
|
+
}
|
|
140
216
|
export function bundleToEnvPrefix(name) {
|
|
141
217
|
return name.replace(/[-\.]/g, '_').toUpperCase();
|
|
142
218
|
}
|
|
@@ -394,6 +470,7 @@ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOv
|
|
|
394
470
|
function prepareBundleWrite(bundle) {
|
|
395
471
|
validateBundleName(bundle.name);
|
|
396
472
|
const backend = bundle.backend ?? 'keychain';
|
|
473
|
+
assertReservedBundleBackend(bundle.name, backend);
|
|
397
474
|
if (backend === 'vault')
|
|
398
475
|
assertVaultBackendUsable(bundle.name);
|
|
399
476
|
for (const key of Object.keys(bundle.vars)) {
|
|
@@ -1323,7 +1400,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1323
1400
|
// If the secrets broker is explicitly disabled and this bundle would otherwise
|
|
1324
1401
|
// fall through to a keychain read (Touch ID prompt), fail loud now. Never-policy
|
|
1325
1402
|
// bundles are verified below and remain silent; vault/file backends are unaffected.
|
|
1326
|
-
if (backend === 'keychain' && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1403
|
+
if (backend === 'keychain' && !verifiedNoAclBundle && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1327
1404
|
throw new Error(`Secrets broker is disabled — re-enable with 'agents daemon services enable secrets-broker'. ` +
|
|
1328
1405
|
`If you meant to read directly from the keychain, set AGENTS_SECRETS_NO_AGENT=1.`);
|
|
1329
1406
|
}
|
|
@@ -21,14 +21,9 @@ export interface PushBundleOptions {
|
|
|
21
21
|
/** Overwrite a key that already exists on the remote. */
|
|
22
22
|
force?: boolean;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Empty is the DEFAULT and the good path: the remote's file store then
|
|
28
|
-
* auto-provisions its own machine-local key (0600, `~/.agents/.secrets-key/`)
|
|
29
|
-
* and reads headlessly. Setting this keys the remote bundle under a shared
|
|
30
|
-
* off-disk secret instead — an opt-in, never a requirement. Requiring one is
|
|
31
|
-
* what pushed operators toward exporting the master key fleet-wide (RUSH-1968).
|
|
24
|
+
* Ignored. File-backend export never forwards AGENTS_SECRETS_PASSPHRASE
|
|
25
|
+
* (PHNX-2371); the remote auto-provisions its own machine-local key.
|
|
26
|
+
* Kept on the options type so existing callers that passed one still compile.
|
|
32
27
|
*/
|
|
33
28
|
passphrase?: string;
|
|
34
29
|
/** Label for the audit trail — `export --device` vs `fleet apply`. */
|
package/dist/lib/secrets/push.js
CHANGED
|
@@ -87,7 +87,6 @@ export function planPushTransport(resolved, bundle, host, opts) {
|
|
|
87
87
|
return { kind: 'refuse', message: 'file backend export to a Windows target is not yet supported' };
|
|
88
88
|
}
|
|
89
89
|
const { remoteCmd, input } = buildRemoteFileImportCommand(bundle, resolved.dotenv, {
|
|
90
|
-
passphrase: opts.passphrase ?? '',
|
|
91
90
|
force: opts.force,
|
|
92
91
|
policyNever: opts.policyNever,
|
|
93
92
|
});
|
|
@@ -148,20 +147,25 @@ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
|
|
|
148
147
|
const msg = (res.stderr || res.stdout || '').trim();
|
|
149
148
|
return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
|
|
150
149
|
}
|
|
151
|
-
// A
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
return fail(verdict.
|
|
162
|
-
? keychainWriteFailureMessage(host, bundle, verdict.reason)
|
|
163
|
-
: `pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
|
|
150
|
+
// A successful remote import is not proof the keys are readable. Two silent
|
|
151
|
+
// failures hide behind exit 0:
|
|
152
|
+
// - keychain: a macOS login keychain is locked under headless SSH, so the
|
|
153
|
+
// write lands metadata but no readable value items.
|
|
154
|
+
// - file: a forwarded AGENTS_SECRETS_PASSPHRASE keys ciphertext to a secret
|
|
155
|
+
// the destination daemon does not hold (PHNX-2371).
|
|
156
|
+
// Read the bundle back the way a later resolve will and FAIL LOUDLY.
|
|
157
|
+
const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true });
|
|
158
|
+
if (!verdict.ok) {
|
|
159
|
+
if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
|
|
160
|
+
return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
|
|
164
161
|
}
|
|
162
|
+
if (opts.remoteBackend === 'file') {
|
|
163
|
+
return fail(`${host}: pushed '${bundle}' but the remote could not decrypt it (${verdict.reason}). ` +
|
|
164
|
+
`File-backend export never forwards AGENTS_SECRETS_PASSPHRASE — the remote keys ` +
|
|
165
|
+
`the bundle under its own machine-local key. If the destination still cannot read, ` +
|
|
166
|
+
`unset a stale AGENTS_SECRETS_PASSPHRASE there (\`agents doctor\`).`);
|
|
167
|
+
}
|
|
168
|
+
return fail(`pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
|
|
165
169
|
}
|
|
166
170
|
for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
|
|
167
171
|
const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true });
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
13
|
* boundary as `export --device` / `run --device`). Bundle names are shell-quoted
|
|
14
|
-
* into the remote command; resolved VALUES return over ssh stdout
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout. File-backend
|
|
15
|
+
* import never forwards AGENTS_SECRETS_PASSPHRASE (PHNX-2371). Nothing is
|
|
16
|
+
* persisted locally.
|
|
17
17
|
*/
|
|
18
18
|
import { type SshExecResult } from '../ssh-exec.js';
|
|
19
19
|
/**
|
|
@@ -208,26 +208,17 @@ export declare function verifyRemoteKeychainPush(target: string, bundle: string,
|
|
|
208
208
|
* The `bash -lc` command + stdin payload that drives a **file-backed** remote
|
|
209
209
|
* import for `secrets export --device … --remote-backend file`.
|
|
210
210
|
*
|
|
211
|
-
* The file store is passphrase-free
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* - **No passphrase** → the remote runs `import … --backend file` directly with
|
|
219
|
-
* ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
|
|
220
|
-
* prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
|
|
221
|
-
* machine-local key path → headless reads.
|
|
222
|
-
* - **Passphrase set** → forward it as the FIRST stdin line, consumed by
|
|
223
|
-
* `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
|
|
224
|
-
* then the .env. The remote then keys the bundle under that shared passphrase.
|
|
211
|
+
* The file store is passphrase-free: the remote `agents secrets import --backend
|
|
212
|
+
* file` auto-provisions the remote's own machine-local key (0600 under
|
|
213
|
+
* `~/.agents/.secrets-key/`), so its reads are HEADLESS — no passphrase, no
|
|
214
|
+
* Touch ID. AGENTS_SECRETS_PASSPHRASE is a deprecated override and MUST NOT be
|
|
215
|
+
* forwarded (PHNX-2371): a remote keyed to a secret its daemon does not hold
|
|
216
|
+
* reports "Imported N key(s)" then fails every later decrypt.
|
|
225
217
|
*
|
|
226
218
|
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
227
219
|
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
228
220
|
*/
|
|
229
221
|
export declare function buildRemoteFileImportCommand(bundle: string, dotenv: string, opts?: {
|
|
230
|
-
passphrase?: string;
|
|
231
222
|
force?: boolean;
|
|
232
223
|
policyNever?: boolean;
|
|
233
224
|
}): {
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
13
|
* boundary as `export --device` / `run --device`). Bundle names are shell-quoted
|
|
14
|
-
* into the remote command; resolved VALUES return over ssh stdout
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout. File-backend
|
|
15
|
+
* import never forwards AGENTS_SECRETS_PASSPHRASE (PHNX-2371). Nothing is
|
|
16
|
+
* persisted locally.
|
|
17
17
|
*/
|
|
18
18
|
import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
@@ -417,20 +417,12 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
|
|
|
417
417
|
* The `bash -lc` command + stdin payload that drives a **file-backed** remote
|
|
418
418
|
* import for `secrets export --device … --remote-backend file`.
|
|
419
419
|
*
|
|
420
|
-
* The file store is passphrase-free
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* - **No passphrase** → the remote runs `import … --backend file` directly with
|
|
428
|
-
* ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
|
|
429
|
-
* prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
|
|
430
|
-
* machine-local key path → headless reads.
|
|
431
|
-
* - **Passphrase set** → forward it as the FIRST stdin line, consumed by
|
|
432
|
-
* `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
|
|
433
|
-
* then the .env. The remote then keys the bundle under that shared passphrase.
|
|
420
|
+
* The file store is passphrase-free: the remote `agents secrets import --backend
|
|
421
|
+
* file` auto-provisions the remote's own machine-local key (0600 under
|
|
422
|
+
* `~/.agents/.secrets-key/`), so its reads are HEADLESS — no passphrase, no
|
|
423
|
+
* Touch ID. AGENTS_SECRETS_PASSPHRASE is a deprecated override and MUST NOT be
|
|
424
|
+
* forwarded (PHNX-2371): a remote keyed to a secret its daemon does not hold
|
|
425
|
+
* reports "Imported N key(s)" then fails every later decrypt.
|
|
434
426
|
*
|
|
435
427
|
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
436
428
|
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
@@ -439,14 +431,7 @@ export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
|
|
|
439
431
|
const force = opts.force ? ' --force' : '';
|
|
440
432
|
const policy = opts.policyNever ? ' --policy never --i-understand' : '';
|
|
441
433
|
const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}${policy}`;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// Opt-in shared passphrase: read it off the FIRST stdin line, export it, then
|
|
445
|
-
// let `import --from -` read the .env remainder.
|
|
446
|
-
const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ${importCmd}`;
|
|
447
|
-
return { remoteCmd: `bash -lc ${shellQuote(remoteAgents)}`, input: `${passphrase}\n${dotenv}` };
|
|
448
|
-
}
|
|
449
|
-
// No passphrase: NO prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the
|
|
450
|
-
// remote, so the file store falls back to its machine-local key (headless).
|
|
434
|
+
// No prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the remote, so the
|
|
435
|
+
// file store falls back to its machine-local key (headless).
|
|
451
436
|
return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
|
|
452
437
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type PushBundleResult } from './push.js';
|
|
2
|
+
import { type DeviceProfile } from '../devices/registry.js';
|
|
3
|
+
import type { DeviceProbe } from '../fleet/types.js';
|
|
4
|
+
export interface AuthSyncDevice {
|
|
5
|
+
name: string;
|
|
6
|
+
reachable: boolean;
|
|
7
|
+
pinned: boolean;
|
|
8
|
+
remoteHasAuth: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type AuthSyncPlanItem = {
|
|
11
|
+
action: 'push';
|
|
12
|
+
device: string;
|
|
13
|
+
} | {
|
|
14
|
+
action: 'skip';
|
|
15
|
+
device: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function planAuthBundlePush(localAuthOk: boolean, devices: AuthSyncDevice[]): AuthSyncPlanItem[];
|
|
19
|
+
export interface AuthSyncResult {
|
|
20
|
+
pushed: string[];
|
|
21
|
+
skipped: Array<{
|
|
22
|
+
device: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
}>;
|
|
25
|
+
errors: Array<{
|
|
26
|
+
device: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
export interface AuthSyncProbe {
|
|
31
|
+
reachable: boolean;
|
|
32
|
+
remoteHasAuth: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface AuthSyncDeps {
|
|
35
|
+
inspectLocal?: () => {
|
|
36
|
+
exists: boolean;
|
|
37
|
+
ok: boolean;
|
|
38
|
+
};
|
|
39
|
+
listDevices?: () => DeviceProfile[];
|
|
40
|
+
localName?: string;
|
|
41
|
+
isPinned?: (name: string) => boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Per-device reachability + auth presence. Default: `probeDevice({ withSecrets: true })`
|
|
44
|
+
* — the same remote-presence probe `fleet apply` uses in `decideSecretPush`.
|
|
45
|
+
*/
|
|
46
|
+
probe?: (device: DeviceProfile) => AuthSyncProbe;
|
|
47
|
+
push?: (bundle: string, host: string) => PushBundleResult;
|
|
48
|
+
sshTarget?: (device: DeviceProfile) => string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
|
|
52
|
+
*
|
|
53
|
+
* Presence is an own-property check on `remoteBundles` (same gate as
|
|
54
|
+
* `decideSecretPush`): a missing listing, a parse failure, or a prototype
|
|
55
|
+
* name must all mean "not present, so push" — never "present, so skip".
|
|
56
|
+
*/
|
|
57
|
+
export declare function authPresenceFromProbe(probe: DeviceProbe): AuthSyncProbe;
|
|
58
|
+
/** Production default: live SSH probe of reachability + `secrets list --json`. */
|
|
59
|
+
export declare function defaultAuthSyncProbe(device: DeviceProfile): AuthSyncProbe;
|
|
60
|
+
/**
|
|
61
|
+
* Push the local file-backed `auth` bundle to every pinned registered device
|
|
62
|
+
* that does not already have it. No-op when the local bundle is missing or on
|
|
63
|
+
* the wrong backend (doctor flags that separately).
|
|
64
|
+
*/
|
|
65
|
+
export declare function syncReservedAuthBundle(deps?: AuthSyncDeps): AuthSyncResult;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371).
|
|
3
|
+
*
|
|
4
|
+
* Setup-tokens drift per box when sync is manual. This module plans + executes
|
|
5
|
+
* a push of the local file-backed `auth` bundle to registered devices that do
|
|
6
|
+
* not yet have it, always with `--remote-backend file` and never forwarding
|
|
7
|
+
* AGENTS_SECRETS_PASSPHRASE. Each destination auto-provisions its own
|
|
8
|
+
* machine-local key and the push read-back-verifies decryptability.
|
|
9
|
+
*
|
|
10
|
+
* Double-fire: each device's daemon only writes that device's own store (the
|
|
11
|
+
* destination). Two boxes that both have `auth` skip each other (already
|
|
12
|
+
* present). A box without `auth` is a pull target for a provisioned peer.
|
|
13
|
+
*
|
|
14
|
+
* The planner is pure so tests cover every skip/push branch with no SSH.
|
|
15
|
+
*/
|
|
16
|
+
import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle } from './bundles.js';
|
|
17
|
+
import { pushBundleToHost } from './push.js';
|
|
18
|
+
import { loadDevicesSync } from '../devices/registry.js';
|
|
19
|
+
import { sshTargetFor } from '../devices/connect.js';
|
|
20
|
+
import { isHostPinned, managedKnownHostsPath } from '../devices/known-hosts.js';
|
|
21
|
+
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
22
|
+
import { probeDevice } from '../fleet/apply.js';
|
|
23
|
+
export function planAuthBundlePush(localAuthOk, devices) {
|
|
24
|
+
if (!localAuthOk) {
|
|
25
|
+
return devices.map((d) => ({
|
|
26
|
+
action: 'skip',
|
|
27
|
+
device: d.name,
|
|
28
|
+
reason: 'no local file-backed auth bundle',
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
return devices.map((d) => {
|
|
32
|
+
if (!d.reachable)
|
|
33
|
+
return { action: 'skip', device: d.name, reason: 'unreachable' };
|
|
34
|
+
if (!d.pinned) {
|
|
35
|
+
return {
|
|
36
|
+
action: 'skip',
|
|
37
|
+
device: d.name,
|
|
38
|
+
reason: `host key not pinned; run \`agents ssh ${d.name}\` once`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (d.remoteHasAuth)
|
|
42
|
+
return { action: 'skip', device: d.name, reason: 'already present' };
|
|
43
|
+
return { action: 'push', device: d.name };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
|
|
48
|
+
*
|
|
49
|
+
* Presence is an own-property check on `remoteBundles` (same gate as
|
|
50
|
+
* `decideSecretPush`): a missing listing, a parse failure, or a prototype
|
|
51
|
+
* name must all mean "not present, so push" — never "present, so skip".
|
|
52
|
+
*/
|
|
53
|
+
export function authPresenceFromProbe(probe) {
|
|
54
|
+
return {
|
|
55
|
+
reachable: probe.reachable,
|
|
56
|
+
remoteHasAuth: !!(probe.remoteBundles &&
|
|
57
|
+
Object.prototype.hasOwnProperty.call(probe.remoteBundles, AUTH_BUNDLE_NAME)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Production default: live SSH probe of reachability + `secrets list --json`. */
|
|
61
|
+
export function defaultAuthSyncProbe(device) {
|
|
62
|
+
return authPresenceFromProbe(probeDevice(device, { withSecrets: true }));
|
|
63
|
+
}
|
|
64
|
+
function defaultDevices() {
|
|
65
|
+
return Object.values(loadDevicesSync());
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Push the local file-backed `auth` bundle to every pinned registered device
|
|
69
|
+
* that does not already have it. No-op when the local bundle is missing or on
|
|
70
|
+
* the wrong backend (doctor flags that separately).
|
|
71
|
+
*/
|
|
72
|
+
export function syncReservedAuthBundle(deps = {}) {
|
|
73
|
+
const inspect = deps.inspectLocal ?? inspectReservedAuthBundle;
|
|
74
|
+
const local = inspect();
|
|
75
|
+
const localOk = local.exists && local.ok;
|
|
76
|
+
const localName = normalizeHost(deps.localName ?? machineId());
|
|
77
|
+
const devices = (deps.listDevices ?? defaultDevices)().filter((d) => normalizeHost(d.name) !== localName);
|
|
78
|
+
const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
|
|
79
|
+
const probe = deps.probe ?? defaultAuthSyncProbe;
|
|
80
|
+
const plan = planAuthBundlePush(localOk, devices.map((d) => {
|
|
81
|
+
// No live probe until we know a push is even possible: missing local auth
|
|
82
|
+
// skips every device, and an unpinned host is refused before we SSH.
|
|
83
|
+
if (!localOk) {
|
|
84
|
+
return { name: d.name, reachable: true, pinned: pinned(d.name), remoteHasAuth: false };
|
|
85
|
+
}
|
|
86
|
+
const isPinnedDev = pinned(d.name);
|
|
87
|
+
if (!isPinnedDev) {
|
|
88
|
+
return { name: d.name, reachable: true, pinned: false, remoteHasAuth: false };
|
|
89
|
+
}
|
|
90
|
+
const p = probe(d);
|
|
91
|
+
return {
|
|
92
|
+
name: d.name,
|
|
93
|
+
reachable: p.reachable,
|
|
94
|
+
pinned: true,
|
|
95
|
+
remoteHasAuth: p.remoteHasAuth,
|
|
96
|
+
};
|
|
97
|
+
}));
|
|
98
|
+
const pushed = [];
|
|
99
|
+
const skipped = [];
|
|
100
|
+
const errors = [];
|
|
101
|
+
const push = deps.push ?? ((bundle, host) => pushBundleToHost(bundle, host, {
|
|
102
|
+
remoteBackend: 'file',
|
|
103
|
+
operation: 'auth-sync',
|
|
104
|
+
}));
|
|
105
|
+
const targetOf = deps.sshTarget ?? sshTargetFor;
|
|
106
|
+
for (const item of plan) {
|
|
107
|
+
if (item.action === 'skip') {
|
|
108
|
+
skipped.push({ device: item.device, reason: item.reason });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const profile = devices.find((d) => d.name === item.device);
|
|
112
|
+
if (!profile) {
|
|
113
|
+
skipped.push({ device: item.device, reason: 'not in registry' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const host = targetOf(profile);
|
|
118
|
+
const out = push(AUTH_BUNDLE_NAME, host);
|
|
119
|
+
if (out.ok)
|
|
120
|
+
pushed.push(item.device);
|
|
121
|
+
else
|
|
122
|
+
errors.push({ device: item.device, message: out.message });
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
errors.push({ device: item.device, message: err.message });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { pushed, skipped, errors };
|
|
129
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// hook-manifest check — detects manifest hooks whose `script:` resolves to
|
|
2
|
+
// nothing, so they are registered in config but silently never installed.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists. resolveHookScriptPath only resolves a manifest script under
|
|
5
|
+
// <root>/hooks/, and resolveContainedHookPath rejects any candidate escaping
|
|
6
|
+
// that root. A manifest entry pointing anywhere else returns null and the hook
|
|
7
|
+
// is dropped — no error, no warning, no trace in `agents doctor`.
|
|
8
|
+
//
|
|
9
|
+
// That is not hypothetical. main-branch-guard was declared in agents.yaml as
|
|
10
|
+
// script: rules/subrules/truly-agentic-git-workflow/main-branch-guard.sh
|
|
11
|
+
// which resolves to hooks/rules/subrules/... — a path that does not exist. The
|
|
12
|
+
// guard was authored, tested with a 100+ case suite, and registered, yet
|
|
13
|
+
// reached zero of 25 settings files across three machines. Nothing surfaced it;
|
|
14
|
+
// it was found only after four agent sessions had written into a primary
|
|
15
|
+
// checkout that this exact hook exists to prevent.
|
|
16
|
+
//
|
|
17
|
+
// A guard that silently does not run is worse than a missing one, because the
|
|
18
|
+
// config says it is there. This check turns that silence into a finding.
|
|
19
|
+
import { resultOf } from '../types.js';
|
|
20
|
+
import { parseHookManifest, resolveHookScriptPath } from '../../hooks/install.js';
|
|
21
|
+
export const hookManifestCheck = {
|
|
22
|
+
id: 'hook-manifest',
|
|
23
|
+
title: 'Hook manifest scripts resolve',
|
|
24
|
+
cadence: 'periodic',
|
|
25
|
+
async run(_ctx) {
|
|
26
|
+
const needsAttention = [];
|
|
27
|
+
let manifest;
|
|
28
|
+
try {
|
|
29
|
+
// warn:false — this check reports, it does not double-log.
|
|
30
|
+
manifest = parseHookManifest({ warn: false });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return resultOf([], [`hook manifest unreadable: ${err.message}`]);
|
|
34
|
+
}
|
|
35
|
+
for (const [name, def] of Object.entries(manifest)) {
|
|
36
|
+
if (!def || typeof def.script !== 'string' || def.script.length === 0)
|
|
37
|
+
continue;
|
|
38
|
+
if (def.enabled === false)
|
|
39
|
+
continue;
|
|
40
|
+
// An absolute script (a subrule-composed hook) is used as-is by the
|
|
41
|
+
// installer, so only relative manifest paths go through the hooks/ root
|
|
42
|
+
// resolver that can silently return null.
|
|
43
|
+
if (def.script.startsWith('/'))
|
|
44
|
+
continue;
|
|
45
|
+
if (resolveHookScriptPath(def.script) === null) {
|
|
46
|
+
needsAttention.push(`hook '${name}' declares script '${def.script}', which resolves to no file under any hooks/ root — ` +
|
|
47
|
+
`it is registered but silently never installed. Move the script under hooks/ (or point the manifest at ` +
|
|
48
|
+
`an entrypoint there) so the installer can find it.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Report only. Repair would mean guessing where the author meant the script
|
|
52
|
+
// to live, and a wrong guess would wire the wrong file into a PreToolUse
|
|
53
|
+
// gate. Naming the broken entry is the fix that belongs here.
|
|
54
|
+
return resultOf([], needsAttention);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (all, or by id) — call runSelfHeal.
|
|
7
7
|
import { resourcesCheck } from './checks/resources.js';
|
|
8
8
|
import { hookRuntimeCheck } from './checks/hook-runtime.js';
|
|
9
|
+
import { hookManifestCheck } from './checks/hook-manifest.js';
|
|
9
10
|
import { shimsCheck } from './checks/shims.js';
|
|
10
11
|
import { shadowingCheck } from './checks/shadowing.js';
|
|
11
12
|
import { pathCheck } from './checks/path.js';
|
|
@@ -17,6 +18,9 @@ export const HEAL_CHECKS = [
|
|
|
17
18
|
shadowingCheck,
|
|
18
19
|
pathCheck,
|
|
19
20
|
hookRuntimeCheck,
|
|
21
|
+
// Detect-only, after the runtime shim repair: a shim can be healthy while the
|
|
22
|
+
// manifest entry that should have produced it resolves nowhere.
|
|
23
|
+
hookManifestCheck,
|
|
20
24
|
resourcesCheck,
|
|
21
25
|
];
|
|
22
26
|
/** Run the selected checks, isolating per-check failures. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type HealCheckId = 'resources' | 'hook-runtime' | 'shims' | 'shadowing' | 'path';
|
|
1
|
+
export type HealCheckId = 'resources' | 'hook-runtime' | 'hook-manifest' | 'shims' | 'shadowing' | 'path';
|
|
2
2
|
/** When the daemon schedules a check. */
|
|
3
3
|
export type HealCadence = 'startup' | 'frequent' | 'periodic';
|
|
4
4
|
export interface HealCtx {
|