@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81

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.
Files changed (200) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +76 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +34 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  87. package/src/handoff/handoff-command-module.ts +48 -0
  88. package/src/handoff/handoff-command.ts +187 -0
  89. package/src/handoff/index.ts +6 -0
  90. package/src/help/__tests__/help-command-module.test.ts +63 -40
  91. package/src/help/__tests__/help-command.test.ts +46 -37
  92. package/src/help/help-command-module.ts +2 -1
  93. package/src/help/help-command.ts +3 -3
  94. package/src/index.ts +124 -2
  95. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  96. package/src/keybindings/index.ts +6 -0
  97. package/src/keybindings/keybindings-command-module.ts +74 -0
  98. package/src/language/__tests__/language-command-module.test.ts +33 -28
  99. package/src/language/language-command-module.ts +2 -1
  100. package/src/language/language-command.ts +8 -6
  101. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  102. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  103. package/src/mcp-activation/index.ts +12 -0
  104. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  105. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  106. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  107. package/src/mcp-activation/mcp-model-status.ts +99 -0
  108. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  109. package/src/memory/memory-command-module.ts +18 -1
  110. package/src/memory/memory-command.ts +38 -38
  111. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  112. package/src/mode/mode-command-module.ts +2 -1
  113. package/src/mode/mode-command.ts +15 -7
  114. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  115. package/src/output-style/index.ts +6 -0
  116. package/src/output-style/output-style-command-module.ts +143 -0
  117. package/src/peers/__tests__/peers-command.test.ts +241 -0
  118. package/src/peers/index.ts +9 -0
  119. package/src/peers/peers-command-module.ts +47 -0
  120. package/src/peers/peers-command.ts +142 -0
  121. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  122. package/src/permissions/permissions-command-module.ts +2 -1
  123. package/src/permissions/permissions-command.ts +46 -4
  124. package/src/plan/__tests__/plan-command.test.ts +129 -0
  125. package/src/plan/index.ts +9 -0
  126. package/src/plan/plan-command-module.ts +49 -0
  127. package/src/plan/plan-command.ts +92 -0
  128. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  129. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  130. package/src/plugin/plugin-command-module.ts +3 -1
  131. package/src/plugin/plugin-command.ts +17 -16
  132. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  133. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  134. package/src/preset/preset-command-module.ts +26 -8
  135. package/src/preset/preset-command.ts +85 -27
  136. package/src/provider/__tests__/org-policy.test.ts +4 -5
  137. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  138. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  139. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  140. package/src/provider/provider-command-execution.ts +4 -4
  141. package/src/provider/provider-command-module.ts +2 -1
  142. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  143. package/src/provider/provider-command-profile-operations.ts +3 -3
  144. package/src/provider/provider-command-profile.ts +1 -1
  145. package/src/provider/provider-command-setup.ts +4 -4
  146. package/src/provider/provider-startup.ts +62 -35
  147. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  148. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  149. package/src/remote-control/index.ts +8 -0
  150. package/src/remote-control/remote-control-command-module.ts +61 -0
  151. package/src/remote-control/remote-control-command.ts +138 -0
  152. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  153. package/src/reset/reset-command-module.ts +2 -1
  154. package/src/reset/reset-command.ts +3 -4
  155. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  156. package/src/rewind/rewind-command-module.ts +2 -1
  157. package/src/rewind/rewind-command.ts +73 -6
  158. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  159. package/src/sandbox/index.ts +2 -0
  160. package/src/sandbox/sandbox-command-module.ts +56 -0
  161. package/src/sandbox/sandbox-command.ts +94 -0
  162. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  163. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  164. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  165. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  166. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  167. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  168. package/src/schedule/index.ts +3 -0
  169. package/src/schedule/loop-command.ts +333 -0
  170. package/src/schedule/loop-jitter.ts +41 -0
  171. package/src/schedule/schedule-command-module.ts +92 -14
  172. package/src/schedule/schedule-command.ts +98 -9
  173. package/src/schedule/schedule-spec-parser.ts +5 -1
  174. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  175. package/src/session/__tests__/session-command-module.test.ts +210 -45
  176. package/src/session/session-command-module.ts +68 -37
  177. package/src/session/session-command.ts +147 -80
  178. package/src/settings/settings-command-module.ts +3 -2
  179. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  180. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  181. package/src/shell/resolve-shell.ts +2 -2
  182. package/src/shell/shell-command-module.ts +6 -5
  183. package/src/shell/shell-command.ts +9 -5
  184. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  185. package/src/skills/index.ts +1 -0
  186. package/src/skills/skills-command-module.ts +22 -5
  187. package/src/skills/skills-command.ts +14 -13
  188. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  189. package/src/statusline/statusline-command-module.ts +2 -1
  190. package/src/statusline/statusline-command.ts +4 -4
  191. package/src/theme/__tests__/theme-command.test.ts +204 -0
  192. package/src/theme/index.ts +12 -0
  193. package/src/theme/theme-command-module.ts +211 -0
  194. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  195. package/src/user-local/user-local-command-constants.ts +1 -1
  196. package/src/user-local/user-local-command-module.ts +6 -5
  197. package/src/user-local/user-local-command.ts +12 -6
  198. package/src/user-local/user-local-memory-command.ts +17 -7
  199. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  200. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The doctor's closed repair allowlist (OBSERVABILITY-1991).
