@stravica-ai/digital-operator-pa 0.1.53 → 0.1.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.
@@ -11,6 +11,7 @@ import { basename, join, resolve, dirname } from 'node:path';
11
11
  import { resolve4 } from 'node:dns/promises';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
14
+ import { resolveWorkspace as coreResolveWorkspace } from '../../../engineering-pa-core/src/workspace/resolve.mjs';
14
15
 
15
16
  const execFileP = promisify(execFile);
16
17
 
@@ -204,45 +205,33 @@ async function withTimeout(promise, ms) {
204
205
  }
205
206
 
206
207
  /**
207
- * Workspace port + reachability. Reads port from operator preferences if
208
- * present; probes with a TCP connect (fast — no HTTP round-trip). Timeout
209
- * capped at 1s.
208
+ * Workspace port + identity, resolved through the ONE shared resolver
209
+ * in @stravica-ai/engineering-pa-core/src/workspace/resolve.mjs (0.1.54
210
+ * fix, w-2026-09-21-dave-002, F6/F7b). The 0.1.53 probe read a bare TCP
211
+ * connect on the default port and called any accepting listener
212
+ * `running` — which reported Docker on 4200 as our workspace and made
213
+ * every viewer link the PA handed out wrong. The shared resolver reads
214
+ * the persisted `workspacePort`, honours the running-port pidfile, and
215
+ * identifies the incumbent via `/__api/identity` before reporting
216
+ * `running`.
210
217
  */
211
218
  export async function probeWorkspace(repoRoot) {
212
- const port = readWorkspacePort(repoRoot);
213
- const net = await import('node:net');
214
- const running = await new Promise((res) => {
215
- const socket = new net.Socket();
216
- let done = false;
217
- const finish = (value) => {
218
- if (done) return;
219
- done = true;
220
- try { socket.destroy(); } catch { /* ignore */ }
221
- res(value);
222
- };
223
- socket.setTimeout(1000);
224
- socket.once('connect', () => finish(true));
225
- socket.once('error', () => finish(false));
226
- socket.once('timeout', () => finish(false));
227
- socket.connect(port, '127.0.0.1');
228
- });
219
+ const resolved = await coreResolveWorkspace({ repoRoot });
229
220
  return {
230
- port,
231
- running,
232
- url: `http://localhost:${port}`,
221
+ port: resolved.effectivePort,
222
+ configuredPort: resolved.configuredPort,
223
+ runningPort: resolved.runningPort,
224
+ // Legacy boolean kept for callers that still read `running`. True
225
+ // only when identity matched — a foreign process on the port is
226
+ // never running as far as this probe is concerned.
227
+ running: resolved.state === 'running',
228
+ state: resolved.state,
229
+ identity: resolved.identity,
230
+ detail: resolved.detail,
231
+ url: resolved.url,
233
232
  };
234
233
  }
235
234
 
