@pellux/goodvibes-tui 1.13.1 → 1.15.0

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 (158) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +412 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +109 -0
  141. package/src/runtime/services.ts +33 -33
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
package/src/cli/status.ts CHANGED
@@ -2,9 +2,11 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
3
3
  import type { OnboardingCheckMarkersState } from '../runtime/onboarding/index.ts';
4
4
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
5
- import { isNetworkFacing } from './network-posture.ts';
5
+ import type { RuntimeEndpointId } from './endpoints.ts';
6
+ import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
6
7
  import type { GoodVibesCliOutputFormat } from './types.ts';
7
8
  import type { CliServicePosture } from './service-posture.ts';
9
+ import type { InstallSelfCheckFinding } from '../runtime/install-self-check.ts';
8
10
  import { getProviderIdFromModel } from '../config/provider-model.ts';
9
11
 
10
12
  export interface CliStatusOptions {
@@ -14,8 +16,57 @@ export interface CliStatusOptions {
14
16
  readonly onboardingMarkers?: OnboardingCheckMarkersState;
15
17
  readonly auth?: CliAuthStatus;
16
18
  readonly service?: CliServicePosture;
19
+ /** Findings from the install self-check (missing vendor binaries, broken daemon path), computed by the caller. */
20
+ readonly install?: readonly InstallSelfCheckFinding[];
17
21
  readonly doctor?: boolean;
18
22
  readonly outputFormat?: GoodVibesCliOutputFormat;
23
+ /** Per-workspace trust + registration posture, computed read-only by the caller. */
24
+ readonly workspace?: CliWorkspaceStatus;
25
+ /** Per-command exec sandbox availability, host-probed by the caller. */
26
+ readonly sandbox?: CliSandboxStatus;
27
+ /** Outbound relay reachability posture (config + feature-flag gate only — a one-shot CLI process has no live connection to inspect). */
28
+ readonly relay?: CliRelayStatus;
29
+ }
30
+
31
+ /** Config-derived relay posture. No live socket check — see doctor's other surfaces for the same honesty bar. */
32
+ export interface CliRelayStatus {
33
+ readonly configEnabled: boolean;
34
+ readonly featureEnabled: boolean;
35
+ readonly url: string;
36
+ readonly rendezvousId: string;
37
+ readonly requireStepUpForMutations: boolean;
38
+ }
39
+
40
+ /** Honest per-command exec sandbox posture for the report. */
41
+ export interface CliSandboxStatus {
42
+ /** `sandbox.enabled` config switch. */
43
+ readonly configEnabled: boolean;
44
+ /** The graduation-gated `exec-sandbox` feature flag. */
45
+ readonly featureEnabled: boolean;
46
+ /** Host can actually provide a boundary (bubblewrap present and working). */
47
+ readonly available: boolean;
48
+ readonly backend: 'bubblewrap' | 'none';
49
+ /** Host-probe diagnosis (why unavailable) or one-line availability summary. */
50
+ readonly reason: string;
51
+ /** Whether `--unshare-net` isolation is confirmed on this host, or unknown. */
52
+ readonly networkIsolationGuaranteed: boolean;
53
+ }
54
+
55
+ /** Read-only snapshot of this workspace's trust gate and registration coverage. */
56
+ export interface CliWorkspaceStatus {
57
+ /** TUI-local trust decision; 'undecided' when no choice is persisted yet. */
58
+ readonly trustLevel: 'trusted' | 'restricted' | 'undecided';
59
+ readonly trustGrandfathered: boolean;
60
+ /** Coverage verdict from the shared workspace registry. */
61
+ readonly registrationStatus: 'covered' | 'declined' | 'unknown';
62
+ /** Normalized working directory resolved against the registry. */
63
+ readonly registrationRoot: string;
64
+ /** Nearest registered root covering this path, or null. */
65
+ readonly registeredBy: string | null;
66
+ /** Coverage inherited through the git worktree→main-repo link. */
67
+ readonly viaWorktreeLink: boolean;
68
+ /** True when the root is too broad to register ($HOME, filesystem root, ~/.goodvibes). */
69
+ readonly registrationBroad: boolean;
19
70
  }
20
71
 
21
72
  export interface CliAuthStatus {
@@ -29,7 +80,7 @@ export interface CliAuthStatus {
29
80
 
30
81
  export interface CliDoctorFinding {
31
82
  readonly id: string;
32
- readonly area: 'auth' | 'network' | 'onboarding' | 'security' | 'service' | 'secrets';
83
+ readonly area: 'auth' | 'network' | 'onboarding' | 'security' | 'service' | 'secrets' | 'install' | 'sandbox';
33
84
  readonly severity: 'warning' | 'risk';
34
85
  readonly summary: string;
35
86
  readonly cause: string;
@@ -69,9 +120,33 @@ export interface CliStatusSnapshot {
69
120
  readonly scope: string;
70
121
  readonly updatedAt: number | null;
71
122
  };
123
+ readonly workspace: CliWorkspaceStatus | null;
124
+ readonly sandbox: CliSandboxStatus | null;
125
+ readonly relay: CliRelayStatus | null;
126
+ readonly exposure: readonly CliExposureSurface[];
72
127
  readonly findings: readonly CliDoctorFinding[];
73
128
  }
74
129
 
130
+ /**
131
+ * Per-surface exposure report row: what a network surface binds to, how it
132
+ * authenticates callers, and what cross-origin allowlist (if any) applies.
133
+ * A plain report — it changes no behavior and writes no config.
134
+ */
135
+ export interface CliExposureSurface {
136
+ readonly id: RuntimeEndpointId;
137
+ readonly label: string;
138
+ readonly enabled: boolean;
139
+ /** Bind envelope, e.g. "network 0.0.0.0:3421". */
140
+ readonly bind: string;
141
+ /** Reachability posture label, e.g. "Local only" / "Local Network" / "Custom network". */
142
+ readonly reach: string;
143
+ readonly networkFacing: boolean;
144
+ /** How callers authenticate against this surface when it is network-facing. */
145
+ readonly authMode: string;
146
+ /** Browser-origin allowlist state. Only the control plane has one; others report n/a. */
147
+ readonly originAllowlist: string;
148
+ }
149
+
75
150
  function yesNo(value: unknown): string {
76
151
  return value === true ? 'yes' : 'no';
77
152
  }
@@ -94,6 +169,146 @@ function bindLine(label: string, enabled: unknown, binding: { readonly hostMode:
94
169
  return ` ${label}: ${yesNo(enabled)} (${binding.hostMode} ${binding.host}:${binding.port})`;
95
170
  }
96
171
 
172
+ /** Describe registration coverage for the Workspace status section. */
173
+ function registrationLine(workspace: CliWorkspaceStatus): string {
174
+ switch (workspace.registrationStatus) {
175
+ case 'covered':
176
+ return workspace.viaWorktreeLink
177
+ ? `registered (via worktree link to ${workspace.registeredBy})`
178
+ : `registered (covered by ${workspace.registeredBy})`;
179
+ case 'declined':
180
+ return 'not registered (declined for this directory)';
181
+ case 'unknown':
182
+ return workspace.registrationBroad
183
+ ? 'not registered (root too broad to register)'
184
+ : 'not registered (never asked)';
185
+ }
186
+ }
187
+
188
+ /** The `Workspace:` block showing the TUI-local trust gate and shared-registry coverage. */
189
+ function renderWorkspaceSection(workspace: CliWorkspaceStatus | null): string[] {
190
+ if (!workspace) return [];
191
+ return [
192
+ 'Workspace:',
193
+ ` trust: ${workspace.trustLevel}${workspace.trustGrandfathered ? ' (grandfathered)' : ''}`,
194
+ ` registration: ${registrationLine(workspace)}`,
195
+ ` root: ${workspace.registrationRoot}`,
196
+ '',
197
+ ];
198
+ }
199
+
200
+ /** The `Exec sandbox:` block — honest host-probed availability of the per-command boundary. */
201
+ function renderSandboxSection(sandbox: CliSandboxStatus | null): string[] {
202
+ if (!sandbox) return [];
203
+ const active = sandbox.configEnabled && sandbox.featureEnabled && sandbox.available;
204
+ const network = !sandbox.available
205
+ ? 'n/a'
206
+ : sandbox.networkIsolationGuaranteed
207
+ ? 'isolation confirmed'
208
+ : 'isolation unknown';
209
+ return [
210
+ 'Exec sandbox:',
211
+ ` active: ${active ? 'yes' : 'no'}`,
212
+ ` configEnabled: ${yesNo(sandbox.configEnabled)}`,
213
+ ` featureEnabled: ${yesNo(sandbox.featureEnabled)}`,
214
+ ` backend: ${sandbox.backend}`,
215
+ ` available: ${yesNo(sandbox.available)}`,
216
+ ` network: ${network}`,
217
+ ` detail: ${sandbox.reason}`,
218
+ '',
219
+ ];
220
+ }
221
+
222
+ /**
223
+ * The `Relay:` block — config + feature-flag gate only. This CLI invocation is
224
+ * a separate one-shot process from any running daemon, so it cannot read the
225
+ * daemon's live in-memory connection state (see /relay status in the TUI for
226
+ * that). "active" here means the gate that lets a running daemon register.
227
+ */
228
+ function renderRelaySection(relay: CliRelayStatus | null): string[] {
229
+ if (!relay) return [];
230
+ const active = relay.configEnabled && relay.featureEnabled;
231
+ return [
232
+ 'Relay:',
233
+ ` active: ${active ? 'yes (a running daemon will register)' : 'no'}`,
234
+ ` configEnabled (relay.enabled): ${yesNo(relay.configEnabled)}`,
235
+ ` featureEnabled (relay-connect flag): ${yesNo(relay.featureEnabled)}`,
236
+ ` url: ${relay.url || '(not set)'}`,
237
+ ` rendezvousId: ${relay.rendezvousId || '(not yet minted)'}`,
238
+ ` requireStepUpForMutations: ${yesNo(relay.requireStepUpForMutations)}`,
239
+ ' Threat model: the relay operator sees only ciphertext and connection metadata — self-host your own relay for full control.',
240
+ '',
241
+ ];
242
+ }
243
+
244
+ /**
245
+ * How callers authenticate against a surface, derived from the auth material
246
+ * the CLI already inspects. A loopback surface trusts the local host; a
247
+ * network-facing surface is authenticated by a local user store and/or an
248
+ * operator token, and reports "none configured" when neither exists.
249
+ */
250
+ function surfaceAuthMode(networkFacing: boolean, auth: CliAuthStatus | undefined): string {
251
+ if (!networkFacing) return 'loopback (host-local trust)';
252
+ if (!auth) return 'unknown';
253
+ const parts: string[] = [];
254
+ if (auth.userStorePresent) parts.push('local users');
255
+ if (auth.operatorTokenPresent) parts.push('operator token');
256
+ if (parts.length === 0) return 'none configured';
257
+ return parts.join(' + ');
258
+ }
259
+
260
+ /** Comma-separated CORS origins, trimmed and emptied of blanks. */
261
+ function parseAllowedOrigins(raw: unknown): string[] {
262
+ return String(raw ?? '')
263
+ .split(',')
264
+ .map((origin) => origin.trim())
265
+ .filter((origin) => origin.length > 0);
266
+ }
267
+
268
+ /**
269
+ * The only browser-origin allowlist in this codebase belongs to the control
270
+ * plane (controlPlane.cors.*). httpListener and web have no origin allowlist
271
+ * concept, so this returns an honest n/a for them.
272
+ */
273
+ function surfaceOriginAllowlist(id: RuntimeEndpointId, config: Pick<ConfigManager, 'get'>): string {
274
+ if (id !== 'controlPlane') return 'n/a (no origin allowlist for this surface)';
275
+ if (config.get('controlPlane.cors.enabled') !== true) return 'CORS off (same-origin only)';
276
+ const origins = parseAllowedOrigins(config.get('controlPlane.cors.allowedOrigins'));
277
+ if (origins.length === 0) return 'CORS on, empty allowlist (refuses all cross-origin)';
278
+ if (origins.includes('*')) return `CORS on, WILDCARD any-origin (${origins.join(', ')})`;
279
+ return `CORS on, allowlist: ${origins.join(', ')}`;
280
+ }
281
+
282
+ const EXPOSURE_SURFACES: readonly { readonly id: RuntimeEndpointId; readonly label: string; readonly enabledKey: string }[] = [
283
+ { id: 'controlPlane', label: 'controlPlane', enabledKey: 'controlPlane.enabled' },
284
+ { id: 'httpListener', label: 'httpListener', enabledKey: 'danger.httpListener' },
285
+ { id: 'web', label: 'web', enabledKey: 'web.enabled' },
286
+ ];
287
+
288
+ /**
289
+ * Build the per-surface exposure report (bind address, auth mode, origin
290
+ * allowlist) for every network surface. Pure over the status options — a
291
+ * report only, no behavior change.
292
+ */
293
+ export function buildCliExposureReport(options: CliStatusOptions): readonly CliExposureSurface[] {
294
+ const config = options.configManager;
295
+ return EXPOSURE_SURFACES.map((surface): CliExposureSurface => {
296
+ const enabled = config.get(surface.enabledKey as never) === true;
297
+ const binding = resolveRuntimeEndpointBinding(config, surface.id);
298
+ const networkFacing = isNetworkFacing(enabled, binding);
299
+ return {
300
+ id: surface.id,
301
+ label: surface.label,
302
+ enabled,
303
+ bind: `${binding.hostMode} ${binding.host}:${binding.port}`,
304
+ reach: classifyBindPosture(binding).label,
305
+ networkFacing,
306
+ authMode: surfaceAuthMode(networkFacing, options.auth),
307
+ originAllowlist: surfaceOriginAllowlist(surface.id, config),
308
+ };
309
+ });
310
+ }
311
+
97
312
  export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliDoctorFinding[] {
98
313
  const config = options.configManager;
99
314
  const serviceEnabled = config.get('service.enabled') === true;
@@ -241,6 +456,49 @@ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliD
241
456
  });
242
457
  }
243
458
 
459
+ if (isNetworkFacing(controlPlaneEnabled, controlPlaneBinding)
460
+ && config.get('controlPlane.cors.enabled') === true
461
+ && parseAllowedOrigins(config.get('controlPlane.cors.allowedOrigins')).includes('*')) {
462
+ findings.push({
463
+ id: 'control-plane-cors-wildcard-origin',
464
+ area: 'network',
465
+ severity: 'risk',
466
+ summary: 'The control plane accepts cross-origin requests from any origin.',
467
+ cause: `controlPlane is ${controlPlaneBinding.hostMode}-bound on ${controlPlaneBinding.host}:${controlPlaneBinding.port}, controlPlane.cors.enabled is true, and controlPlane.cors.allowedOrigins contains a wildcard "*".`,
468
+ impact: 'Any website a signed-in operator visits could make credentialed cross-origin calls to the control plane.',
469
+ action: 'Replace the wildcard with an explicit list of trusted browser origins in controlPlane.cors.allowedOrigins.',
470
+ });
471
+ }
472
+
473
+ if (options.install) {
474
+ for (const finding of options.install) {
475
+ findings.push({
476
+ id: `install-${finding.id}`,
477
+ area: 'install',
478
+ severity: 'warning',
479
+ summary: finding.summary,
480
+ cause: finding.detail,
481
+ impact: 'The daemon and any background surfaces (control plane, listener, web) may fail to start until the install is repaired.',
482
+ action: `Repair this install by running: ${finding.repairCommand}`,
483
+ });
484
+ }
485
+ }
486
+
487
+ // Exec sandbox requested but the host cannot provide a boundary: the exec tool
488
+ // silently runs unsandboxed and the approval flow keeps prompting. Flag it so
489
+ // the operator knows the boundary they turned on is not in force.
490
+ if (options.sandbox && options.sandbox.configEnabled && options.sandbox.featureEnabled && !options.sandbox.available) {
491
+ findings.push({
492
+ id: 'exec-sandbox-unavailable',
493
+ area: 'sandbox',
494
+ severity: 'warning',
495
+ summary: 'The per-command exec sandbox is enabled but unavailable on this host.',
496
+ cause: options.sandbox.reason,
497
+ impact: 'Exec commands run unsandboxed and boundary-safe commands still prompt instead of auto-allowing.',
498
+ action: 'Install bubblewrap (bwrap) on Linux, or disable sandbox.enabled to stop requesting a boundary this host cannot provide.',
499
+ });
500
+ }
501
+
244
502
  return findings;
245
503
  }
246
504
 
@@ -283,6 +541,10 @@ export function buildCliStatusSnapshot(options: CliStatusOptions): CliStatusSnap
283
541
  scope: marker?.scope ?? 'none',
284
542
  updatedAt: marker?.payload?.updatedAt ?? null,
285
543
  },
