@monster-orange/den 0.4.18 → 0.4.20

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 (53) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +148 -30
  4. package/docs/reference.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/install-den/SKILL.md +35 -75
  7. package/src/shared/agents/den-claude.ts +40 -6
  8. package/src/shared/agents/guardrails/stop-hook.ts +30 -4
  9. package/src/shared/agents/guardrails.ts +40 -4
  10. package/src/shared/agents/handback.ts +22 -4
  11. package/src/shared/agents/personas.ts +32 -5
  12. package/src/shared/agents/runtime.ts +8 -0
  13. package/src/shared/agents/server-agent.ts +57 -0
  14. package/src/shared/agents/server-pack/server.md +45 -0
  15. package/src/shared/environment-manager/workspaces.ts +4 -0
  16. package/src/shared/secrets/index.ts +71 -3
  17. package/src/shared/secrets/onepassword.ts +87 -14
  18. package/src/shared/secrets/op-token.ts +270 -0
  19. package/src/shared/secrets/secret-env.ts +19 -6
  20. package/src/shared/secrets/strategies.ts +6 -0
  21. package/src/shared/secrets/types.ts +8 -0
  22. package/src/ui/cli/agent-ops.ts +41 -6
  23. package/src/ui/cli/create.ts +2 -2
  24. package/src/ui/cli/doctor-command.ts +54 -2
  25. package/src/ui/cli/index.ts +1 -1
  26. package/src/ui/cli/repo-command.ts +8 -1
  27. package/src/ui/cli/repo-detect.ts +58 -0
  28. package/src/ui/cli/server-agent-ops.ts +411 -0
  29. package/src/ui/mcp/server.ts +29 -12
  30. package/src/ui/mcp/tools.ts +4 -2
  31. package/src/ui/server/agent-routes.ts +1 -1
  32. package/src/ui/server/held-actions.ts +35 -7
  33. package/src/ui/server/index.ts +25 -6
  34. package/src/ui/server/public/agents-page.js +10 -1
  35. package/src/ui/server/public/error-page.js +39 -1
  36. package/src/ui/server/public/graph.css +21 -0
  37. package/src/ui/server/public/graph.js +49 -6
  38. package/src/ui/server/public/header.js +29 -7
  39. package/src/ui/server/public/i18n/es.js +38 -0
  40. package/src/ui/server/public/i18n/ja.js +40 -0
  41. package/src/ui/server/public/local-dev.html +22 -1
  42. package/src/ui/server/public/persona-ui.css +6 -0
  43. package/src/ui/server/public/server-agent-page.js +415 -0
  44. package/src/ui/server/public/server-agent.html +222 -0
  45. package/src/ui/server/public/settings-registry.js +22 -0
  46. package/src/ui/server/public/shell.js +67 -6
  47. package/src/ui/server/public/svc-panel.js +27 -14
  48. package/src/ui/server/public/topbar.js +13 -1
  49. package/src/ui/server/route-classes.ts +21 -0
  50. package/src/ui/server/server-agent-routes.ts +176 -0
  51. package/src/ui/server/session-routes.ts +12 -1
  52. package/src/ui/server/ui-assets.ts +2 -0
  53. package/src/ui/server/workspace-routes.ts +3 -0
@@ -17,6 +17,12 @@
17
17
  */
18
18
  import { readDenConfig, readUserConfig } from "../den-config.js";
19
19
  import type { DetectResult } from "../strategies/registry.js";
20
+ import { ConfigError } from "../config-error.js";
21
+ import {
22
+ describeOpTokenSource,
23
+ opTokenSourceError,
24
+ parseOpTokenSource,
25
+ } from "./op-token.js";
20
26
  import { notConfiguredMessage, SecretsNotConfiguredError } from "./none.js";
21
27
  import { secretsStrategy, type SecretsStrategy } from "./strategies.js";