236
- function readWorkspacePort(repoRoot) {
237
- try {
238
- const prefs = readPreferencesSync(repoRoot);
239
- if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
240
- } catch {
241
- // fall through
242
- }
243
- return 4200;
244
- }
245
-
246
235
  /**
247
236
  * Working directory + project root resolution. Search-upward for an
248
237
  * `agents/<name>/CLAUDE.md` marker or a `.git` ancestor. Env var override
@@ -17,6 +17,10 @@ import {
17
17
  } from '../../../engineering-pa-core/src/distill/health.mjs';
18
18
  import { vaultState } from '../../../engineering-pa-core/src/vault/require.mjs';
19
19
  import { enabledToolsNeedingSecrets } from '../../../engineering-pa-core/src/vault/tool-needs.mjs';
20
+ import {
21
+ resolveWorkspace as coreResolveWorkspace,
22
+ formatIncumbentClause,
23
+ } from '../../../engineering-pa-core/src/workspace/resolve.mjs';
20
24
  import { resolveDefaultScope } from '../secrets/vault.mjs';
21
25
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
22
26
  import { doOnboard } from './onboard.mjs';
@@ -184,23 +188,32 @@ async function doStatus(opts = {}) {
184
188
 
185
189
  // 3. Workspace reachability probe.
186
190
  //
187
- // 0.1.50 (item 12): report BOTH the running port (from `.ws.pid` when
188
- // present) and the configured port when they differ, so the operator
189
- // can see when the auto-probe moved the server onto a different port
190
- // than preferences declare. Probe the running port when available,
191
- // falling back to the configured port.
191
+ // 0.1.54 (w-2026-09-21-dave-002, A2/A3): resolved through the ONE
192
+ // shared workspace resolver in core/workspace/resolve.mjs so `pa
193
+ // status`, `pa environment` and `pa workspace start`'s auto-probe
194
+ // all report the same fact. The 0.1.53 F1 finding — the "nothing of
195
+ // yours to reclaim" clause landed only on `workspace start`'s
196
+ // hard-fail branch and never on `pa status` — is closed by rendering
197
+ // the clause here whenever the incumbent is not ours.
192
198
  out.write('\nWorkspace:\n');
193
- const runningPort = readRunningWorkspacePort(repoRoot);
194
- const effectivePort = runningPort ?? workspacePort;
195
- const probeUrl = `http://localhost:${effectivePort}/__api/identity`;
196
- const reachability = await probeWorkspace(probeUrl, opts.fetchImpl);
199
+ const resolved = await coreResolveWorkspace({ repoRoot, fetchImpl: opts.fetchImpl });
200
+ const probeUrl = `${resolved.url}/__api/identity`;
197
201
  out.write(` ${probeUrl}\n`);
198
- if (runningPort !== null && runningPort !== workspacePort) {
199
- out.write(` running port: ${runningPort} (configured: ${workspacePort})\n`);
202
+ if (resolved.runningPort !== null && resolved.runningPort !== resolved.configuredPort) {
203
+ out.write(` running port: ${resolved.runningPort} (configured: ${resolved.configuredPort})\n`);
200
204
  }
201
- out.write(` status: ${reachability.status}`);
202
- if (reachability.detail) out.write(` (${reachability.detail})`);
205
+ const statusLabel = resolved.state === 'running'
206
+ ? 'reachable'
207
+ : resolved.state === 'other-process'
208
+ ? 'in use by another process'
209
+ : 'not running';
210
+ out.write(` status: ${statusLabel}`);
211
+ if (resolved.detail) out.write(` (${resolved.detail})`);
203
212
  out.write('\n');
213
+ const incumbent = formatIncumbentClause(resolved);
214
+ if (incumbent) {
215
+ out.write(` Note: ${incumbent}.\n`);
216
+ }
204
217
  // Workspace-not-running is OK — operators may run pa status before
205
218
  // starting the workspace server.
206
219
 
@@ -323,48 +336,27 @@ function resolveWorkspacePort(repoRoot, opts = {}) {
323
336
  * (see packages/workspace/src/api.mjs). Any other body on the port means
324
337
  * the port is bound by an unrelated process, not our workspace.
325
338
  */
326
- const WORKSPACE_IDENTITY_SERVER = 'digital-operator-pa-workspace';
339
+ import { probeWorkspaceIdentity, WORKSPACE_IDENTITY_SERVER as CORE_WORKSPACE_IDENTITY_SERVER } from '../../../engineering-pa-core/src/workspace/resolve.mjs';
340
+ const WORKSPACE_IDENTITY_SERVER = CORE_WORKSPACE_IDENTITY_SERVER;
327
341
 