544
+ workspace: options.workspace ?? null,
545
+ sandbox: options.sandbox ?? null,
546
+ relay: options.relay ?? null,
547
+ exposure: buildCliExposureReport(options),
286
548
  findings,
287
549
  };
288
550
  }
@@ -349,6 +611,17 @@ export function renderCliStatus(options: CliStatusOptions): string {
349
611
  ` checked: ${marker?.exists ? 'yes' : 'no'}`,
350
612
  ` scope: ${marker?.scope ?? 'none'}`,
351
613
  ` updatedAt: ${marker?.payload ? new Date(marker.payload.updatedAt).toISOString() : 'n/a'}`,
614
+ '',
615
+ ...renderWorkspaceSection(snapshot.workspace),
616
+ ...renderSandboxSection(snapshot.sandbox),
617
+ ...renderRelaySection(snapshot.relay),
618
+ 'Exposure (report only — no changes made):',
619
+ ...snapshot.exposure.flatMap((surface) => [
620
+ ` ${surface.label}: ${yesNo(surface.enabled)} · ${surface.reach}${surface.networkFacing ? ' · network-facing' : ''}`,
621
+ ` bind: ${surface.bind}`,
622
+ ` auth: ${surface.authMode}`,
623
+ ` originAllowlist: ${surface.originAllowlist}`,
624
+ ]),
352
625
  ];