22
28
  import type {
@@ -37,6 +43,7 @@ export { notConfiguredMessage, SecretsNotConfiguredError } from "./none.js";
37
43
  export { secretsStrategy, secretsStrategyIds } from "./strategies.js";
38
44
  export type { SecretsStrategy, SecretsSetupContext } from "./strategies.js";
39
45
  export * from "./secret-env.js";
46
+ export * from "./op-token.js";
40
47
 
41
48
  export interface SecretsConfig {
42
49
  provider?: SecretsProviderName;
@@ -48,7 +55,16 @@ export interface SecretsConfig {
48
55
  */
49
56
  required?: boolean;
50
57
  doppler?: { project?: string; config?: string };
51
- onepassword?: { account?: string };
58
+ onepassword?: {
59
+ account?: string;
60
+ /**
61
+ * Where the service-account token comes from: `keychain:<service>`,
62
+ * `file:<path>` (mode 600) or `env:<VAR>` (the default,
63
+ * `env:OP_SERVICE_ACCOUNT_TOKEN`). Per machine: read from
64
+ * config/local/user.yml only, never from the distribution's config.
65
+ */
66
+ token?: string;
67
+ };
52
68
  /** `"vault/item/field": FLAT_KEY` overrides for doppler/env. */
53
69
  keys?: Record<string, string>;
54
70
  /**
@@ -126,11 +142,32 @@ export function readSecretsConfig(): SecretsConfig {
126
142
  } catch {
127
143
  /* no user config */
128
144
  }
145
+ // The token source is this machine's: the distribution's config never
146
+ // names one, and a bad one is refused here, naming the key.
147
+ const tokenErr =
148
+ user.onepassword?.token === undefined || user.onepassword.token === null
149
+ ? null
150
+ : opTokenSourceError(user.onepassword.token);
151
+ if (tokenErr) {
152
+ const key = "secrets.onepassword.token";
153
+ throw new ConfigError([
154
+ {
155
+ file: "config/local/user.yml",
156
+ key,
157
+ message: tokenErr,
158
+ text: `${key} ${tokenErr}`,
159
+ },
160
+ ]);
161
+ }
129
162
  return {
130
163
  ...team,
131
164
  ...user,
132
165
  doppler: { ...team.doppler, ...user.doppler },
133
- onepassword: { ...team.onepassword, ...user.onepassword },
166
+ onepassword: {
167
+ ...team.onepassword,
168
+ ...user.onepassword,
169
+ token: user.onepassword?.token ?? undefined,
170
+ },
134
171
  keys: { ...team.keys, ...user.keys },
135
172
  refs: { ...team.refs, ...user.refs },
136
173
  };
@@ -251,6 +288,14 @@ class DetectedProvider implements SecretsProvider {
251
288
  async tryGet(ref: SecretRef): Promise<string | null> {
252
289
  return this.unavailable() ? null : this.inner.tryGet(ref);
253
290
  }
291
+ async tryGetWithReason(
292
+ ref: SecretRef,
293
+ ): Promise<{ value: string | null; reason?: string }> {
294
+ const why = this.unavailable();
295
+ if (why) return { value: null, reason: why };
296
+ if (this.inner.tryGetWithReason) return this.inner.tryGetWithReason(ref);
297
+ return { value: await this.inner.tryGet(ref) };
298
+ }
254
299
  async set(ref: SecretRef, value: string): Promise<void> {
255
300
  const why = this.unavailable();
256
301
  if (why) throw new Error(`${this.name} secrets backend ${why}`);
@@ -364,6 +409,24 @@ export function secretsProcessEnv(
364
409
  return strategyFor(name).capabilities.env?.(cfg, env) ?? {};
365
410
  }
366
411
 
412
+ /**
413
+ * Settings › Secrets: where the 1Password token comes from, as text
414
+ * ("keychain: <service>", "file: <path>", "env: <VAR>") or "not set". The
415
+ * source only, never the token; reads nothing from the keychain or the file.
416
+ */
417
+ export function opTokenSourceSetting(
418
+ cfg: SecretsConfig | (() => SecretsConfig) = readSecretsConfig,
419
+ ): string {
420
+ try {
421
+ const raw = (typeof cfg === "function" ? cfg() : cfg).onepassword?.token;
422
+ return raw === undefined || raw === null
423
+ ? "not set"
424
+ : describeOpTokenSource(parseOpTokenSource(raw));
425
+ } catch {
426
+ return "invalid: see secrets.onepassword.token";
427
+ }
428
+ }
429
+
367
430
  /** The engine's own named refs, for the legacy warning. */
368
431
  export const ENGINE_SECRET_REFS = ["sudo", "journeys_agent_key"] as const;
369
432
 
@@ -379,9 +442,14 @@ export const IMPLICIT_PROVIDER_WARNING =
379
442
  * distribution that needs no secrets never hears about it.
380
443
  */
381
444
  export function secretsConfigWarnings(
382
- cfg: SecretsConfig = readSecretsConfig(),
445
+ cfg?: SecretsConfig,
383
446
  env: NodeJS.ProcessEnv = process.env,
384
447
  ): string[] {
448
+ try {
449
+ cfg ??= readSecretsConfig();
450
+ } catch (err) {
451
+ return [(err as Error).message];
452
+ }
385
453
  let r: ResolvedProvider;
386
454
  try {
387
455
  r = resolveProvider(cfg, env);
@@ -5,27 +5,95 @@
5
5
  import { execFile } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
8
+ import {
9
+ DEFAULT_OP_TOKEN_SOURCE,
10
+ OP_TOKEN_ENV,
11
+ OpFailure,
12
+ mapOpFailure,
13
+ readOpToken,
14
+ type OpTokenDeps,
15
+ type OpTokenSource,
16
+ } from "./op-token.js";
8
17
 
9
18
  import { product } from "../product.js";
10
19
  const execFileAsync = promisify(execFile);
11
20
 
12
21
  export interface OnePasswordOptions {
13
22
  account?: string;
23
+ /**
24
+ * Where the service-account token comes from (`secrets.onepassword.token`).
25
+ * Unset: the default `env:OP_SERVICE_ACCOUNT_TOKEN`, and `op` is run with
26
+ * this process's own env even when that has none (a signed-in desktop app
27
+ * still works).
28
+ */
29
+ tokenSource?: OpTokenSource;
30
+ /** Test seam: the env, platform and home the token source is read with. */
31
+ tokenDeps?: OpTokenDeps;
14
32
  timeoutMs?: number;
15
33
  }
16
34
 
17
35
  export class OnePasswordProvider implements SecretsProvider {
18
36
  readonly name = "1password" as const;
19
37
  private readonly timeout: number;
38
+ private readonly tokenSource: OpTokenSource | undefined;
39
+ private readonly tokenDeps: OpTokenDeps | undefined;
20
40
 
21
41
  constructor(opts: OnePasswordOptions = {}) {
22
42
  this.timeout = opts.timeoutMs ?? 60_000;
43
+ this.tokenSource = opts.tokenSource;
44
+ this.tokenDeps = opts.tokenDeps;
23
45
  if (opts.account) process.env.OP_ACCOUNT ||= opts.account;
24
46
  }
25
47
 
48
+ /**
49
+ * The options for one `op` call. The token is read now (never cached) and
50
+ * goes only into the child's env. A configured source that yields none
51
+ * fails with its reason; the default source with no token runs `op` as
52
+ * before.
53
+ */
54
+ private async opOptions(): Promise<{
55
+ timeout: number;
56
+ env?: NodeJS.ProcessEnv;
57
+ token?: string;
58
+ }> {
59
+ const explicit = this.tokenSource !== undefined;
60
+ const t = await readOpToken(
61
+ this.tokenSource ?? DEFAULT_OP_TOKEN_SOURCE,
62
+ this.tokenDeps,
63
+ );
64
+ if (!t.ok) {
65
+ if (explicit) throw new OpFailure("token_source", t.reason);
66
+ return { timeout: this.timeout, env: process.env };
67
+ }
68
+ return {
69
+ timeout: this.timeout,
70
+ env: { ...process.env, [OP_TOKEN_ENV]: t.token },
71
+ token: t.token,
72
+ };
73
+ }
74
+
75
+ /** Run `op`; a failure carries op's reason in plain words, never the token. */
76
+ private async run(args: string[], rawErrors = false): Promise<string> {
77
+ const { token, ...opts } = await this.opOptions();
78
+ try {
79
+ const { stdout } = await execFileAsync("op", args, opts);
80
+ return String(stdout);
81
+ } catch (err) {
82
+ // `set` keeps op's own error: its caller strips the value it wrote.
83
+ if (rawErrors) throw err;
84
+ const e = err as { stderr?: unknown; killed?: boolean; code?: unknown };
85
+ if (e.killed)
86
+ throw new OpFailure("unknown", "1Password failed: op timed out");
87
+ if (e.code === "ENOENT")
88
+ throw new OpFailure("unknown", "1Password failed: op is not installed");
89
+ const m = mapOpFailure(String(e.stderr ?? ""), token ? [token] : []);
90
+ throw new OpFailure(m.code, m.text);
91
+ }
92
+ }
93
+
26
94
  async isReady(): Promise<boolean> {
27
95
  try {
28
- await execFileAsync("op", ["whoami"], { timeout: this.timeout });
96
+ await this.run(["whoami"]);
29
97
  return true;
30
98
  } catch {
31
99
  return false;
@@ -37,18 +105,26 @@ export class OnePasswordProvider implements SecretsProvider {
37
105
  const args = ["item", "get", ref.item];
38
106
  if (ref.vault) args.push("--vault", ref.vault);
39
107
  args.push("--fields", `label=${ref.field}`, "--reveal");
40
- const { stdout } = await execFileAsync("op", args, {
41
- timeout: this.timeout,
42
- });
43
- return String(stdout).trim();
108
+ return (await this.run(args)).trim();
44
109
  }
45
110
 
46
111
  async tryGet(ref: SecretRef): Promise<string | null> {
112
+ return (await this.tryGetWithReason(ref)).value;
113
+ }
114
+
115
+ async tryGetWithReason(
116
+ ref: SecretRef,
117
+ ): Promise<{ value: string | null; reason?: string }> {
47
118
  try {
48
119
  const v = await this.get(ref);
49
- return v.length > 0 ? v : null;
50
- } catch {
51
- return null;
120
+ return v.length > 0
121
+ ? { value: v }
122
+ : { value: null, reason: "the field is empty" };
123
+ } catch (err) {
124
+ return {
125
+ value: null,
126
+ reason: err instanceof OpFailure ? err.message : undefined,
127
+ };
52
128
  }
53
129
  }
54
130
 
@@ -57,13 +133,10 @@ export class OnePasswordProvider implements SecretsProvider {
57
133
  const vault = ref.vault ? ["--vault", ref.vault] : [];
58
134
  const field = `${ref.field}[password]=${value}`;
59
135
  try {
60
- await execFileAsync("op", ["item", "edit", ref.item, ...vault, field], {
61
- timeout: this.timeout,
62
- });
136
+ await this.run(["item", "edit", ref.item, ...vault, field], true);
63
137
  } catch (err) {
64
138
  try {
65
- await execFileAsync(
66
- "op",
139
+ await this.run(
67
140
  [
68
141
  "item",
69
142
  "create",
@@ -74,7 +147,7 @@ export class OnePasswordProvider implements SecretsProvider {
74
147
  ...vault,
75
148
  field,
76
149
  ],
77
- { timeout: this.timeout },
150
+ true,
78
151
  );
79
152
  } catch {
80
153
  throw err;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Where the 1Password service-account token comes from, and why `op` failed.
3
+ *
4
+ * A Den running as a service (launchd, systemd) does not read a login shell,
5
+ * so `op` has no OP_SERVICE_ACCOUNT_TOKEN of its own. `secrets.onepassword.token`
6
+ * in config/local/user.yml (per machine, never the distribution's) says
7
+ * where to read one:
8
+ *
9
+ * keychain:<service> macOS: `security find-generic-password -s <service> -w`
10
+ * file:<path> a file whose mode is exactly 600; `~` expands
11
+ * env:<VAR> an env var of this process (the default is
12
+ * env:OP_SERVICE_ACCOUNT_TOKEN)
13
+ *
14
+ * The token is read when a ref is resolved, never cached, and goes only into
15
+ * the `op` child's env. Nothing here returns it in text: every message names
16
+ * the source, never the token.
17
+ */
18
+ import { execFile, execFileSync } from "child_process";
19
+ import * as fs from "fs";
20
+ import * as os from "os";
21
+ import * as path from "path";
22
+ import { promisify } from "util";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ export const OP_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN";
27
+ const SECURITY_TIMEOUT_MS = 10_000;
28
+
29
+ export type OpTokenSource =
30
+ | { kind: "keychain"; service: string }
31
+ | { kind: "file"; path: string }
32
+ | { kind: "env"; name: string };
33
+
34
+ export const DEFAULT_OP_TOKEN_SOURCE: OpTokenSource = {
35
+ kind: "env",
36
+ name: OP_TOKEN_ENV,
37
+ };
38
+
39
+ const SHAPE =
40
+ "must be keychain:<service>, file:<path> or env:<VAR> (for example env:OP_SERVICE_ACCOUNT_TOKEN)";
41
+
42
+ /** Why `raw` is not a valid token source, or null. Never echoes `raw`. */
43
+ export function opTokenSourceError(raw: unknown): string | null {
44
+ if (typeof raw !== "string") return SHAPE;
45
+ const m = /^(keychain|file|env):(.*)$/s.exec(raw);
46
+ if (!m) return SHAPE;
47
+ const [, kind, rest] = m;
48
+ // eslint-disable-next-line no-control-regex
49
+ if (rest.trim() === "" || /[\u0000-\u001f]/.test(rest))
50
+ return `${kind}: needs a value on one line; ${SHAPE}`;
51
+ if (kind === "env" && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rest))
52
+ return `env: needs an env var name; ${SHAPE}`;
53
+ if (kind === "keychain" && rest.startsWith("-"))
54
+ return `keychain: service name must not start with "-"; ${SHAPE}`;
55
+ return null;
56
+ }
57
+
58
+ /** The source `raw` names (unset: the default). Throws, naming `where`. */
59
+ export function parseOpTokenSource(
60
+ raw: unknown,
61
+ where = "secrets.onepassword.token",
62
+ ): OpTokenSource {
63
+ if (raw === undefined || raw === null) return DEFAULT_OP_TOKEN_SOURCE;
64
+ const err = opTokenSourceError(raw);
65
+ if (err) throw new Error(`${where} ${err}`);
66
+ const s = raw as string;
67
+ const i = s.indexOf(":");
68
+ const kind = s.slice(0, i);
69
+ const rest = s.slice(i + 1);
70
+ if (kind === "keychain") return { kind, service: rest };
71
+ if (kind === "file") return { kind, path: rest };
72
+ return { kind: "env", name: rest };
73
+ }
74
+
75
+ /** "keychain: <service>", "file: <path>" or "env: <VAR>": what people see. */
76
+ export function describeOpTokenSource(src: OpTokenSource): string {
77
+ if (src.kind === "keychain") return `keychain: ${src.service}`;
78
+ if (src.kind === "file") return `file: ${src.path}`;
79
+ return `env: ${src.name}`;
80
+ }
81
+
82
+ export type OpTokenResult =
83
+ | { ok: true; token: string }
84
+ | { ok: false; reason: string };
85
+
86
+ export interface OpTokenDeps {
87
+ env?: NodeJS.ProcessEnv;
88
+ platform?: NodeJS.Platform;
89
+ home?: string;
90
+ }
91
+
92
+ function expandHome(p: string, home: string): string {
93
+ if (p === "~") return home;
94
+ if (p.startsWith("~/")) return path.join(home, p.slice(2));
95
+ return p;
96
+ }
97
+
98
+ function fail(src: OpTokenSource, why: string): OpTokenResult {
99
+ return {
100
+ ok: false,
101
+ reason: `token source ${describeOpTokenSource(src)}: ${why}`,
102
+ };
103
+ }
104
+
105
+ function readTokenFile(src: { kind: "file"; path: string }, home: string) {
106
+ const file = expandHome(src.path, home);
107
+ let st: fs.Stats;
108
+ try {
109
+ st = fs.statSync(file);
110
+ } catch {
111
+ return fail(src, "file not found or not readable");
112
+ }
113
+ if (!st.isFile()) return fail(src, "not a regular file");
114
+ const mode = st.mode & 0o777;
115
+ if (mode !== 0o600)
116
+ return fail(
117
+ src,
118
+ `file mode is ${mode.toString(8).padStart(3, "0")}; it must be 600 (chmod 600 the file)`,
119
+ );
120
+ let text: string;
121
+ try {
122
+ text = fs.readFileSync(file, "utf-8");
123
+ } catch {
124
+ return fail(src, "file not readable");
125
+ }
126
+ const token = text.replace(/[\r\n]+$/, "");
127
+ return token ? ({ ok: true, token } as const) : fail(src, "file is empty");
128
+ }
129
+
130
+ function fromEnv(src: { kind: "env"; name: string }, env: NodeJS.ProcessEnv) {
131
+ const v = env[src.name];
132
+ return v
133
+ ? ({ ok: true, token: v } as const)
134
+ : fail(src, "env var is not set");
135
+ }
136
+
137
+ const NOT_MAC = "the macOS keychain is not available on this system";
138
+
139
+ /**
140
+ * Read the token now. Never cached. The failure reason names the source,
141
+ * never the token.
142
+ */
143
+ export async function readOpToken(
144
+ src: OpTokenSource,
145
+ deps: OpTokenDeps = {},
146
+ ): Promise<OpTokenResult> {
147
+ const env = deps.env ?? process.env;
148
+ const home = deps.home ?? os.homedir();
149
+ if (src.kind === "env") return fromEnv(src, env);
150
+ if (src.kind === "file") return readTokenFile(src, home);
151
+ if ((deps.platform ?? process.platform) !== "darwin")
152
+ return fail(src, NOT_MAC);
153
+ try {
154
+ const { stdout } = await execFileAsync(
155
+ "security",
156
+ ["find-generic-password", "-s", src.service, "-w"],
157
+ { timeout: SECURITY_TIMEOUT_MS, env },
158
+ );
159
+ const token = String(stdout).replace(/[\r\n]+$/, "");
160
+ return token ? { ok: true, token } : fail(src, "keychain item is empty");
161
+ } catch {
162
+ return fail(src, "keychain item not found or not readable");
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Whether the source yields a token, for `doctor`: the same checks, but a
168
+ * keychain item is only looked up (no `-w`), so nothing is read and no
169
+ * keychain prompt can appear. Returns the reason, or null when it works.
170
+ */
171
+ export function opTokenSourceProblem(
172
+ src: OpTokenSource,
173
+ deps: OpTokenDeps = {},
174
+ ): string | null {
175
+ const env = deps.env ?? process.env;
176
+ const home = deps.home ?? os.homedir();
177
+ if (src.kind === "env") {
178
+ const r = fromEnv(src, env);
179
+ return r.ok ? null : r.reason;
180
+ }
181
+ if (src.kind === "file") {
182
+ const r = readTokenFile(src, home);
183
+ return r.ok ? null : r.reason;
184
+ }
185
+ if ((deps.platform ?? process.platform) !== "darwin")
186
+ return `token source ${describeOpTokenSource(src)}: ${NOT_MAC}`;
187
+ try {
188
+ execFileSync("security", ["find-generic-password", "-s", src.service], {
189
+ timeout: SECURITY_TIMEOUT_MS,
190
+ env,
191
+ stdio: "ignore",
192
+ });
193
+ return null;
194
+ } catch {
195
+ return `token source ${describeOpTokenSource(src)}: keychain item not found`;
196
+ }
197
+ }
198
+
199
+ export type OpFailureCode =
200
+ | "no_token"
201
+ | "no_access"
202
+ | "not_found"
203
+ | "rate_limited"
204
+ | "token_source"
205
+ | "unknown";
206
+
207
+ export interface OpFailureInfo {
208
+ code: OpFailureCode;
209
+ /** Plain words, safe to show. */
210
+ text: string;
211
+ }
212
+
213
+ /**
214
+ * Map `op`'s stderr to one plain reason. Pure. `redact` lists strings (the
215
+ * token) to blank out of the unknown case's summary.
216
+ */
217
+ export function mapOpFailure(
218
+ stderr: string,
219
+ redact: readonly string[] = [],
220
+ ): OpFailureInfo {
221
+ const s = stderr ?? "";
222
+ if (
223
+ /not signed in|no active session|service account token|sign ?in|session (has )?expired|authenticat|no accounts? (configured|found)/i.test(
224
+ s,
225
+ )
226
+ )
227
+ return {
228
+ code: "no_token",
229
+ text: "1Password has no service-account token or session for this process",
230
+ };
231
+ if (/rate.?limit|too many requests|\b429\b/i.test(s))
232
+ return { code: "rate_limited", text: "1Password rate limited the request" };
233
+ if (
234
+ /isn't a vault|is not a vault|don't have access|do not have access|doesn't have access|not authorized|forbidden|permission|\b403\b|access denied/i.test(
235
+ s,
236
+ )
237
+ )
238
+ return { code: "no_access", text: "no access to the vault" };
239
+ if (
240
+ /isn't an item|isn't a field|is not an item|not found|no such|could not find|couldn't find|doesn't exist|does not exist|no (item|field)|more than one item/i.test(
241
+ s,
242
+ )
243
+ )
244
+ return { code: "not_found", text: "item or field not found" };
245
+ let line =
246
+ s
247
+ .split(/\r?\n/)
248
+ .map((l) =>
249
+ l.replace(/^\[\w+\]\s*\d{4}\/\d\d\/\d\d \d\d:\d\d:\d\d\s*/, "").trim(),
250
+ )
251
+ .find((l) => l) ?? "";
252
+ for (const r of redact) if (r) line = line.split(r).join("[hidden]");
253
+ line = line.replace(/op:\/\/\S+/g, "[ref]");
254
+ if (line.length > 100) line = `${line.slice(0, 100)}...`;
255
+ return {
256
+ code: "unknown",
257
+ text: line ? `1Password failed: ${line}` : "1Password failed (no detail)",
258
+ };
259
+ }
260
+
261
+ /** A failed 1Password read, with a reason that is safe to show. */
262
+ export class OpFailure extends Error {
263
+ constructor(
264
+ readonly code: OpFailureCode,
265
+ message: string,
266
+ ) {
267
+ super(message);
268
+ this.name = "OpFailure";
269
+ }
270
+ }
@@ -80,6 +80,8 @@ export function assertSecretEnvSpec(
80
80
  export interface SecretEnvMissing {
81
81
  key: string;
82
82
  ref: string;
83
+ /** Why, in plain words, when the provider said (never a value). */
84
+ reason?: string;
83
85
  }
84
86
 
85
87
  export type SecretEnvResolution =
@@ -87,8 +89,10 @@ export type SecretEnvResolution =
87
89
  | { ok: false; missing: SecretEnvMissing[]; message: string };
88
90
 
89
91
  /**
90
- * The failure text for unresolved keys: key, ref and provider, never a value
91
- * and never the provider's own error output (which could echo one).
92
+ * The failure text for unresolved keys: key, ref and provider, and the
93
+ * provider's reason when it gave one (a mapped phrase such as "no access to
94
+ * the vault"), never a value and never its raw error output (which could
95
+ * echo one).
92
96
  */
93
97
  export function describeSecretEnvMissing(
94
98
  target: string,
@@ -96,7 +100,8 @@ export function describeSecretEnvMissing(
96
100
  provider: string,
97
101
  ): string {
98
102
  const lines = missing.map(
99
- (m) => ` ${m.key} (ref ${m.ref}, provider ${provider}): not resolved`,
103
+ (m) =>
104
+ ` ${m.key} (ref ${m.ref}, provider ${provider}): not resolved${m.reason ? `: ${m.reason}` : ""}`,
100
105
  );
101
106
  return (
102
107
  `${target}: ${missing.length} secret_env key(s) could not be resolved; not starting it:\n` +
@@ -120,17 +125,25 @@ export async function resolveSecretEnv(
120
125
  entries.map(async ([key, raw]) => {
121
126
  if (secretEnvRefError(raw)) return { key, raw, value: null };
122
127
  let value: string | null = null;
128
+ let reason: string | undefined;
123
129
  try {
124
- value = await provider.tryGet(parseSecretEnvRef(raw));
130
+ const ref = parseSecretEnvRef(raw);
131
+ if (provider.tryGetWithReason) {
132
+ ({ value, reason } = await provider.tryGetWithReason(ref));
133
+ } else value = await provider.tryGet(ref);
125
134
  } catch {
126
135
  value = null;
127
136
  }
128
- return { key, raw, value };
137
+ return { key, raw, value, reason };
129
138
  }),
130
139
  );
131
140
  const missing = results
132
141
  .filter((r) => !r.value)
133
- .map((r) => ({ key: r.key, ref: String(r.raw) }));
142
+ .map((r) => ({
143
+ key: r.key,
144
+ ref: String(r.raw),
145
+ ...(r.reason ? { reason: r.reason } : {}),
146
+ }));
134
147
  if (missing.length)
135
148
  return {
136
149
  ok: false,
@@ -27,6 +27,7 @@ import { DopplerProvider } from "./doppler.js";
27
27
  import { EnvProvider } from "./env.js";
28
28
  import { NoneProvider } from "./none.js";
29
29
  import { OnePasswordProvider } from "./onepassword.js";
30
+ import { parseOpTokenSource } from "./op-token.js";
30
31
  import type { SecretsConfig } from "./index.js";
31
32
  import type { SecretRef, SecretsProvider } from "./types.js";
32
33
 
@@ -162,6 +163,11 @@ const onepassword: SecretsStrategy = {
162
163
  // From the distribution's config or the engineer's shell; never a
163
164
  // built-in account.
164
165
  account: c.onepassword?.account,
166
+ // Per machine (user.yml); validated when the config is read.
167
+ tokenSource:
168
+ c.onepassword?.token === undefined
169
+ ? undefined
170
+ : parseOpTokenSource(c.onepassword.token),
165
171
  timeoutMs: OP_CLI_TIMEOUT_MS,
166
172
  }),
167
173
  cli: { bin: "op", formula: "1password-cli" },
@@ -50,6 +50,14 @@ export interface SecretsProvider {
50
50
  get(ref: SecretRef): Promise<string>;
51
51
  /** Read a secret, returning null instead of throwing. */
52
52
  tryGet(ref: SecretRef): Promise<string | null>;
53
+ /**
54
+ * Like tryGet, plus a plain-words reason when it is null (op's own: no
55
+ * token, no access to the vault, not found, rate limited). Optional. The
56
+ * reason never carries a secret value or a token.
57
+ */
58
+ tryGetWithReason?(
59
+ ref: SecretRef,
60
+ ): Promise<{ value: string | null; reason?: string }>;
53
61
  /** Write (create or overwrite) a secret. Throws on failure. */
54
62
  set(ref: SecretRef, value: string): Promise<void>;
55
63
  /** Check the backend is usable before `den up`/`restart`. */