3
+ *
4
+ * Two entries, both existing idempotent writers, both gated on the state being re-read IMMEDIATELY
5
+ * before the write (the report a user read a minute ago is not the state on disk now):
6
+ * - the host's own user settings file (id `settings.user.<family>`) exists but holds nothing
7
+ * (`empty`); rewriting it as `{}` through `writeSettings` loses no content and changes neither
8
+ * onboarding (`isFirstRun` keys on a separate marker) nor provider setup (which keys on document
9
+ * content).
10
+ * - `storage.user` — the host's user store root or its sessions directory is missing or not
11
+ * owner-only; `ensureOwnerOnlyDirectory` creates and tightens both.
12
+ * Anything else — an unknown id, a state that is not the repairable one, an already-clean check —
13
+ * is refused with no write.
14
+ */
15
+ import { ensureOwnerOnlyDirectory } from '@robota-sdk/agent-core/node';
16
+ import { inspectSettingsLayers, writeSettings } from '@robota-sdk/agent-framework';
17
+
18
+ import {
19
+ isRepairableSettingsLayer,
20
+ settingsCheckId,
21
+ userSettingsCheckId,
22
+ } from './doctor-settings-probe.js';
23
+ import { userStorageState } from './doctor-storage-probe.js';
24
+
25
+ import type { IDoctorDeps, IDoctorInputs } from './doctor-types.js';
26
+
27
+ export const STORAGE_REPAIR_ID = 'storage.user';
28
+
29
+ /** The closed allowlist for this composition: the host's user settings layer id and `storage.user`. */
30
+ export function doctorRepairAllowlist(inputs: IDoctorInputs): readonly string[] {
31
+ const settings = userSettingsCheckId(inputs);
32
+ return settings === undefined ? [STORAGE_REPAIR_ID] : [settings, STORAGE_REPAIR_ID];
33
+ }
34
+
35
+ export interface IDoctorRepairPlan {
36
+ readonly id: string;
37
+ /** What the writer will do, for the confirmation prompt. */
38
+ readonly description: string;
39
+ readonly path: string;
40
+ }
41
+
42
+ export type TDoctorRepairPlanResult =
43
+ | { readonly ok: true; readonly plan: IDoctorRepairPlan }
44
+ | { readonly ok: false; readonly reason: string };
45
+
46
+ export type TDoctorRepairOutcome =
47
+ | { readonly applied: true; readonly plan: IDoctorRepairPlan }
48
+ | { readonly applied: false; readonly reason: string };
49
+
50
+ export function isDoctorRepairId(id: string, inputs: IDoctorInputs): boolean {
51
+ return doctorRepairAllowlist(inputs).includes(id);
52
+ }
53
+
54
+ function planSettingsRepair(id: string, inputs: IDoctorInputs): TDoctorRepairPlanResult {
55
+ const layer = inspectSettingsLayers(inputs.settingsSources).layers.find(
56
+ (candidate) => settingsCheckId(candidate.source) === id,
57
+ );
58
+ if (layer === undefined || layer.source.kind !== 'host') {
59
+ return { ok: false, reason: `${id}: no user settings layer in this composition` };
60
+ }
61
+ if (!isRepairableSettingsLayer(layer, inputs)) {
62
+ return { ok: false, reason: `${id}: state is ${layer.state}; only an empty file is rewritten` };
63
+ }
64
+ return {
65
+ ok: true,
66
+ plan: {
67
+ id,
68
+ description: 'rewrite the empty user settings file as {}',
69
+ path: layer.source.path,
70
+ },
71
+ };
72
+ }
73
+
74
+ function planStorageRepair(inputs: IDoctorInputs, deps: IDoctorDeps): TDoctorRepairPlanResult {
75
+ const state = userStorageState(inputs, deps);
76
+ const { root } = inputs.userStorage;
77
+ if (state !== 'missing' && state !== 'too-open') {
78
+ return {
79
+ ok: false,
80
+ reason: `storage.user: state is ${state}; only a missing or too-open directory is repaired`,
81
+ };
82
+ }
83
+ return {
84
+ ok: true,
85
+ plan: {
86
+ id: 'storage.user',
87
+ description:
88
+ 'create the user store root and its sessions directory as owner-only directories',
89
+ path: root,
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Re-read the state now and decide whether `id` is repairable in it. Never writes. */
95
+ export function planDoctorRepair(
96
+ id: string,
97
+ inputs: IDoctorInputs,
98
+ deps: IDoctorDeps,
99
+ ): TDoctorRepairPlanResult {
100
+ if (!isDoctorRepairId(id, inputs)) {
101
+ return {
102
+ ok: false,
103
+ reason: `${id}: not a repairable check (allowlist: ${doctorRepairAllowlist(inputs).join(', ')})`,
104
+ };
105
+ }
106
+ return id === STORAGE_REPAIR_ID
107
+ ? planStorageRepair(inputs, deps)
108
+ : planSettingsRepair(id, inputs);
109
+ }
110
+
111
+ /**
112
+ * Plan, confirm, re-plan, write. `confirm` is the host's prompt (`--yes` supplies `() => true`); a
113
+ * refusal or an absent prompt writes nothing. The plan is recomputed after confirmation so a state
114
+ * that changed while the user was deciding is refused rather than overwritten.
115
+ */
116
+ export async function applyDoctorRepair(
117
+ id: string,
118
+ inputs: IDoctorInputs,
119
+ deps: IDoctorDeps,
120
+ confirm: (plan: IDoctorRepairPlan) => Promise<boolean>,
121
+ ): Promise<TDoctorRepairOutcome> {
122
+ const first = planDoctorRepair(id, inputs, deps);
123
+ if (!first.ok) return { applied: false, reason: first.reason };
124
+ if (!(await confirm(first.plan)))
125
+ return { applied: false, reason: `${id}: repair not confirmed; nothing written` };
126
+ const current = planDoctorRepair(id, inputs, deps);
127
+ if (!current.ok)
128
+ return { applied: false, reason: `${current.reason} (state changed before the write)` };
129
+ if (current.plan.id === STORAGE_REPAIR_ID) {
130
+ const { root, sessions } = inputs.userStorage;
131
+ ensureOwnerOnlyDirectory(sessions, { withinRoot: root });
132
+ } else {
133
+ writeSettings(current.plan.path, {});
134
+ }
135
+ return { applied: true, plan: current.plan };
136
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The doctor runner (OBSERVABILITY-1991): one pass over every probe, one redaction boundary, one
3
+ * exit-code aggregation. Reachable without a session — it constructs no provider, no preset and no
4
+ * session; the host supplies what it composed and the checks only it can make.
5
+ */
6
+ import { probeExtensions } from './doctor-extensions-probe.js';
7
+ import {
8
+ collectSettingsSecrets,
9
+ describeDiagnosticError,
10
+ redactDiagnosticText,
11
+ } from './doctor-redaction.js';
12
+ import { probeProvider, probeSettings } from './doctor-settings-probe.js';
13
+ import { probeStorageAndTrust } from './doctor-storage-probe.js';
14
+
15
+ import type { ISettingsProbeResult } from './doctor-settings-probe.js';
16
+ import type { IDoctorCheck, IDoctorDeps, IDoctorInputs, IDoctorReport } from './doctor-types.js';
17
+
18
+ /** A probe that throws is a `fail` check carrying the owner's error class — never a default value. */
19
+ function probeFailure(id: string, label: string, error: Error): IDoctorCheck {
20
+ return { id, label, status: 'fail', cause: describeDiagnosticError(error) };
21
+ }
22
+
23
+ async function guarded<T>(
24
+ id: string,
25
+ label: string,
26
+ probe: () => Promise<T> | T,
27
+ onValue: (value: T) => readonly IDoctorCheck[],
28
+ ): Promise<readonly IDoctorCheck[]> {
29
+ try {
30
+ return onValue(await probe());
31
+ } catch (error) {
32
+ // allow-fallback: the probe's own failure is reported as a check, per the fallback declaration
33
+ return [probeFailure(id, label, error instanceof Error ? error : new Error(String(error)))];
34
+ }
35
+ }
36
+
37
+ function redactCheck(check: IDoctorCheck, secrets: readonly string[]): IDoctorCheck {
38
+ const redact = (text: string): string => redactDiagnosticText(text, secrets);
39
+ return {
40
+ ...check,
41
+ label: redact(check.label),
42
+ ...(check.path === undefined ? {} : { path: redact(check.path) }),
43
+ ...(check.cause === undefined ? {} : { cause: redact(check.cause) }),
44
+ ...(check.detail === undefined ? {} : { detail: check.detail.map(redact) }),
45
+ };
46
+ }
47
+
48
+ /** Aggregate checks into the report; `fail` alone raises the exit code (CLI-067). */
49
+ export function buildDoctorReport(checks: readonly IDoctorCheck[]): IDoctorReport {
50
+ const failCount = checks.filter((check) => check.status === 'fail').length;
51
+ const warnCount = checks.filter((check) => check.status === 'warn').length;
52
+ return {
53
+ checks,
54
+ failCount,
55
+ warnCount,
56
+ repairable: checks.flatMap((check) => (check.repair === undefined ? [] : [check.repair])),
57
+ exitCode: failCount === 0 ? 0 : 1,
58
+ };
59
+ }
60
+
61
+ /** The settings probe under its own boundary: its failure is a `fail` check and later probes run without layers. */
62
+ function guardedSettings(inputs: IDoctorInputs): {
63
+ checks: readonly IDoctorCheck[];
64
+ result?: ISettingsProbeResult;
65
+ } {
66
+ try {
67
+ const result = probeSettings(inputs);
68
+ return { checks: result.checks, result };
69
+ } catch (error) {
70
+ // allow-fallback: the settings probe's own failure is a `fail` check; later probes run without layers
71
+ const cause = error instanceof Error ? error : new Error(String(error));
72
+ return { checks: [probeFailure('settings', 'Settings', cause)] };
73
+ }
74
+ }
75
+
76
+ /** Run every probe and return the redacted report. */
77
+ export async function runDoctor(inputs: IDoctorInputs, deps: IDoctorDeps): Promise<IDoctorReport> {
78
+ const checks: IDoctorCheck[] = [...inputs.hostChecks];
79
+ if (inputs.compositionFailure !== undefined) checks.push(inputs.compositionFailure);
80
+
81
+ const settings = guardedSettings(inputs);
82
+ checks.push(...settings.checks);
83
+ const inspection = settings.result?.inspection;
84
+
85
+ const secrets = collectSettingsSecrets(
86
+ (inspection?.layers ?? []).map((layer) => layer.settings),
87
+ inputs.env,
88
+ );
89
+
90
+ checks.push(
91
+ ...(await guarded(
92
+ 'provider',
93
+ 'Provider',
94
+ () => probeProvider(inputs, deps),
95
+ (provider) => {
96
+ if (provider.resolvedApiKey !== undefined) secrets.push(provider.resolvedApiKey);
97
+ return provider.checks;
98
+ },
99
+ )),
100
+ );
101
+
102
+ if (inspection !== undefined) {
103
+ checks.push(
104
+ ...(await guarded(
105
+ 'storage',
106
+ 'Storage',
107
+ () => probeStorageAndTrust(inputs, deps, inspection),
108
+ (c) => c,
109
+ )),
110
+ );
111
+ checks.push(
112
+ ...(await guarded(
113
+ 'extensions',
114
+ 'Extensions',
115
+ () => probeExtensions(inputs, deps, inspection),
116
+ (c) => c,
117
+ )),
118
+ );
119
+ }
120
+
121
+ return buildDoctorReport(checks.map((check) => redactCheck(check, secrets)));
122
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Settings and provider probes (OBSERVABILITY-1991).
3
+ *
4
+ * Every fact here comes from an owner API — `inspectSettingsLayers` for the layers and their
5
+ * provenance, `readProviderSettings` for the runtime-equivalent provider resolution. The probe adds
6
+ * no reader of its own; that is the CLI-067 discipline the old `diagnose` command broke three times.
7
+ */
8
+ import { basename, dirname } from 'node:path';
9
+
10
+ import { findProviderDefinition } from '@robota-sdk/agent-core';
11
+ import { inspectSettingsLayers, readProviderSettings } from '@robota-sdk/agent-framework';
12
+
13
+ import { describeDiagnosticError } from './doctor-redaction.js';
14
+
15
+ import type { IDoctorCheck, IDoctorDeps, IDoctorInputs } from './doctor-types.js';
16
+ import type { IProviderDefinitionConfig } from '@robota-sdk/agent-core';
17
+ import type {
18
+ ISettingsInspection,
19
+ ISettingsLayerInspection,
20
+ TSettingsSource,
21
+ } from '@robota-sdk/agent-framework';
22
+
23
+ const HTTP_PORT = 80;
24
+ const HTTPS_PORT = 443;
25
+
26
+ /**
27
+ * `settings.<scope>.<family>` — the id a user types into `--repair`. The family is the name of the
28
+ * directory the file lives in, without its leading dot, so the id follows whatever layout the host
29
+ * composed rather than a product name written here.
30
+ */
31
+ export function settingsCheckId(source: TSettingsSource): string {
32
+ const path = source.kind === 'host' ? source.path : source.relativePath;
33
+ const family = basename(dirname(path)).replace(/^\./, '');
34
+ return `settings.${source.scope}.${family}`;
35
+ }
36
+
37
+ /** The check id of the host's own user settings file — the settings repair target. */
38
+ export function userSettingsCheckId(inputs: IDoctorInputs): string | undefined {
39
+ const source = inputs.settingsSources.find(
40
+ (candidate) => candidate.kind === 'host' && candidate.path === inputs.userSettingsPath,
41
+ );
42
+ return source === undefined ? undefined : settingsCheckId(source);
43
+ }
44
+
45
+ function layerCause(layer: ISettingsLayerInspection): string {
46
+ const cause = layer.cause;
47
+ if (cause === undefined) return layer.state;
48
+ switch (cause.state) {
49
+ case 'unreadable':
50
+ return cause.errno === undefined ? 'unreadable' : `unreadable (${cause.errno})`;
51
+ case 'invalid-json':
52
+ return cause.offset === undefined ? 'invalid-json' : `invalid-json at offset ${cause.offset}`;
53
+ case 'schema-invalid':
54
+ return `schema-invalid: ${(cause.issues ?? []).map((issue) => `${issue.path} (${issue.code})`).join(', ')}`;
55
+ default:
56
+ return cause.state;
57
+ }
58
+ }
59
+
60
+ /** Only the host's own user settings file, and only when it is empty, is a settings repair target. */
61
+ export function isRepairableSettingsLayer(
62
+ layer: ISettingsLayerInspection,
63
+ inputs: IDoctorInputs,
64
+ ): boolean {
65
+ return (
66
+ layer.source.kind === 'host' &&
67
+ layer.source.path === inputs.userSettingsPath &&
68
+ layer.state === 'empty'
69
+ );
70
+ }
71
+
72
+ function layerCheck(layer: ISettingsLayerInspection, inputs: IDoctorInputs): IDoctorCheck {
73
+ const id = settingsCheckId(layer.source);
74
+ const path = layer.source.kind === 'host' ? layer.source.path : layer.source.relativePath;
75
+ if (layer.state === 'absent') {
76
+ return { id, label: 'Settings layer', status: 'not-configured', path, cause: 'absent' };
77
+ }
78
+ if (layer.state === 'ok') return { id, label: 'Settings layer', status: 'ok', path, cause: 'ok' };
79
+ const repairable = isRepairableSettingsLayer(layer, inputs);
80
+ return {
81
+ id,
82
+ label: 'Settings layer',
83
+ status: 'fail',
84
+ path,
85
+ cause: layerCause(layer),
86
+ detail: repairable
87
+ ? ['The file exists but holds nothing; rewriting it as {} loses no content.']
88
+ : ['Session start will refuse this configuration. Fix the file at the named path.'],
89
+ ...(repairable ? { repair: id } : {}),
90
+ };
91
+ }
92
+
93
+ function provenanceCheck(inspection: ISettingsInspection): IDoctorCheck {
94
+ const lines = [
95
+ ...inspection.provenance.map(
96
+ (entry) => `${entry.key}: ${entry.rule} ← ${entry.contributors.join(' → ')}`,
97
+ ),
98
+ ...inspection.hookSources.map(
99
+ (hook, index) => `settings hook ${index + 1}: ${hook.event}/${hook.type} ← ${hook.source}`,
100
+ ),
101
+ ];
102
+ if (inspection.partial) {
103
+ return {
104
+ id: 'settings.merge',
105
+ label: 'Merged settings',
106
+ status: 'warn',
107
+ cause: 'partial — a present layer is broken; session start will refuse this configuration',
108
+ detail: lines.length === 0 ? ['(no key declared by a healthy layer)'] : lines,
109
+ };
110
+ }
111
+ return {
112
+ id: 'settings.merge',
113
+ label: 'Merged settings',
114
+ status: 'ok',
115
+ cause: `${inspection.provenance.length} key(s) from ${inspection.layers.filter((l) => l.state === 'ok').length} layer(s)`,
116
+ detail: lines.length === 0 ? ['(no settings declared)'] : lines,
117
+ };
118
+ }
119
+
120
+ export interface ISettingsProbeResult {
121
+ readonly inspection: ISettingsInspection;
122
+ readonly checks: readonly IDoctorCheck[];
123
+ }
124
+
125
+ /** Every layer in precedence order, then the merged view with its provenance. */
126
+ export function probeSettings(inputs: IDoctorInputs): ISettingsProbeResult {
127
+ const inspection = inspectSettingsLayers(inputs.settingsSources);
128
+ return {
129
+ inspection,
130
+ checks: [
131
+ ...inspection.layers.map((layer) => layerCheck(layer, inputs)),
132
+ provenanceCheck(inspection),
133
+ ],
134
+ };
135
+ }
136
+
137
+ function resolutionCheck(config: IProviderDefinitionConfig): IDoctorCheck {
138
+ const source =
139
+ config.source === 'env-default'
140
+ ? `env-default via ${config.sourceEnvVar ?? 'environment'}`
141
+ : 'settings profile';
142
+ return {
143
+ id: 'provider.resolution',
144
+ label: 'Provider',
145
+ status: 'ok',
146
+ cause: `${config.name} (${config.model}) — ${source}`,
147
+ };
148
+ }
149
+
150
+ function endpointOf(
151
+ config: IProviderDefinitionConfig,
152
+ inputs: IDoctorInputs,
153
+ ): { host: string; port: number; origin: string } | undefined {
154
+ const definition = findProviderDefinition(inputs.providerDefinitions, config.name);
155
+ const baseURL = config.baseURL ?? definition?.defaults?.baseURL;
156
+ if (baseURL !== undefined) {
157
+ try {
158
+ const url = new URL(baseURL);
159
+ const port =
160
+ url.port === '' ? (url.protocol === 'http:' ? HTTP_PORT : HTTPS_PORT) : Number(url.port);
161
+ return {
162
+ host: url.hostname,
163
+ port,
164
+ origin: config.baseURL === undefined ? 'definition defaults.baseURL' : 'profile baseURL',
165
+ };
166
+ } catch {
167
+ // allow-fallback: an unparseable baseURL is reported by the caller as a warn, never guessed
168
+ return undefined;
169
+ }
170
+ }
171
+ const endpoint = definition?.endpoint;
172
+ return endpoint === undefined ? undefined : { ...endpoint, origin: 'definition endpoint' };
173
+ }
174
+
175
+ async function reachabilityCheck(
176
+ config: IProviderDefinitionConfig,
177
+ inputs: IDoctorInputs,
178
+ deps: IDoctorDeps,
179
+ ): Promise<IDoctorCheck> {
180
+ const endpoint = endpointOf(config, inputs);
181
+ if (endpoint === undefined) {
182
+ return {
183
+ id: 'provider.reachability',
184
+ label: 'Provider reachability',
185
+ status: 'warn',
186
+ cause: `not checked: ${config.name} declares no endpoint (no profile baseURL, defaults.baseURL or definition endpoint)`,
187
+ };
188
+ }
189
+ const result = await deps.probeEndpoint(endpoint.host, endpoint.port);
190
+ const target = `${endpoint.host}:${endpoint.port} (${endpoint.origin})`;
191
+ return result.reachable
192
+ ? {
193
+ id: 'provider.reachability',
194
+ label: 'Provider reachability',
195
+ status: 'ok',
196
+ cause: `${target} reachable in ${result.elapsedMs ?? 0}ms`,
197
+ }
198
+ : {
199
+ id: 'provider.reachability',
200
+ label: 'Provider reachability',
201
+ status: 'warn',
202
+ cause: `${target} unreachable: ${result.error ?? 'connection failed'}`,
203
+ detail: ['Check proxy settings, firewall, or the profile baseURL.'],
204
+ };
205
+ }
206
+
207
+ /** Runtime-equivalent resolution, then TCP reachability of the host the resolved profile implies. */
208
+ export async function probeProvider(
209
+ inputs: IDoctorInputs,
210
+ deps: IDoctorDeps,
211
+ ): Promise<{ readonly checks: readonly IDoctorCheck[]; readonly resolvedApiKey?: string }> {
212
+ let config: IProviderDefinitionConfig;
213
+ try {
214
+ config = readProviderSettings(inputs.settingsSources, {
215
+ providerDefinitions: inputs.providerDefinitions,
216
+ env: { ...inputs.env },
217
+ });
218
+ } catch (error) {
219
+ // allow-fallback: resolution failure IS the finding; it is reported, never defaulted
220
+ return {
221
+ checks: [
222
+ {
223
+ id: 'provider.resolution',
224
+ label: 'Provider',
225
+ status: 'fail',
226
+ cause: describeDiagnosticError(error instanceof Error ? error : new Error(String(error))),
227
+ detail: [
228
+ inputs.diagnosticGuidance?.providerResolution ??
229
+ 'Configure a provider or set its API key environment variable.',
230
+ ],
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ return {
236
+ checks: [resolutionCheck(config), await reachabilityCheck(config, inputs, deps)],
237
+ ...(config.apiKey === undefined ? {} : { resolvedApiKey: config.apiKey }),
238
+ };
239
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Storage, workspace-trust and provider-security probes (OBSERVABILITY-1991). Read-only: existence,
3
+ * `W_OK` access and mode bits are inspected, never written.
4
+ */
5
+ import type { IDoctorCheck, IDoctorDeps, IDoctorInputs } from './doctor-types.js';
6
+ import type { ISettingsInspection } from '@robota-sdk/agent-framework';
7
+
8
+ const FORBIDDEN_MODE_BITS = 0o077;
9
+
10
+ export type TUserStorageState = 'ok' | 'missing' | 'unwritable' | 'too-open' | 'mode-not-probed';
11
+
12
+ /** The state the repair allowlist re-reads immediately before writing. */
13
+ export function userStorageState(inputs: IDoctorInputs, deps: IDoctorDeps): TUserStorageState {
14
+ const { root, sessions } = inputs.userStorage;
15
+ const facts = [deps.inspectPath(root), deps.inspectPath(sessions)];
16
+ if (facts.some((f) => !f.exists)) return 'missing';
17
+ if (facts.some((f) => !f.isDirectory || !f.writable)) return 'unwritable';
18
+ if (deps.ownerOnlyGuarantee() !== 'posix-mode') return 'mode-not-probed';
19
+ return facts.some((f) => f.mode !== undefined && (f.mode & FORBIDDEN_MODE_BITS) !== 0)
20
+ ? 'too-open'
21
+ : 'ok';
22
+ }
23
+
24
+ function userStorageCheck(inputs: IDoctorInputs, deps: IDoctorDeps): IDoctorCheck {
25
+ const { root, sessions } = inputs.userStorage;
26
+ const base = { id: 'storage.user', label: 'User storage', path: root } as const;
27
+ switch (userStorageState(inputs, deps)) {
28
+ case 'missing':
29
+ return {
30
+ ...base,
31
+ status: 'warn',
32
+ cause: 'not initialized (created on first write)',
33
+ detail: [root, sessions],
34
+ repair: 'storage.user',
35
+ };
36
+ case 'unwritable':
37
+ return {
38
+ ...base,
39
+ status: 'fail',
40
+ cause: 'exists but is not a writable directory',
41
+ detail: [root, sessions],
42
+ };
43
+ case 'too-open':
44
+ return {
45
+ ...base,
46
+ status: 'warn',
47
+ cause: 'not owner-only (mode allows group/other access)',
48
+ detail: [root, sessions],
49
+ repair: 'storage.user',
50
+ };
51
+ case 'mode-not-probed':
52
+ return {
53
+ ...base,
54
+ status: 'not-probed',
55
+ cause: 'owner-only mode is not asserted on this platform (windows-acl)',
56
+ };
57
+ default:
58
+ return { ...base, status: 'ok', cause: 'writable, owner-only' };
59
+ }
60
+ }
61
+
62
+ function projectStorageCheck(inputs: IDoctorInputs, deps: IDoctorDeps): IDoctorCheck {
63
+ const access = inputs.projectAccess;
64
+ if (access.status !== 'trusted') {
65
+ return {
66
+ id: 'storage.project',
67
+ label: 'Project storage',
68
+ status: 'not-configured',
69
+ cause: 'project sources are disabled in an untrusted workspace',
70
+ };
71
+ }
72
+ const root = inputs.projectStorageRoot;
73
+ if (root === undefined) {
74
+ return {
75
+ id: 'storage.project',
76
+ label: 'Project storage',
77
+ status: 'not-configured',
78
+ cause: 'the host names no project state root',
79
+ };
80
+ }
81
+ const facts = deps.inspectPath(root);
82
+ if (!facts.exists) {
83
+ return {
84
+ id: 'storage.project',
85
+ label: 'Project storage',
86
+ status: 'not-configured',
87
+ path: root,
88
+ cause: 'absent (created on first write)',
89
+ };
90
+ }
91
+ return facts.isDirectory && facts.writable
92
+ ? {
93
+ id: 'storage.project',
94
+ label: 'Project storage',
95
+ status: 'ok',
96
+ path: root,
97
+ cause: 'writable',
98
+ }
99
+ : {
100
+ id: 'storage.project',
101
+ label: 'Project storage',
102
+ status: 'fail',
103
+ path: root,
104
+ cause: 'exists but is not a writable directory',
105
+ };
106
+ }
107
+
108
+ function trustCheck(inputs: IDoctorInputs): IDoctorCheck {
109
+ const access = inputs.projectAccess;
110
+ if (access.status === 'trusted') {
111
+ return {
112
+ id: 'workspace.trust',
113
+ label: 'Workspace trust',
114
+ status: 'ok',
115
+ path: access.identity.displayPath,
116
+ cause: 'trusted',
117
+ };
118
+ }
119
+ const cause =
120
+ access.cause === undefined
121
+ ? access.trustState
122
+ : `${access.trustState}: ${access.cause.name}: ${access.cause.message}`;
123
+ return {
124
+ id: 'workspace.trust',
125
+ label: 'Workspace trust',
126
+ status:
127
+ access.trustState === 'store-unavailable' || access.trustState === 'identity-unavailable'
128
+ ? 'fail'
129
+ : 'warn',
130
+ ...(access.displayPath === undefined ? {} : { path: access.displayPath }),
131
+ cause,
132
+ detail: [
133
+ inputs.diagnosticGuidance?.projectTrust ??
134
+ 'Project sources are disabled. Approve this workspace with the host.',
135
+ ],
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The credential-quarantine report `robota diagnose` already shipped (CLI-067), over the inspection's
141
+ * parsed layers instead of a private re-read: a lower-trust layer that changed a profile's `baseURL`
142
+ * without supplying its own key had the inherited key removed. Neither key is ever shown.
143
+ */
144
+ function providerSecurityCheck(settings: ISettingsInspection): IDoctorCheck | undefined {
145
+ const seen = new Map<string, { baseURL?: string; apiKey?: string }>();
146
+ let quarantined = false;
147
+ for (const layer of settings.layers) {
148
+ for (const [name, profile] of Object.entries(layer.settings?.providers ?? {})) {
149
+ const previous = seen.get(name);
150
+ if (
151
+ previous?.apiKey !== undefined &&
152
+ profile.baseURL !== undefined &&
153
+ profile.baseURL !== previous.baseURL &&
154
+ profile.apiKey === undefined
155
+ ) {
156
+ quarantined = true;
157
+ }
158
+ seen.set(name, {
159
+ baseURL: profile.baseURL ?? previous?.baseURL,
160
+ apiKey: profile.apiKey ?? previous?.apiKey,
161
+ });
162
+ }
163
+ }
164
+ return quarantined
165
+ ? {
166
+ id: 'provider.security',
167
+ label: 'Provider security',
168
+ status: 'warn',
169
+ cause: 'provider endpoint quarantined; inherited credential removed (credential redacted)',
170
+ }
171
+ : undefined;
172
+ }
173
+
174
+ /** Workspace trust, storage, then the provider-security report. */
175
+ export function probeStorageAndTrust(
176
+ inputs: IDoctorInputs,
177
+ deps: IDoctorDeps,
178
+ settings: ISettingsInspection,
179
+ ): IDoctorCheck[] {
180
+ const security = providerSecurityCheck(settings);
181
+ return [
182
+ trustCheck(inputs),
183
+ userStorageCheck(inputs, deps),
184
+ projectStorageCheck(inputs, deps),
185
+ ...(security === undefined ? [] : [security]),
186
+ ];
187
+ }