353
626
 
354
627
  if (options.doctor) {
@@ -1,6 +1,8 @@
1
1
  import type { CommandContext, CommandRegistry } from '../input/command-registry.ts';
2
2
  import type { InputHandler } from '../input/handler.ts';
3
+ import type { SelectionItem } from '../input/selection-modal.ts';
3
4
  import { hasResumableWizardProgress, readOnboardingCheckMarker, readWizardProgress } from '../runtime/onboarding/index.ts';
5
+ import { startOnboardingFastPath } from '../runtime/onboarding/fast-path.ts';
4
6
  import { readLastSessionPointer } from '@/runtime/index.ts';
5
7
  import type { GoodVibesCliParseResult } from './types.ts';
6
8
 
@@ -9,6 +11,115 @@ export type TuiStartupShellPaths = Parameters<typeof readOnboardingCheckMarker>[
9
11
  readonly homeDirectory: string;
10
12
  };
11
13
 
14
+ export type FirstOpenTrustLevel = 'trusted' | 'restricted';
15
+
16
+ /** The decisions a first-open prompt choice resolves to. Absent halves were not asked. */
17
+ export interface FirstOpenDecision {
18
+ readonly trust?: FirstOpenTrustLevel;
19
+ readonly register?: 'yes' | 'no';
20
+ }
21
+
22
+ /**
23
+ * decodeFirstOpenChoice — pure mapping from a chosen selection-item id (or null
24
+ * on Escape/enter-through) to the trust + registration decisions, given which
25
+ * halves the prompt actually offered. Kept pure so the combined-prompt semantics
26
+ * are unit-testable without a live modal.
27
+ *
28
+ * Escape/enter-through (id === null) is the safe default: an offered trust half
29
+ * settles to 'restricted' (matching the standalone trust prompt), and an offered
30
+ * register half defaults to NO — recorded as a subtree-scoped decline so the
31
+ * directory is not re-asked every startup.
32
+ */
33
+ export function decodeFirstOpenChoice(
34
+ id: string | null,
35
+ offered: { readonly trustNeeded: boolean; readonly registerNeeded: boolean },
36
+ ): FirstOpenDecision {
37
+ const decision: { trust?: FirstOpenTrustLevel; register?: 'yes' | 'no' } = {};
38
+ if (offered.trustNeeded && offered.registerNeeded) {
39
+ // Combined 2x2 choice.
40
+ const trust: FirstOpenTrustLevel = id === 'trust-register' || id === 'trust-only' ? 'trusted' : 'restricted';
41
+ const register: 'yes' | 'no' = id === 'trust-register' || id === 'restrict-register' ? 'yes' : 'no';
42
+ decision.trust = trust;
43
+ decision.register = register;
44
+ } else if (offered.trustNeeded) {
45
+ decision.trust = id === 'trusted' ? 'trusted' : 'restricted';
46
+ } else if (offered.registerNeeded) {
47
+ decision.register = id === 'register' ? 'yes' : 'no';
48
+ }
49
+ return decision;
50
+ }
51
+
52
+ /** Build the selection rows for a first-open prompt given which halves apply. */
53
+ export function buildFirstOpenItems(offered: {
54
+ readonly trustNeeded: boolean;
55
+ readonly registerNeeded: boolean;
56
+ }): { readonly title: string; readonly items: SelectionItem[] } {
57
+ if (offered.trustNeeded && offered.registerNeeded) {
58
+ return {
59
+ title: 'New workspace — trust level and registration',
60
+ items: [
61
+ { id: 'trust-register', label: 'Trust & register this workspace', detail: 'Full capability, and track it in your workspace registry', primaryAction: 'select' },
62
+ { id: 'trust-only', label: 'Trust, don\'t register', detail: 'Full capability; leave it out of the registry', primaryAction: 'select' },
63
+ { id: 'restrict-register', label: 'Keep restricted, register', detail: 'Read-only for now; still track it in the registry', primaryAction: 'select' },
64
+ { id: 'restrict-only', label: 'Keep restricted, don\'t register', detail: 'Read-only exploration; nothing recorded (default)', primaryAction: 'select' },
65
+ ],
66
+ };
67
+ }
68
+ if (offered.trustNeeded) {
69
+ return {
70
+ title: 'New workspace — choose a trust level',
71
+ items: [
72
+ { id: 'trusted', label: 'Trust this workspace', detail: 'Full capability — all tools may run', primaryAction: 'select' },
73
+ { id: 'restricted', label: 'Keep restricted (read-only)', detail: 'Explore safely; writes and commands are denied until trusted', primaryAction: 'select' },
74
+ ],
75
+ };
76
+ }
77
+ return {
78
+ title: 'Register this directory as a workspace?',
79
+ items: [
80
+ { id: 'register', label: 'Register this workspace', detail: 'Track this project root in your workspace registry', primaryAction: 'select' },
81
+ { id: 'skip', label: 'Don\'t register', detail: 'Leave it out; not asked again for this directory (default)', primaryAction: 'select' },
82
+ ],
83
+ };
84
+ }
85
+
86
+ /**
87
+ * First-open workspace prompt — one surface folding the TUI-local trust gate and
88
+ * the platform-wide registration half. When GoodVibes opens a workspace it has
89
+ * no trust decision for, and/or one whose registration resolves UNKNOWN, ask
90
+ * once (never two stacked modals): trust level AND "register this directory?".
91
+ *
92
+ * The two records are independent: a grandfathered-trusted workspace skips the
93
+ * trust half but may still see the register half once; a covered/declined/broad
94
+ * directory skips the register half. Escape/enter-through takes the safe default
95
+ * (restricted, and a recorded decline for the register half).
96
+ */
97
+ async function promptWorkspaceFirstOpen(commandContext: CommandContext, render: () => void): Promise<void> {
98
+ const trustManager = commandContext.workspace?.workspaceTrustManager;
99
+ const registrationManager = commandContext.workspace?.workspaceRegistrationManager;
100
+ const trustNeeded = Boolean(trustManager && !trustManager.isDecided());
101
+ const evaluation = registrationManager ? await registrationManager.evaluate() : null;
102
+ const registerNeeded = Boolean(evaluation?.offerRegister);
103
+ if (!trustNeeded && !registerNeeded) return;
104
+
105
+ const offered = { trustNeeded, registerNeeded };
106
+ const { title, items } = buildFirstOpenItems(offered);
107
+ commandContext.openSelection?.(
108
+ title,
109
+ items,
110
+ { allowSearch: false, primaryVerbLabel: 'Choose' },
111
+ (result) => {
112
+ const decision = decodeFirstOpenChoice(result?.item.id ?? null, offered);
113
+ const apply = async () => {
114
+ if (decision.trust && trustManager) await trustManager.setLevel(decision.trust);
115
+ if (decision.register === 'yes' && registrationManager) await registrationManager.register();
116
+ else if (decision.register === 'no' && registrationManager) await registrationManager.decline();
117
+ };
118
+ void apply().finally(render);
119
+ },
120
+ );
121
+ }
122
+
12
123
  export function applyInitialTuiCliState(options: {
13
124
  readonly cli: GoodVibesCliParseResult;
14
125
  readonly input: InputHandler;
@@ -70,7 +181,9 @@ export function applyInitialTuiCliState(options: {
70
181
  .then(() => render());
71
182
  }
72
183
  } else if (!globalOnboardingMarker.exists) {
73
- input.openOnboardingWizard({ mode: 'new', reset: true });
184
+ // Fast path: get a brand-new user to a working session in the fewest steps.
185
+ // Falls back to the full wizard when the surface can't detect providers.
186
+ startOnboardingFastPath({ input, commandContext, shellPaths, render });
74
187
  } else {
75
188
  // User has completed onboarding before but left a wizard session in progress.
76
189
  // Reopen the wizard at the last saved step so they can continue or dismiss.
@@ -89,6 +202,11 @@ export function applyInitialTuiCliState(options: {
89
202
  },
90
203
  });
91
204
  }
205
+ } else {
206
+ // Returning user, no wizard to resume: this is the moment to ask, in one
207
+ // surface, about a genuinely new workspace's trust level and whether to
208
+ // register it — before they run anything here.
209
+ void promptWorkspaceFirstOpen(commandContext, render);
92
210
  }
93
211
  }