342
+ /**
343
+ * Legacy signature retained for existing tests. All new callers go via
344
+ * `coreResolveWorkspace` in packages/core/src/workspace/resolve.mjs.
345
+ * The `{status, detail}` shape is preserved for the WSD-laptop probe
346
+ * tests; internally it delegates to the shared identity probe.
347
+ */
328
348
  async function probeWorkspace(url, fetchImpl) {
329
- const fetchFn = fetchImpl ?? globalThis.fetch;
330
- if (typeof fetchFn !== 'function') {
331
- return { status: 'error', detail: 'fetch unavailable' };
349
+ const probe = await probeWorkspaceIdentity(url, { fetchImpl });
350
+ if (probe.state === 'running') {
351
+ return { status: 'reachable', detail: probe.detail };
332
352
  }
333
- const controller = new AbortController();
334
- const timer = setTimeout(() => controller.abort(), 1500);
335
- try {
336
- const res = await fetchFn(url, { signal: controller.signal });
337
- // Any HTTP response has to be positively identified as OUR workspace
338
- // before the probe reports "reachable". WSD-laptop cold install of
339
- // 0.1.41 (2026-09-16): a docker container bound to :4200 answered
340
- // /__api/identity with HTTP 404 and `pa status` reported the
341
- // workspace "reachable (HTTP 404)". Identity-gate the probe so a
342
- // foreign process on the port surfaces as "in use by another
343
- // process" instead of a false green.
344
- if (res.status >= 200 && res.status < 500) {
345
- let body = null;
346
- try {
347
- body = await res.json();
348
- } catch { /* not JSON — falls through to the identity-mismatch branch */ }
349
- if (body && body.server === WORKSPACE_IDENTITY_SERVER) {
350
- return { status: 'reachable', detail: `HTTP ${res.status}` };
351
- }
352
- return {
353
- status: 'in use by another process',
354
- detail: `HTTP ${res.status}, /__api/identity did not identify as ${WORKSPACE_IDENTITY_SERVER}`,
355
- };
356
- }
357
- return { status: 'error', detail: `HTTP ${res.status}` };
358
- } catch (e) {
359
- // ECONNREFUSED / abort / DNS — server is not running.
360
- const code = e && (e.cause?.code || e.code);
361
- if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || e?.name === 'AbortError') {
362
- return { status: 'not running', detail: null };
363
- }
364
- return { status: 'error', detail: e?.message || String(e) };
365
- } finally {
366
- clearTimeout(timer);
353
+ if (probe.state === 'other-process') {
354
+ return { status: 'in use by another process', detail: probe.detail };
355
+ }
356
+ if (probe.detail === null) {
357
+ return { status: 'not running', detail: null };
367
358
  }
359
+ return { status: probe.detail && probe.detail.startsWith('HTTP') ? 'error' : 'error', detail: probe.detail };
368
360
  }
369
361
 
370
362
  export { doStatus, probeWorkspace, resolveWorkspacePort };
@@ -19,7 +19,7 @@ import { runFind } from './commands/find.mjs';
19
19
  import { runVerify } from './commands/verify.mjs';
20
20
  import { resolveScopePaths, VaultError } from './vault.mjs';
21
21
 
22
- const HELP = `secrets — SOPS+age secrets management
22
+ const HELP = `secrets: SOPS+age secrets management
23
23
 
24
24
  Usage:
25
25
  secrets init Initialise the vault (generate keys, create empty secrets file).
@@ -27,7 +27,8 @@ Usage:
27
27
  --stdin-json: after init, seed KEYs from a JSON object
28
28
  read on stdin (values never echoed; refuses a TTY stdin).
29
29
  secrets get KEY Retrieve a secret (add --exists for a present/absent check that never prints the value)
30
- secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
30
+ secrets set KEY - Read the value from stdin. Safe form: keeps the value out of shell history and argv (0.1.54, w-2026-09-21-dave-002, D4)
31
+ secrets set KEY VALUE Store or update a secret (the argv form is retained but records the value in shell history and the process listing)
31
32
  Since 0.1.20, canonical-first dual-write is
32
33
  the DEFAULT for scoped writes when a canonical
33
34
  clone for the scope is discoverable on this
@@ -45,7 +46,7 @@ Usage:
45
46
  secrets status Check vault health and report key info
46
47
  secrets backup Show QR code of master key for backup (opens browser)
47
48
  secrets vaults List every vault visible from the workspace
48
- (scope, layout, key count, path — no decryption)
49
+ (scope, layout, key count, path; no decryption)
49
50
  secrets find KEY Report which vault(s) contain KEY by name
50
51
  (alias: which; names/paths only, never values;
51
52
  exits 1 when not found)
@@ -53,7 +54,7 @@ Usage:
53
54
  Byte-level comparison of DECRYPTED values
54
55
  between this vault and a reference vault at
55
56
  <path>. Reports per-key same/differs/missing.
56
- --values is required — decrypt-only is never
57
+ --values is required; decrypt-only is never
57
58
  a valid pass (see below). Never prints values.
58
59
 
59
60
  Age private key resolution (0.1.51, w-2026-09-18-dave-007). One shared
@@ -75,13 +76,13 @@ SOPS config: .vault/.sops.yaml (committed to repo)
75
76
  Workspace resolution (all verbs), in order:
76
77
  1. Walk up from the current directory for a vault-bearing workspace
77
78
  (.vault/ or vault/*.enc.json). A git repo WITHOUT a vault does not
78
- stop resolution — it falls through to the next tiers with a stderr
79
+ stop resolution; it falls through to the next tiers with a stderr
79
80
  notice naming the store actually targeted.
80
81
  2. $DAVE_ROOT (or $DAVE_REPO_ROOT / $PA_REPO_ROOT).
81
82
  3. "workspaceRoot" in ~/.config/stravica-pa/defaults.json (written by
82
83
  you or provisioning; the CLI never creates it).
83
84
  4. Error listing what was tried.
84
- NOTE: this precedence is a change — the environment variable used to be
85
+ NOTE: this precedence is a change; the environment variable used to be
85
86
  checked BEFORE the directory walk. Standing inside a workspace now
86
87
  always targets THAT workspace, even when $DAVE_ROOT points elsewhere.
87
88
  Exception: 'secrets init' inside a vault-less git repo initialises the
@@ -93,7 +94,7 @@ Scoped vaults:
93
94
  up to the repo root and checking, in order:
94
95
  .vault/scopes/NAME.enc.json (workspace layout)
95
96
  vault/NAME.enc.json (canonical layout)
96
- Both present is an error — pass --file to
97
+ Both present is an error; pass --file to
97
98
  disambiguate. Supported on: get, set, list,
98
99
  delete, rotate, status, vaults, find.
99
100
  --file PATH Bypass scope resolution and operate directly on
@@ -145,38 +146,38 @@ Default scope (get/set only, when neither --scope nor --file is given):
145
146
  in order:
146
147
  1. $SECRETS_DEFAULT_SCOPE env var.
147
148
  2. "defaultScope" in ~/.config/stravica-pa/defaults.json (same file
148
- as "workspaceRoot" above; written by you or provisioning — the
149
+ as "workspaceRoot" above; written by you or provisioning; the
149
150
  CLI never creates it).
150
151
  3. The calling PA's own agents/<name>/identity.json "defaultScope"
151
152
  field, found by walking up from the current directory (or, for
152
153
  Dave specifically, $DAVE_ROOT/agents/dave/identity.json). A
153
154
  malformed identity.json is a stderr warning, not a crash.
154
- 4. Legacy fallback: the single-file .vault/ vault — the notice says
155
+ 4. Legacy fallback: the single-file .vault/ vault; the notice says
155
156
  so explicitly ("no scope specified and no default configured").
156
157
  Example notices:
157
158
  note: writing to 'hq-estate' pot (default from SECRETS_DEFAULT_SCOPE env var)
158
159
  note: writing to 'wsd' pot (default from /home/dex/agents/barry/dex/identity.json)
159
- note: writing to '(legacy)' pot — no scope specified and no default configured. Use --scope <name> or configure a default.
160
+ note: writing to '(legacy)' pot (no scope specified and no default configured). Use --scope <name> or configure a default.
160
161
 
161
162
  Canonical-first writes (set only):
162
- Canonical-first dual-write is DEFAULT for scoped writes since 0.1.20 —
163
+ Canonical-first dual-write is DEFAULT for scoped writes since 0.1.20:
163
164
  a plain 'secrets set' on a scoped vault writes canonical FIRST and
164
165
  workspace SECOND whenever a canonical clone for the scope is
165
166
  discoverable on this host. Discovery order, first hit wins:
166
167
  1. Explicit --canonical-clone PATH (always wins, bootstraps a
167
168
  missing file).
168
- 2. "canonicalClone" in ~/.config/stravica-pa/defaults.json — either:
169
+ 2. "canonicalClone" in ~/.config/stravica-pa/defaults.json, in one of two shapes:
169
170
  object (scope → file map):
170
171
  { "canonicalClone": { "seo-ops": "/abs/vault/seo-ops.enc.json" } }
171
172
  string (root dir), derives <root>/vault/<scope>.enc.json:
172
173
  { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
173
174
  3. Estate convention: ~/stravica-canonical/*/vault/<scope>.enc.json
174
175
  (exactly one match; ambiguity or zero matches skips this tier).
175
- Absence is a NOTICE, never a failure — a host with no canonical layout
176
+ Absence is a NOTICE, never a failure; a host with no canonical layout
176
177
  keeps writing to workspace only, with a one-line stderr line naming why
177
178
  nothing resolved. If any canonical write fails (existing store
178
179
  undecryptable, sops encrypt failure), the command aborts with exit 2
179
- BEFORE touching the workspace copy — the ordering invariant is
180
+ BEFORE touching the workspace copy; the ordering invariant is
180
181
  identical to the flag path since it always was.
181
182
 
182
183
  --canonical-clone PATH Force PATH as the canonical target (wins over
@@ -187,13 +188,13 @@ Canonical-first writes (set only):
187
188
  Value-compare verification:
188
189
  secrets verify --values --against <path>
189
190
  Decrypts BOTH the resolved vault and the vault at <path>, then
190
- compares every key's VALUE byte-for-byte — never decrypt-only. A
191
+ compares every key's VALUE byte-for-byte; never decrypt-only. A
191
192
  vault clobbered by an always-take-canonical materialisation still
192
193
  decrypts cleanly with placeholder/stale values (same recipients);
193
194
  only a value comparison against a known-good reference catches that.
194
195
  Reports per-key same/differs/missing-in-working/missing-in-reference.
195
196
  Exits 2 if any key differs or is missing on either side. Never
196
- prints a decrypted value — only the comparison result per key name.
197
+ prints a decrypted value; only the comparison result per key name.
197
198
  `;
198
199
 
199
200
  const USAGE = `Usage: secrets {init|get|set|list|delete|rotate|status|backup|vaults|find|verify} [KEY] [VALUE]
@@ -201,6 +202,7 @@ const USAGE = `Usage: secrets {init|get|set|list|delete|rotate|status|backup|vau
201
202
  Commands:
202
203
  init Initialise the vault (generate keys, create config)
203
204
  get KEY Retrieve a secret value
205
+ set KEY - Read the value from stdin (safe form; 0.1.54)
204
206
  set KEY VALUE Store or update a secret (canonical-first by default; --no-canonical to opt out)
205
207
  list List all secret key names
206
208
  delete KEY Remove a secret
@@ -30,7 +30,12 @@ export async function runGet(args, overrides = {}) {
30
30
  // resolveCommandPaths prints the interim stderr notice (naming the
31
31
  // target vault) whenever --scope/--file wasn't given upstream in
32
32
  // cli.mjs. See vault.mjs's default-scope resolution section.
33
- const paths = resolveCommandPaths(overrides);
33
+ //
34
+ // 0.1.54 fix (w-2026-09-21-dave-002, D4): suppress the legacy-pot
35
+ // notice on value-blind reads. `--exists` is scripted-friendly and
36
+ // an interactive operator running `get KEY --exists` doesn't need
37
+ // the routing tip on every call.
38
+ const paths = resolveCommandPaths({ ...overrides, suppressLegacyNotice: exists });
34
39
  const key = rest[0];
35
40
 
36
41
  if (!key) {
@@ -65,7 +65,7 @@ export async function runSet(args, overrides = {}) {
65
65
  let value = rest[1];
66
66
 
67
67
  if (!key || value == null || value === '') {
68
- console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH | --no-canonical] (use `-` as VALUE to read from stdin)');
68
+ console.error('Usage: secrets set KEY - [--canonical-clone PATH | --no-canonical] (reads the value from stdin; the safe form)\n secrets set KEY VALUE [--canonical-clone PATH | --no-canonical] (records the value in shell history and argv)');
69
69
  throw new VaultError('usage', 1);
70
70
  }
71
71
 
@@ -82,7 +82,7 @@ export async function runSet(args, overrides = {}) {
82
82
  // legitimately contains it.
83
83
  value = raw.replace(/\r?\n$/, '');
84
84
  if (value === '') {
85
- console.error('Empty value on stdin — refusing to store an empty secret.');
85
+ console.error('Empty value on stdin; refusing to store an empty secret.');
86
86
  throw new VaultError('empty stdin', 1);
87
87
  }
88
88
  }
@@ -184,7 +184,7 @@ export async function runSet(args, overrides = {}) {
184
184
  const tail = resolved.tried && resolved.tried.length > 0
185
185
  ? ` (tried: ${resolved.tried.join('; ')})`
186
186
  : '';
187
- console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
187
+ console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host; workspace-only write${tail}`);
188
188
  }
189
189
  }
