@monster-orange/den 0.4.19 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +7 -7
- package/docs/distributions.md +49 -6
- package/package.json +1 -1
- package/src/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/ui/cli/doctor-command.ts +54 -2
- package/src/ui/server/index.ts +5 -1
- package/src/ui/server/public/i18n/es.js +1 -0
- package/src/ui/server/public/i18n/ja.js +1 -0
- package/src/ui/server/public/settings-registry.js +22 -0
- package/src/ui/server/public/shell.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.4.20] - 2026-11-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **1Password works for a Den running as a service.** A Den under launchd
|
|
12
|
+
does not read your login shell, so `op` had no
|
|
13
|
+
`OP_SERVICE_ACCOUNT_TOKEN` and every 1Password `secret_env` ref failed
|
|
14
|
+
with a bare "not resolved". `secrets.onepassword.token` in
|
|
15
|
+
`config/local/user.yml` (per machine) now says where the token comes
|
|
16
|
+
from: `keychain:<service>` (macOS), `file:<path>` (mode 600 only) or
|
|
17
|
+
`env:<VAR>` (the default is `env:OP_SERVICE_ACCOUNT_TOKEN`, as before).
|
|
18
|
+
A bad value is refused at config load, naming the key. The token is read
|
|
19
|
+
when a ref is resolved, never cached, and goes only into the `op` child's
|
|
20
|
+
env; it is never logged, stored, put in an error, the activity records
|
|
21
|
+
or an API response.
|
|
22
|
+
- **"Not resolved" now says why.** A 1Password ref that fails reports one
|
|
23
|
+
of: no service-account token or session, no access to the vault, item or
|
|
24
|
+
field not found, rate limited, or a short safe summary. A token source
|
|
25
|
+
that fails (keychain item missing, file mode wrong, env var unset) is
|
|
26
|
+
named, never the token.
|
|
27
|
+
- **doctor** adds a `1password` row, "this process has no service-account
|
|
28
|
+
token. Set secrets.onepassword.token.", when 1Password is the provider
|
|
29
|
+
and the source yields none (a warning).
|
|
30
|
+
- **Settings › Secrets** shows the token's source, never the token
|
|
31
|
+
(`GET /api/settings` gains `op_token_source`, a string).
|
|
32
|
+
|
|
7
33
|
## [0.4.19] - 2026-11-08
|
|
8
34
|
|
|
9
35
|
### Added
|
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<picture>
|
|
13
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.
|
|
14
|
-
<img src="docs/images/0.4.
|
|
13
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.20/hero-dark.png">
|
|
14
|
+
<img src="docs/images/0.4.20/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
|
|
15
15
|
</picture>
|
|
16
16
|
|
|
17
17
|
## Set up by your agent
|
|
@@ -39,7 +39,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
|
|
|
39
39
|
yours. Den connects them and turns on good practice from the start.
|
|
40
40
|
|
|
41
41
|
<p align="center">
|
|
42
|
-
<img src="docs/images/0.4.
|
|
42
|
+
<img src="docs/images/0.4.20/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
|
|
43
43
|
</p>
|
|
44
44
|
|
|
45
45
|
## How it helps
|
|
@@ -69,10 +69,10 @@ yours. Den connects them and turns on good practice from the start.
|
|
|
69
69
|
lives in your own repo.
|
|
70
70
|
|
|
71
71
|
<p align="center">
|
|
72
|
-
<img src="docs/images/0.4.
|
|
73
|
-
<img src="docs/images/0.4.
|
|
74
|
-
<img src="docs/images/0.4.
|
|
75
|
-
<img src="docs/images/0.4.
|
|
72
|
+
<img src="docs/images/0.4.20/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
|
|
73
|
+
<img src="docs/images/0.4.20/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
|
|
74
|
+
<img src="docs/images/0.4.20/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
|
|
75
|
+
<img src="docs/images/0.4.20/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
|
|
76
76
|
</p>
|
|
77
77
|
|
|
78
78
|
## Get started
|
package/docs/distributions.md
CHANGED
|
@@ -619,12 +619,12 @@ The backend is a strategy, chosen with `secrets.provider` in the org config
|
|
|
619
619
|
(per engineer in `config/local/user.yml`, per shell with
|
|
620
620
|
`<PREFIX>_SECRETS_PROVIDER`):
|
|
621
621
|
|
|
622
|
-
| `secrets.provider` | What it is
|
|
623
|
-
| ------------------ |
|
|
624
|
-
| `none` | No store. Nothing is resolved, no CLI is run; `den create` scaffolds this
|
|
625
|
-
| `env` | Plain environment variables of the process running Den
|
|
626
|
-
| `doppler` | The `doppler` CLI (`secrets.doppler: { project, config }` for a default scope)
|
|
627
|
-
| `1password` | The `op` CLI (`secrets.onepassword.account` for the account, else your own `OP_ACCOUNT
|
|
622
|
+
| `secrets.provider` | What it is |
|
|
623
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
624
|
+
| `none` | No store. Nothing is resolved, no CLI is run; `den create` scaffolds this |
|
|
625
|
+
| `env` | Plain environment variables of the process running Den |
|
|
626
|
+
| `doppler` | The `doppler` CLI (`secrets.doppler: { project, config }` for a default scope) |
|
|
627
|
+
| `1password` | The `op` CLI (`secrets.onepassword.account` for the account, else your own `OP_ACCOUNT`; [a service-account token for a service](#1password-for-a-den-running-as-a-service)) |
|
|
628
628
|
|
|
629
629
|
```yaml
|
|
630
630
|
# config/acme.yml
|
|
@@ -799,6 +799,49 @@ OPENAI_API_KEY (names only; not resolved in a dry run)`.
|
|
|
799
799
|
(`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
|
|
800
800
|
`secret_env` too.
|
|
801
801
|
|
|
802
|
+
## 1Password for a Den running as a service
|
|
803
|
+
|
|
804
|
+
A Den started by launchd or systemd does not read your login shell, so
|
|
805
|
+
`op` has no `OP_SERVICE_ACCOUNT_TOKEN` and every 1Password ref fails.
|
|
806
|
+
`secrets.onepassword.token` says where this machine keeps the
|
|
807
|
+
service-account token. It is per machine: Den reads it from
|
|
808
|
+
`config/local/user.yml` only, and a value in the distribution's config is
|
|
809
|
+
ignored.
|
|
810
|
+
|
|
811
|
+
```yaml
|
|
812
|
+
# config/local/user.yml
|
|
813
|
+
secrets:
|
|
814
|
+
onepassword:
|
|
815
|
+
token: keychain:op-service-account # or file:~/.config/op/token | env:OP_SERVICE_ACCOUNT_TOKEN
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
| Value | Where the token comes from |
|
|
819
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
820
|
+
| `keychain:<service>` | macOS: `security find-generic-password -s <service> -w`, run with no shell. Refused on other systems |
|
|
821
|
+
| `file:<path>` | A file whose mode is exactly 600 (a group or other bit refuses it). `~` expands; a trailing newline is dropped |
|
|
822
|
+
| `env:<VAR>` | The env var `<VAR>` of the process running Den. With no key set the default is `env:OP_SERVICE_ACCOUNT_TOKEN`, as before |
|
|
823
|
+
|
|
824
|
+
- **Checked at config load.** A value that is none of these shapes is
|
|
825
|
+
refused, naming `secrets.onepassword.token`. The message never repeats
|
|
826
|
+
the value.
|
|
827
|
+
- **Read each time.** The token is read when a ref is resolved, never
|
|
828
|
+
cached or written anywhere, and passed only in the `op` child's env as
|
|
829
|
+
`OP_SERVICE_ACCOUNT_TOKEN`. It is never in a log, an error, the activity
|
|
830
|
+
records or an API response.
|
|
831
|
+
- **Why a ref failed.** A `secret_env` key that does not resolve says why,
|
|
832
|
+
in plain words: no service-account token or session; no access to the
|
|
833
|
+
vault; item or field not found; rate limited; or a short summary of
|
|
834
|
+
what `op` said. When the token source itself fails (the keychain item is
|
|
835
|
+
missing, the file's mode is wrong, the env var is unset) the message
|
|
836
|
+
names the source, never the token.
|
|
837
|
+
- **doctor.** With `secrets.provider: 1password` and a source that yields
|
|
838
|
+
no token, `doctor` adds a `1password` row: "this process has no
|
|
839
|
+
service-account token. Set secrets.onepassword.token." It is a warning,
|
|
840
|
+
not a failure (a signed-in `op` app needs no token). `doctor` only looks
|
|
841
|
+
a keychain item up; it never reads the token.
|
|
842
|
+
- **Settings.** Settings › Secrets shows the source (`keychain: <service>`,
|
|
843
|
+
`file: <path>`, `env: <VAR>` or "not set"), never the token.
|
|
844
|
+
|
|
802
845
|
## The edge and TLS
|
|
803
846
|
|
|
804
847
|
The edge is one Traefik per org that routes `<name>.<edge.domain>` (and
|
package/package.json
CHANGED
|
@@ -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?: {
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
91
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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) => ({
|
|
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`. */
|
|
@@ -37,8 +37,13 @@ import { readBrand } from "../../shared/brand.js";
|
|
|
37
37
|
import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
|
|
38
38
|
import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
|
|
39
39
|
import {
|
|
40
|
+
describeOpTokenSource,
|
|
41
|
+
opTokenSourceProblem,
|
|
42
|
+
parseOpTokenSource,
|
|
40
43
|
readSecretsConfig,
|
|
44
|
+
resolveProviderName,
|
|
41
45
|
secretsStatus,
|
|
46
|
+
type SecretsConfig,
|
|
42
47
|
} from "../../shared/secrets/index.js";
|
|
43
48
|
import {
|
|
44
49
|
DEN_PTY_ID,
|
|
@@ -77,7 +82,22 @@ type Selector = (env: NodeJS.ProcessEnv) => DoctorRow;
|
|
|
77
82
|
|
|
78
83
|
const SELECTORS: Record<string, Selector> = {
|
|
79
84
|
secrets(env) {
|
|
80
|
-
|
|
85
|
+
let cfg: SecretsConfig;
|
|
86
|
+
try {
|
|
87
|
+
cfg = readSecretsConfig();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// A bad secrets.* key (naming the key): a problem, not a crash.
|
|
90
|
+
return {
|
|
91
|
+
kind: "secrets",
|
|
92
|
+
selected: "",
|
|
93
|
+
implicit: false,
|
|
94
|
+
available: false,
|
|
95
|
+
reason: (err as Error).message,
|
|
96
|
+
problem: true,
|
|
97
|
+
warnings: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const st = secretsStatus(cfg, env);
|
|
81
101
|
return {
|
|
82
102
|
kind: "secrets",
|
|
83
103
|
selected: st.backend,
|
|
@@ -172,7 +192,7 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
|
|
|
172
192
|
...Object.keys(SELECTORS).filter((k) => kinds.includes(k)),
|
|
173
193
|
...kinds.filter((k) => !(k in SELECTORS)),
|
|
174
194
|
];
|
|
175
|
-
|
|
195
|
+
const rows = ordered.map(
|
|
176
196
|
(kind) =>
|
|
177
197
|
SELECTORS[kind]?.(env) ?? {
|
|
178
198
|
kind,
|
|
@@ -184,6 +204,38 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
|
|
|
184
204
|
warnings: [],
|
|
185
205
|
},
|
|
186
206
|
);
|
|
207
|
+
const op = onePasswordTokenRow(env);
|
|
208
|
+
if (op) rows.splice(rows.findIndex((r) => r.kind === "secrets") + 1, 0, op);
|
|
209
|
+
return rows;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The 1password row: shown only when 1Password is the configured provider
|
|
214
|
+
* (secret_env refs and the engine's own secrets resolve through it) and the
|
|
215
|
+
* configured token source yields no token. A warning, not a failure: a
|
|
216
|
+
* signed-in desktop app needs none. Checks the source without reading a
|
|
217
|
+
* keychain secret, so nothing prompts.
|
|
218
|
+
*/
|
|
219
|
+
function onePasswordTokenRow(env: NodeJS.ProcessEnv): DoctorRow | null {
|
|
220
|
+
try {
|
|
221
|
+
const cfg = readSecretsConfig();
|
|
222
|
+
if (resolveProviderName(cfg, env) !== "1password") return null;
|
|
223
|
+
const src = parseOpTokenSource(cfg.onepassword?.token);
|
|
224
|
+
const why = opTokenSourceProblem(src, { env });
|
|
225
|
+
if (!why) return null;
|
|
226
|
+
return {
|
|
227
|
+
kind: "1password",
|
|
228
|
+
selected: describeOpTokenSource(src),
|
|
229
|
+
implicit: false,
|
|
230
|
+
available: false,
|
|
231
|
+
reason:
|
|
232
|
+
"this process has no service-account token. Set secrets.onepassword.token.",
|
|
233
|
+
problem: false,
|
|
234
|
+
warnings: [why],
|
|
235
|
+
};
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
187
239
|
}
|
|
188
240
|
|
|
189
241
|
export function formatDoctor(rows: DoctorRow[], title: string): string[] {
|
package/src/ui/server/index.ts
CHANGED
|
@@ -173,6 +173,7 @@ import {
|
|
|
173
173
|
} from "../../shared/auth/sudo-vault.js";
|
|
174
174
|
import {
|
|
175
175
|
notConfiguredMessage,
|
|
176
|
+
opTokenSourceSetting,
|
|
176
177
|
secretsConfigWarnings,
|
|
177
178
|
} from "../../shared/secrets/index.js";
|
|
178
179
|
import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
|
|
@@ -4860,7 +4861,8 @@ async function handleRequest(
|
|
|
4860
4861
|
// ── API: user settings ────────────────────────────────────────────────────
|
|
4861
4862
|
// GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages,
|
|
4862
4863
|
// log_window_lines, graph, appearance, shell, distribution,
|
|
4863
|
-
// user_web_port, local_dev, cli_tabs, language, ui_languages
|
|
4864
|
+
// user_web_port, local_dev, cli_tabs, language, ui_languages,
|
|
4865
|
+
// op_token_source }
|
|
4864
4866
|
// POST /api/settings body: { editor?, terminal?, editor_overrides?, graph?, appearance?, shell?,
|
|
4865
4867
|
// language? }
|
|
4866
4868
|
// graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
|
|
@@ -4929,6 +4931,8 @@ async function handleRequest(
|
|
|
4929
4931
|
// what it is when unset (the short hostname).
|
|
4930
4932
|
machine_name: machineNameSetting(user),
|
|
4931
4933
|
machine_name_default: machineLabel(null, os.hostname()),
|
|
4934
|
+
// Where the 1Password token comes from (the source, never the token).
|
|
4935
|
+
op_token_source: opTokenSourceSetting(),
|
|
4932
4936
|
});
|
|
4933
4937
|
}
|
|
4934
4938
|
if (req.method === "POST") {
|
|
@@ -402,6 +402,7 @@
|
|
|
402
402
|
"set.cli.web_port": "Tu puerto web",
|
|
403
403
|
"set.cli.signin": "Inicio de sesión del navegador",
|
|
404
404
|
"set.cli.sudo": "Contraseña de administrador",
|
|
405
|
+
"set.cli.op_token": "Origen del token de 1Password",
|
|
405
406
|
"set.access.owner": "Propietario",
|
|
406
407
|
"set.access.devices": "Dispositivos vinculados",
|
|
407
408
|
"set.access.waiting": "Dispositivos en espera",
|
|
@@ -391,6 +391,7 @@
|
|
|
391
391
|
"set.cli.web_port": "あなたの web ポート",
|
|
392
392
|
"set.cli.signin": "ブラウザのサインイン",
|
|
393
393
|
"set.cli.sudo": "管理者パスワード",
|
|
394
|
+
"set.cli.op_token": "1Password トークンの取得元",
|
|
394
395
|
"set.access.owner": "オーナー",
|
|
395
396
|
"set.access.devices": "リンク済みデバイス",
|
|
396
397
|
"set.access.waiting": "承認待ちのデバイス",
|
|
@@ -81,6 +81,13 @@ export const DEN_SETTING_CATEGORIES = [
|
|
|
81
81
|
description:
|
|
82
82
|
"Reach this Den from your other devices: sign in with GitHub as its owner, or pair a device with a one-time link. On localhost nothing asks.",
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
id: "secrets",
|
|
86
|
+
label: "Secrets",
|
|
87
|
+
order: 58,
|
|
88
|
+
description:
|
|
89
|
+
"Where this machine gets the credentials Den resolves secrets with. Per machine (config/local/user.yml); only the source is shown, never a secret.",
|
|
90
|
+
},
|
|
84
91
|
{
|
|
85
92
|
id: "cli",
|
|
86
93
|
label: "Command line",
|
|
@@ -1033,6 +1040,21 @@ export const DEN_SETTINGS = [
|
|
|
1033
1040
|
read: () => null,
|
|
1034
1041
|
},
|
|
1035
1042
|
|
|
1043
|
+
{
|
|
1044
|
+
// 1Password for a Den running as a service: where its service-account
|
|
1045
|
+
// token comes from (secrets.onepassword.token in user.yml). The source
|
|
1046
|
+
// only, never the token.
|
|
1047
|
+
id: "cli.op_token",
|
|
1048
|
+
category: "secrets",
|
|
1049
|
+
label: "1Password token source",
|
|
1050
|
+
description:
|
|
1051
|
+
"Where this machine's 1Password service-account token comes from, for a Den running as a service. Set secrets.onepassword.token in config/local/user.yml: keychain:<service>, file:<path> or env:<VAR>. The token itself is never shown.",
|
|
1052
|
+
keywords: ["secrets", "1password", "op", "service account", "token"],
|
|
1053
|
+
scope: "user",
|
|
1054
|
+
control: { type: "readonly" },
|
|
1055
|
+
read: (ctx) => ctx.data.op_token_source ?? "not set",
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1036
1058
|
...ACCESS_ROWS,
|
|
1037
1059
|
|
|
1038
1060
|
// ── Distribution (read-only) ───────────────────────────────────────────
|
|
@@ -516,6 +516,7 @@
|
|
|
516
516
|
"set.cli.web_port": "Your web port",
|
|
517
517
|
"set.cli.signin": "Browser sign-in",
|
|
518
518
|
"set.cli.sudo": "Admin password",
|
|
519
|
+
"set.cli.op_token": "1Password token source",
|
|
519
520
|
"set.access.owner": "Owner",
|
|
520
521
|
"set.access.devices": "Linked devices",
|
|
521
522
|
"ident.local": "Local",
|