94
212
  }
package/src/cli/types.ts CHANGED
@@ -20,9 +20,11 @@ export type GoodVibesCliCommand =
20
20
  | 'surfaces'
21
21
  | 'listener'
22
22
  | 'control-plane'
23
- | 'bundle'
23
+ | 'support-bundle'
24
24
  | 'remote'
25
25
  | 'bridge'
26
+ | 'hooks'
27
+ | 'plugin'
26
28
  | 'completion'
27
29
  | 'help'
28
30
  | 'version'
@@ -39,6 +39,22 @@ export function isAutoApproveEnabled(configManager: Pick<ConfigManager, 'get'>):
39
39
  return configManager.get('behavior.autoApprove');
40
40
  }
41
41
 
42
+ /**
43
+ * True when the current permission posture effectively auto-approves everything:
44
+ * auto-approve on, permission mode `allow-all`, or a `custom` mode whose every
45
+ * tool action is `allow`. Drives the footer's danger indicator.
46
+ */
47
+ export function isEffectiveDangerMode(configManager: Pick<ConfigManager, 'get' | 'getCategory'>): boolean {
48
+ if (configManager.get('behavior.autoApprove')) return true;
49
+ const permMode = configManager.get('permissions.mode');
50
+ if (permMode === 'allow-all') return true;
51
+ if (permMode === 'custom') {
52
+ const tools = configManager.getCategory('permissions').tools;
53
+ if (Object.values(tools).every((action) => action === 'allow')) return true;
54
+ }
55
+ return false;
56
+ }
57
+
42
58
  export function getWorkingDirectory(configManager: Pick<ConfigManager, 'getWorkingDirectory'>): string | null {
43
59
  return configManager.getWorkingDirectory();
44
60
  }