190
190
  }
@@ -13,6 +13,7 @@ import {
13
13
  sopsConfigExists,
14
14
  sopsDecrypt,
15
15
  } from '../vault.mjs';
16
+ import { resolveKeyFile as coreResolveKeyFile } from '../../../../engineering-pa-core/src/vault/require.mjs';
16
17
 
17
18
  async function printDepVersions() {
18
19
  console.log('');
@@ -42,6 +43,22 @@ export async function runStatus(overrides = {}) {
42
43
  console.log('============');
43
44
  console.log('');
44
45
 
46
+ // 0.1.54 (w-2026-09-21-dave-002, A1 diagnostic): print the resolver
47
+ // datapoint on one line so anyone hitting a decrypt-failure has the
48
+ // resolved keyFile + source + mode value-blind. Barry ruling
49
+ // 2026-09-21 (post-QA): the secrets surfaces make the datapoint
50
+ // visible without asking; the F4 root cause is only findable when
51
+ // every failing host reports the same shape.
52
+ try {
53
+ const keyResolved = coreResolveKeyFile({
54
+ repoRoot: paths.root,
55
+ home: paths.home,
56
+ env: {},
57
+ mode: 'auto',
58
+ });
59
+ console.log(`Key resolver: ${keyResolved.keyFile} (source: ${keyResolved.source}, mode: ${keyResolved.mode})`);
60
+ } catch { /* non-fatal; render nothing */ }
61
+
45
62
  // Private key
46
63
  if (keyFileExists(paths)) {
47
64
  console.log(`Private key: ${paths.keyFile} (present)`);