@stigmer/cli 3.12.6 → 3.12.7
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/commands/setup.d.ts.map +1 -1
- package/commands/setup.js +38 -18
- package/commands/setup.js.map +1 -1
- package/local/daemon/components.d.ts.map +1 -1
- package/local/daemon/components.js +11 -1
- package/local/daemon/components.js.map +1 -1
- package/local/daemon/env.d.ts +6 -0
- package/local/daemon/env.d.ts.map +1 -1
- package/local/daemon/env.js +8 -0
- package/local/daemon/env.js.map +1 -1
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/daemon/launch.js +15 -0
- package/local/daemon/launch.js.map +1 -1
- package/local/operator-config.d.ts +28 -0
- package/local/operator-config.d.ts.map +1 -0
- package/local/operator-config.js +77 -0
- package/local/operator-config.js.map +1 -0
- package/local/setup/wizard.d.ts +16 -3
- package/local/setup/wizard.d.ts.map +1 -1
- package/local/setup/wizard.js +72 -13
- package/local/setup/wizard.js.map +1 -1
- package/package.json +5 -5
- package/src/commands/setup.ts +48 -18
- package/src/local/daemon/components.test.ts +20 -0
- package/src/local/daemon/components.ts +9 -1
- package/src/local/daemon/env.test.ts +21 -0
- package/src/local/daemon/env.ts +14 -0
- package/src/local/daemon/launch.ts +17 -0
- package/src/local/operator-config.test.ts +72 -0
- package/src/local/operator-config.ts +95 -0
- package/src/local/setup/wizard.test.ts +46 -1
- package/src/local/setup/wizard.ts +82 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/cli",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.7",
|
|
4
4
|
"description": "Stigmer command-line interface — manage agents, workflows, MCP servers, skills, and executions from the terminal",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"@connectrpc/connect": "^2.0.0",
|
|
36
36
|
"@connectrpc/connect-node": "^2.1.1",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
38
|
-
"@stigmer/ink": "3.12.
|
|
39
|
-
"@stigmer/mcp-server": "3.12.
|
|
40
|
-
"@stigmer/protos": "3.12.
|
|
41
|
-
"@stigmer/sdk": "3.12.
|
|
38
|
+
"@stigmer/ink": "3.12.7",
|
|
39
|
+
"@stigmer/mcp-server": "3.12.7",
|
|
40
|
+
"@stigmer/protos": "3.12.7",
|
|
41
|
+
"@stigmer/sdk": "3.12.7",
|
|
42
42
|
"commander": "^12.1.0",
|
|
43
43
|
"fflate": "^0.8.3",
|
|
44
44
|
"ink": "^7.0.0",
|
package/src/commands/setup.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// `stigmer setup` — configure the LLM provider used for agent execution
|
|
1
|
+
// `stigmer setup` — configure the LLM provider used for agent execution and
|
|
2
|
+
// the optional self-hosted operator identity (oss#796).
|
|
2
3
|
//
|
|
3
4
|
// Two paths share one decision core (local/setup/wizard.ts): a non-interactive
|
|
4
|
-
// flag path (`--provider`, `--
|
|
5
|
-
// interactive
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// flag path (`--provider`/`--api-key`, `--operator-email`/`--operator-name`)
|
|
6
|
+
// for scripts and CI, and an interactive flow when NO flag is given. Flags
|
|
7
|
+
// are independently applicable — operator flags without `--provider` leave
|
|
8
|
+
// the LLM section untouched, and vice versa. Writes land under
|
|
9
|
+
// backend.local.llm / backend.local.operator, preserving every other config
|
|
10
|
+
// field. The valid provider set is owned by wizard.ts's PROVIDER_CHOICES
|
|
11
|
+
// (Anthropic-only + skip — the only story the native runner serves).
|
|
9
12
|
//
|
|
10
13
|
// Setup deliberately has NO model concept: the platform model registry owns
|
|
11
14
|
// the execution default, and per-run overrides belong to `stigmer run
|
|
@@ -21,45 +24,72 @@ import { addResultFlags, resultFormat } from "./shared.js";
|
|
|
21
24
|
interface SetupFlags extends OutputFlags {
|
|
22
25
|
provider?: string;
|
|
23
26
|
apiKey?: string;
|
|
27
|
+
operatorEmail?: string;
|
|
28
|
+
operatorName?: string;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export function registerSetup(program: Command): void {
|
|
27
32
|
const setup = program
|
|
28
33
|
.command("setup")
|
|
29
|
-
.description("configure the LLM provider for
|
|
34
|
+
.description("configure the LLM provider and operator identity for local execution")
|
|
30
35
|
.option("--provider <name>", "provider: anthropic, or skip to clear (non-interactive)")
|
|
31
36
|
.option("--api-key <key>", "Anthropic API key")
|
|
37
|
+
.option("--operator-email <email>", "operator identity for local caller attribution (non-interactive)")
|
|
38
|
+
.option("--operator-name <name>", "operator display name (requires --operator-email)")
|
|
32
39
|
.action((options: SetupFlags) => runSetup(options));
|
|
33
40
|
addResultFlags(setup);
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
async function runSetup(options: SetupFlags): Promise<void> {
|
|
37
44
|
const { load, save } = await import("../config/config.js");
|
|
38
|
-
const { PROVIDER_CHOICES, applyChoice, runInteractiveWizard } =
|
|
45
|
+
const { PROVIDER_CHOICES, applyChoice, applyOperatorIdentity, runInteractiveWizard } =
|
|
46
|
+
await import("../local/setup/wizard.js");
|
|
39
47
|
const { isRunning } = await import("../local/daemon/launch.js");
|
|
40
48
|
const { CliExitError } = await import("../errors/cli-exit-error.js");
|
|
41
49
|
const { ExitCode } = await import("../errors/exit-codes.js");
|
|
42
50
|
|
|
43
51
|
const config = load();
|
|
52
|
+
const nonInteractive =
|
|
53
|
+
options.provider !== undefined ||
|
|
54
|
+
options.operatorEmail !== undefined ||
|
|
55
|
+
options.operatorName !== undefined;
|
|
44
56
|
|
|
45
57
|
let updated: typeof config;
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
if (nonInteractive) {
|
|
59
|
+
updated = config;
|
|
60
|
+
if (options.provider !== undefined) {
|
|
61
|
+
const provider = options.provider.toLowerCase();
|
|
62
|
+
if (!(PROVIDER_CHOICES as readonly string[]).includes(provider)) {
|
|
63
|
+
throw new CliExitError(`unknown provider: ${options.provider}`, ExitCode.Usage, [
|
|
64
|
+
`Valid providers: ${PROVIDER_CHOICES.join(", ")}`,
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
updated = applyChoice(updated, provider as (typeof PROVIDER_CHOICES)[number], {
|
|
68
|
+
apiKey: options.apiKey,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (options.operatorEmail !== undefined || options.operatorName !== undefined) {
|
|
72
|
+
try {
|
|
73
|
+
updated = applyOperatorIdentity(
|
|
74
|
+
updated,
|
|
75
|
+
options.operatorEmail ?? "",
|
|
76
|
+
options.operatorName ?? "",
|
|
77
|
+
);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Same rule the server enforces at boot, surfaced at setup time.
|
|
80
|
+
throw new CliExitError(
|
|
81
|
+
error instanceof Error ? error.message : String(error),
|
|
82
|
+
ExitCode.Usage,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
52
85
|
}
|
|
53
|
-
updated = applyChoice(config, provider as (typeof PROVIDER_CHOICES)[number], {
|
|
54
|
-
apiKey: options.apiKey,
|
|
55
|
-
});
|
|
56
86
|
} else {
|
|
57
87
|
updated = await runInteractiveWizard(config);
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
save(updated);
|
|
61
91
|
|
|
62
|
-
const result = CommandResult.success("
|
|
92
|
+
const result = CommandResult.success("Configuration saved");
|
|
63
93
|
if (await isRunning(homedir())) {
|
|
64
94
|
result.hint("Restart to apply: stigmer down && stigmer up");
|
|
65
95
|
}
|
|
@@ -26,6 +26,26 @@ describe("buildServerEnv", () => {
|
|
|
26
26
|
expect(env.DB_PATH).toBe("/home/u/.stigmer/stigmer.db");
|
|
27
27
|
expect(env.STORAGE_PATH).toBe("/home/u/.stigmer/storage");
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
it("forwards the operator identity only when set, resolved value winning over the base env (oss#796)", () => {
|
|
31
|
+
const bare = buildServerEnv(config, {});
|
|
32
|
+
expect(bare.STIGMER_OPERATOR_EMAIL).toBeUndefined();
|
|
33
|
+
expect(bare.STIGMER_OPERATOR_NAME).toBeUndefined();
|
|
34
|
+
|
|
35
|
+
// Contract value authoritative over a stale inherited export — the
|
|
36
|
+
// ANTHROPIC_API_KEY precedent in buildRunnerEnv.
|
|
37
|
+
const env = buildServerEnv(
|
|
38
|
+
{ ...config, operatorEmail: "ada@example.com", operatorName: "Ada" },
|
|
39
|
+
{ STIGMER_OPERATOR_EMAIL: "stale@example.com" },
|
|
40
|
+
);
|
|
41
|
+
expect(env.STIGMER_OPERATOR_EMAIL).toBe("ada@example.com");
|
|
42
|
+
expect(env.STIGMER_OPERATOR_NAME).toBe("Ada");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not deliver the operator identity to the runner — it resolves identity from session data", () => {
|
|
46
|
+
const env = buildRunnerEnv({ ...config, operatorEmail: "ada@example.com" }, {});
|
|
47
|
+
expect(env.STIGMER_OPERATOR_EMAIL).toBeUndefined();
|
|
48
|
+
});
|
|
29
49
|
});
|
|
30
50
|
|
|
31
51
|
describe("buildRunnerEnv", () => {
|
|
@@ -23,7 +23,7 @@ const SERVER_GATE_TIMEOUT_MS = 30_000;
|
|
|
23
23
|
*/
|
|
24
24
|
export function buildServerEnv(config: DaemonConfig, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
25
25
|
const configDir = dirname(config.dataDir); // ~/.stigmer
|
|
26
|
-
|
|
26
|
+
const env: NodeJS.ProcessEnv = {
|
|
27
27
|
...base,
|
|
28
28
|
GRPC_PORT: String(SERVER_PORT),
|
|
29
29
|
TEMPORAL_HOST_PORT: config.temporalAddress,
|
|
@@ -37,6 +37,14 @@ export function buildServerEnv(config: DaemonConfig, base: NodeJS.ProcessEnv = p
|
|
|
37
37
|
// is visible and enforceable here, next to the runner's LOCAL_ARTIFACT_PATH.
|
|
38
38
|
ARTIFACT_LOCAL_BASE_PATH: join(config.dataDir, ARTIFACTS_SUBDIR),
|
|
39
39
|
};
|
|
40
|
+
// Explicit set (after the base spread) so the launcher-resolved identity wins
|
|
41
|
+
// over a stale inherited value — the delivery path for a `stigmer setup`-
|
|
42
|
+
// persisted operator identity (oss#796; the ANTHROPIC_API_KEY precedent in
|
|
43
|
+
// buildRunnerEnv). Server child only: the runner resolves caller identity
|
|
44
|
+
// from the session's stamped created_by, never from its own env.
|
|
45
|
+
if (config.operatorEmail !== undefined) env.STIGMER_OPERATOR_EMAIL = config.operatorEmail;
|
|
46
|
+
if (config.operatorName !== undefined) env.STIGMER_OPERATOR_NAME = config.operatorName;
|
|
47
|
+
return env;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
@@ -28,6 +28,8 @@ describe("buildDaemonEnv + readDaemonConfig", () => {
|
|
|
28
28
|
cursorApiKey: undefined,
|
|
29
29
|
anthropicApiKey: undefined,
|
|
30
30
|
activityRouting: undefined,
|
|
31
|
+
operatorEmail: undefined,
|
|
32
|
+
operatorName: undefined,
|
|
31
33
|
});
|
|
32
34
|
});
|
|
33
35
|
|
|
@@ -61,6 +63,25 @@ describe("buildDaemonEnv + readDaemonConfig", () => {
|
|
|
61
63
|
expect(config.anthropicApiKey).toBe("sk-ant-env");
|
|
62
64
|
});
|
|
63
65
|
|
|
66
|
+
// The operator identity rides the same config-file-only delivery path as the
|
|
67
|
+
// Anthropic key (oss#796): persisted by `stigmer setup`, absent from the
|
|
68
|
+
// shell, written into the contract explicitly by the launcher.
|
|
69
|
+
it("delivers a launcher-resolved operator identity with no help from the base env", () => {
|
|
70
|
+
const env = buildDaemonEnv(
|
|
71
|
+
{ ...baseInputs, operatorEmail: "ada@example.com", operatorName: "Ada Lovelace" },
|
|
72
|
+
{},
|
|
73
|
+
);
|
|
74
|
+
const config = readDaemonConfig(env);
|
|
75
|
+
expect(config.operatorEmail).toBe("ada@example.com");
|
|
76
|
+
expect(config.operatorName).toBe("Ada Lovelace");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("passes a shell-exported operator identity through from the base env", () => {
|
|
80
|
+
const env = buildDaemonEnv(baseInputs, { STIGMER_OPERATOR_EMAIL: "env@example.com" });
|
|
81
|
+
expect(readDaemonConfig(env).operatorEmail).toBe("env@example.com");
|
|
82
|
+
expect(readDaemonConfig(env).operatorName).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
64
85
|
// Other provider keys have no contract slot; they reach the runner solely via
|
|
65
86
|
// shell-env inheritance (the child env spreads the base env).
|
|
66
87
|
it("leaves shell-exported non-Anthropic keys in the env without parsing them", () => {
|
package/src/local/daemon/env.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const DaemonEnvVar = {
|
|
|
19
19
|
CursorApiKey: "CURSOR_API_KEY",
|
|
20
20
|
AnthropicApiKey: "ANTHROPIC_API_KEY",
|
|
21
21
|
ActivityRouting: "STIGMER_ACTIVITY_ROUTING",
|
|
22
|
+
OperatorEmail: "STIGMER_OPERATOR_EMAIL",
|
|
23
|
+
OperatorName: "STIGMER_OPERATOR_NAME",
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
24
26
|
/** Resolved runner launch coordinates. */
|
|
@@ -41,6 +43,8 @@ export interface DaemonConfig {
|
|
|
41
43
|
cursorApiKey?: string;
|
|
42
44
|
anthropicApiKey?: string;
|
|
43
45
|
activityRouting?: string;
|
|
46
|
+
operatorEmail?: string;
|
|
47
|
+
operatorName?: string;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
/** Inputs the launcher already resolved, to encode into the daemon env. */
|
|
@@ -60,6 +64,12 @@ export interface DaemonEnvInputs {
|
|
|
60
64
|
// persisted delivery path — other keys (OPENAI_API_KEY, CURSOR_API_KEY)
|
|
61
65
|
// reach the runner solely via shell-env inheritance.
|
|
62
66
|
anthropicApiKey?: string;
|
|
67
|
+
// Operator identity resolved by the launcher (env > config file), the same
|
|
68
|
+
// persisted-delivery reasoning as the Anthropic key (oss#796). Consumed by
|
|
69
|
+
// the SERVER child only — the runner resolves caller identity from the
|
|
70
|
+
// session's stamped created_by, never from its own env.
|
|
71
|
+
operatorEmail?: string;
|
|
72
|
+
operatorName?: string;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/**
|
|
@@ -82,6 +92,8 @@ export function buildDaemonEnv(inputs: DaemonEnvInputs, base: NodeJS.ProcessEnv
|
|
|
82
92
|
env[DaemonEnvVar.RunnerAppDir] = inputs.runner.appDir;
|
|
83
93
|
}
|
|
84
94
|
if (inputs.anthropicApiKey !== undefined) env[DaemonEnvVar.AnthropicApiKey] = inputs.anthropicApiKey;
|
|
95
|
+
if (inputs.operatorEmail !== undefined) env[DaemonEnvVar.OperatorEmail] = inputs.operatorEmail;
|
|
96
|
+
if (inputs.operatorName !== undefined) env[DaemonEnvVar.OperatorName] = inputs.operatorName;
|
|
85
97
|
return env;
|
|
86
98
|
}
|
|
87
99
|
|
|
@@ -105,6 +117,8 @@ export function readDaemonConfig(env: NodeJS.ProcessEnv = process.env): DaemonCo
|
|
|
105
117
|
cursorApiKey: nonEmpty(env[DaemonEnvVar.CursorApiKey]),
|
|
106
118
|
anthropicApiKey: nonEmpty(env[DaemonEnvVar.AnthropicApiKey]),
|
|
107
119
|
activityRouting: nonEmpty(env[DaemonEnvVar.ActivityRouting]),
|
|
120
|
+
operatorEmail: nonEmpty(env[DaemonEnvVar.OperatorEmail]),
|
|
121
|
+
operatorName: nonEmpty(env[DaemonEnvVar.OperatorName]),
|
|
108
122
|
};
|
|
109
123
|
}
|
|
110
124
|
|
|
@@ -22,6 +22,7 @@ import { findProcessByPort, isProcessAlive, killProcess } from "../state/proc.js
|
|
|
22
22
|
import { type StartupConfig, removeStartupConfig, saveStartupConfig } from "../state/startup-config.js";
|
|
23
23
|
import { rotateLogs } from "../state/log-rotation.js";
|
|
24
24
|
import { resolveApiKey, resolveProvider } from "../llm-config.js";
|
|
25
|
+
import { resolveOperatorIdentity } from "../operator-config.js";
|
|
25
26
|
import { ensureRunner } from "../runtime/runner.js";
|
|
26
27
|
import { ensureServerBinary } from "../runtime/server.js";
|
|
27
28
|
import { TemporalManager } from "../temporal/manager.js";
|
|
@@ -77,6 +78,7 @@ export async function up(options: UpOptions = {}, home: string = homedir()): Pro
|
|
|
77
78
|
serverBin,
|
|
78
79
|
runner,
|
|
79
80
|
...resolveLlmKeyInputs(config),
|
|
81
|
+
...resolveOperatorIdentityInputs(config),
|
|
80
82
|
},
|
|
81
83
|
process.env,
|
|
82
84
|
);
|
|
@@ -235,6 +237,21 @@ function resolveLlmKeyInputs(config: Config): Pick<DaemonEnvInputs, "anthropicAp
|
|
|
235
237
|
return key === "" ? {} : { anthropicApiKey: key };
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
// Operator-identity delivery for the server child (oss#796): same persisted-
|
|
241
|
+
// delivery reasoning as the LLM key above — a setup-persisted identity lives
|
|
242
|
+
// only in the config file, so the launcher must write it into the daemon
|
|
243
|
+
// contract explicitly. resolveOperatorIdentity's precedence (env > config,
|
|
244
|
+
// sources never mixed) makes both cases one code path.
|
|
245
|
+
function resolveOperatorIdentityInputs(
|
|
246
|
+
config: Config,
|
|
247
|
+
): Pick<DaemonEnvInputs, "operatorEmail" | "operatorName"> {
|
|
248
|
+
const identity = resolveOperatorIdentity(config);
|
|
249
|
+
if (identity === undefined) return {};
|
|
250
|
+
return identity.name === undefined
|
|
251
|
+
? { operatorEmail: identity.email }
|
|
252
|
+
: { operatorEmail: identity.email, operatorName: identity.name };
|
|
253
|
+
}
|
|
254
|
+
|
|
238
255
|
// Default to managed Temporal unless the opaque local config explicitly opts
|
|
239
256
|
// out — zero-config `stigmer up` must work on a fresh machine.
|
|
240
257
|
function isTemporalManaged(config: Config): boolean {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Config } from "../config/config.js";
|
|
3
|
+
import {
|
|
4
|
+
readOperator,
|
|
5
|
+
resolveOperatorIdentity,
|
|
6
|
+
setOperator,
|
|
7
|
+
validateOperatorIdentity,
|
|
8
|
+
} from "./operator-config.js";
|
|
9
|
+
|
|
10
|
+
function config(local?: unknown): Config {
|
|
11
|
+
return { backend: { type: "local", local } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("resolveOperatorIdentity", () => {
|
|
15
|
+
it("honors env > config, never mixing sources", () => {
|
|
16
|
+
const cfg = config({ operator: { email: "cfg@example.com", name: "Config Name" } });
|
|
17
|
+
|
|
18
|
+
// Env email brings the env name (or none) — the config name must not
|
|
19
|
+
// ride along under an env identity, that would be attribution lying.
|
|
20
|
+
expect(resolveOperatorIdentity(cfg, { STIGMER_OPERATOR_EMAIL: "env@example.com" })).toEqual({
|
|
21
|
+
email: "env@example.com",
|
|
22
|
+
});
|
|
23
|
+
expect(
|
|
24
|
+
resolveOperatorIdentity(cfg, {
|
|
25
|
+
STIGMER_OPERATOR_EMAIL: "env@example.com",
|
|
26
|
+
STIGMER_OPERATOR_NAME: "Env Name",
|
|
27
|
+
}),
|
|
28
|
+
).toEqual({ email: "env@example.com", name: "Env Name" });
|
|
29
|
+
|
|
30
|
+
expect(resolveOperatorIdentity(cfg, {})).toEqual({
|
|
31
|
+
email: "cfg@example.com",
|
|
32
|
+
name: "Config Name",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns undefined when neither source has an email — the anonymous default", () => {
|
|
37
|
+
expect(resolveOperatorIdentity(config(), {})).toBeUndefined();
|
|
38
|
+
expect(resolveOperatorIdentity(config({ operator: {} }), {})).toBeUndefined();
|
|
39
|
+
// Whitespace-only values are unset, matching the server's TrimSpace.
|
|
40
|
+
expect(resolveOperatorIdentity(config(), { STIGMER_OPERATOR_EMAIL: " " })).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("trims values, matching the server's boot check", () => {
|
|
44
|
+
const cfg = config({ operator: { email: " ada@example.com ", name: " Ada " } });
|
|
45
|
+
expect(resolveOperatorIdentity(cfg, {})).toEqual({ email: "ada@example.com", name: "Ada" });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("validateOperatorIdentity", () => {
|
|
50
|
+
it("mirrors the server's boot rules: minimal @ check, name requires email", () => {
|
|
51
|
+
expect(validateOperatorIdentity("ada@example.com", "Ada")).toBeUndefined();
|
|
52
|
+
expect(validateOperatorIdentity("ada@example.com", "")).toBeUndefined();
|
|
53
|
+
expect(validateOperatorIdentity("", "")).toBeUndefined(); // "no identity" is valid
|
|
54
|
+
|
|
55
|
+
expect(validateOperatorIdentity("not-an-email", "")).toContain("missing '@'");
|
|
56
|
+
expect(validateOperatorIdentity("", "Ada")).toContain("set both or neither");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("setOperator", () => {
|
|
61
|
+
it("preserves sibling local keys and can clear the section", () => {
|
|
62
|
+
const cfg = config({ temporal: { managed: true }, llm: { provider: "anthropic" } });
|
|
63
|
+
const updated = setOperator(cfg, { email: "ada@example.com", name: "Ada" });
|
|
64
|
+
expect(readOperator(updated)).toEqual({ email: "ada@example.com", name: "Ada" });
|
|
65
|
+
expect((updated.backend.local as { temporal: unknown }).temporal).toEqual({ managed: true });
|
|
66
|
+
expect((updated.backend.local as { llm: unknown }).llm).toEqual({ provider: "anthropic" });
|
|
67
|
+
|
|
68
|
+
const cleared = setOperator(updated, undefined);
|
|
69
|
+
expect(readOperator(cleared)).toBeUndefined();
|
|
70
|
+
expect((cleared.backend.local as { temporal: unknown }).temporal).toEqual({ managed: true });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Typed view over `backend.local.operator` — the self-hosted operator
|
|
2
|
+
// identity `setup` persists and the launcher delivers to the server child
|
|
3
|
+
// (oss#796, the UX-completion arm of oss#400's configured operator identity).
|
|
4
|
+
//
|
|
5
|
+
// The lens pattern mirrors llm-config.ts: config/config.ts carries
|
|
6
|
+
// `backend.local` verbatim so unknown siblings are never dropped; this module
|
|
7
|
+
// owns exactly one sub-tree (`local.operator`) and resolves the effective
|
|
8
|
+
// identity with env-override precedence (STIGMER_OPERATOR_EMAIL >
|
|
9
|
+
// config file). Sources never mix: an env-provided email brings the
|
|
10
|
+
// env-provided name (or none), a config-provided email brings the config
|
|
11
|
+
// name — half-and-half identities would be attribution lies.
|
|
12
|
+
//
|
|
13
|
+
// Validation deliberately duplicates the server's boot check
|
|
14
|
+
// (stigmer-server pkg/config/config.go loadOperatorIdentity): trim both
|
|
15
|
+
// values; a non-empty email must contain '@' (nothing more — the server is
|
|
16
|
+
// equally minimal on purpose); a name without an email is refused because
|
|
17
|
+
// the email IS the identity. Checking here surfaces the failure at setup
|
|
18
|
+
// time instead of at the next `stigmer up`.
|
|
19
|
+
|
|
20
|
+
import type { Config } from "../config/config.js";
|
|
21
|
+
|
|
22
|
+
/** Operator identity as persisted under `backend.local.operator` (snake_case = YAML keys). */
|
|
23
|
+
export interface OperatorSettings {
|
|
24
|
+
email?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LocalSection {
|
|
29
|
+
operator?: OperatorSettings;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function localSection(config: Config): LocalSection {
|
|
34
|
+
const local = config.backend.local;
|
|
35
|
+
return local !== null && typeof local === "object" ? (local as LocalSection) : {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The persisted operator settings, if any. */
|
|
39
|
+
export function readOperator(config: Config): OperatorSettings | undefined {
|
|
40
|
+
return localSection(config).operator;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Effective operator identity: env > config file, sources never mixed.
|
|
45
|
+
* Returns `undefined` when neither source provides an email (the anonymous
|
|
46
|
+
* default oss#400 preserves).
|
|
47
|
+
*/
|
|
48
|
+
export function resolveOperatorIdentity(
|
|
49
|
+
config: Config,
|
|
50
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
+
): OperatorSettings | undefined {
|
|
52
|
+
const envEmail = (env.STIGMER_OPERATOR_EMAIL ?? "").trim();
|
|
53
|
+
if (envEmail !== "") {
|
|
54
|
+
const envName = (env.STIGMER_OPERATOR_NAME ?? "").trim();
|
|
55
|
+
return envName === "" ? { email: envEmail } : { email: envEmail, name: envName };
|
|
56
|
+
}
|
|
57
|
+
const persisted = readOperator(config);
|
|
58
|
+
const email = (persisted?.email ?? "").trim();
|
|
59
|
+
if (email === "") return undefined;
|
|
60
|
+
const name = (persisted?.name ?? "").trim();
|
|
61
|
+
return name === "" ? { email } : { email, name };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate an operator identity the way the server's boot check will
|
|
66
|
+
* (byte-similar messages so setup-time and boot-time failures read as one
|
|
67
|
+
* rule). Returns an error message, or undefined when valid. An empty email
|
|
68
|
+
* with an empty name is valid — it means "no identity", the wizard's skip.
|
|
69
|
+
*/
|
|
70
|
+
export function validateOperatorIdentity(email: string, name: string): string | undefined {
|
|
71
|
+
const trimmedEmail = email.trim();
|
|
72
|
+
const trimmedName = name.trim();
|
|
73
|
+
if (trimmedEmail !== "" && !trimmedEmail.includes("@")) {
|
|
74
|
+
return `"${trimmedEmail}" is not an email address (missing '@')`;
|
|
75
|
+
}
|
|
76
|
+
if (trimmedName !== "" && trimmedEmail === "") {
|
|
77
|
+
return "an operator name without an email is not an identity — set both or neither";
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Return a copy of `config` with the operator section replaced, preserving
|
|
84
|
+
* every other key under `backend.local`. Passing `undefined` clears the
|
|
85
|
+
* section.
|
|
86
|
+
*/
|
|
87
|
+
export function setOperator(config: Config, settings: OperatorSettings | undefined): Config {
|
|
88
|
+
const local: LocalSection = { ...localSection(config) };
|
|
89
|
+
if (settings === undefined) {
|
|
90
|
+
delete local.operator;
|
|
91
|
+
} else {
|
|
92
|
+
local.operator = settings;
|
|
93
|
+
}
|
|
94
|
+
return { ...config, backend: { ...config.backend, local } };
|
|
95
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { Config } from "../../config/config.js";
|
|
3
3
|
import { readLlm } from "../llm-config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { readOperator } from "../operator-config.js";
|
|
5
|
+
import {
|
|
6
|
+
PROVIDER_CHOICES,
|
|
7
|
+
applyChoice,
|
|
8
|
+
applyOperatorIdentity,
|
|
9
|
+
buildLlmForChoice,
|
|
10
|
+
buildOperatorForInputs,
|
|
11
|
+
} from "./wizard.js";
|
|
5
12
|
|
|
6
13
|
function config(local?: unknown): Config {
|
|
7
14
|
return { backend: { type: "local", local } };
|
|
@@ -47,3 +54,41 @@ describe("applyChoice", () => {
|
|
|
47
54
|
expect(readLlm(updated)).toBeUndefined();
|
|
48
55
|
});
|
|
49
56
|
});
|
|
57
|
+
|
|
58
|
+
describe("buildOperatorForInputs", () => {
|
|
59
|
+
it("persists email (and optional name), trimmed", () => {
|
|
60
|
+
expect(buildOperatorForInputs(" ada@example.com ", "")).toEqual({ email: "ada@example.com" });
|
|
61
|
+
expect(buildOperatorForInputs("ada@example.com", " Ada ")).toEqual({
|
|
62
|
+
email: "ada@example.com",
|
|
63
|
+
name: "Ada",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns undefined for empty inputs — nothing to persist, identity stays anonymous", () => {
|
|
68
|
+
expect(buildOperatorForInputs("", "")).toBeUndefined();
|
|
69
|
+
expect(buildOperatorForInputs(" ", "")).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The server's boot rules, surfaced at setup time (oss#796): a malformed
|
|
73
|
+
// identity must never be persisted only to fail the next `stigmer up`.
|
|
74
|
+
it("refuses what the server's boot check would refuse", () => {
|
|
75
|
+
expect(() => buildOperatorForInputs("not-an-email", "")).toThrow(/missing '@'/);
|
|
76
|
+
expect(() => buildOperatorForInputs("", "Ada")).toThrow(/set both or neither/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("applyOperatorIdentity", () => {
|
|
81
|
+
it("writes the identity while preserving siblings", () => {
|
|
82
|
+
const cfg = config({ temporal: { managed: true }, llm: { provider: "anthropic" } });
|
|
83
|
+
const updated = applyOperatorIdentity(cfg, "ada@example.com", "Ada");
|
|
84
|
+
expect(readOperator(updated)).toEqual({ email: "ada@example.com", name: "Ada" });
|
|
85
|
+
expect(readLlm(updated)).toEqual({ provider: "anthropic" });
|
|
86
|
+
expect((updated.backend.local as { temporal: unknown }).temporal).toEqual({ managed: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("empty inputs leave the config unchanged (skip, not clear)", () => {
|
|
90
|
+
const cfg = config({ operator: { email: "keep@example.com" } });
|
|
91
|
+
const updated = applyOperatorIdentity(cfg, "", "");
|
|
92
|
+
expect(readOperator(updated)).toEqual({ email: "keep@example.com" });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
// Local agent execution is Anthropic-only (the native runner only constructs
|
|
8
8
|
// Anthropic clients and the registry's native entries are Anthropic models),
|
|
9
9
|
// so the interactive flow is a single API-key prompt rather than a provider
|
|
10
|
-
// menu
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// menu, followed by the skippable operator-identity prompt (oss#796). There
|
|
11
|
+
// is no model concept here at all: the platform model registry owns the
|
|
12
|
+
// execution default, and per-run overrides belong to `stigmer run --model`
|
|
13
|
+
// (oss#314 removed the dead setup-level pin).
|
|
13
14
|
|
|
14
15
|
import type { Config } from "../../config/config.js";
|
|
15
16
|
import { type LlmSettings, setLlm } from "../llm-config.js";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type OperatorSettings,
|
|
19
|
+
setOperator,
|
|
20
|
+
validateOperatorIdentity,
|
|
21
|
+
} from "../operator-config.js";
|
|
22
|
+
import { promptLine, promptSecret } from "./prompt.js";
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* The providers `stigmer setup` accepts. This is the single source of truth
|
|
@@ -47,25 +53,88 @@ export function applyChoice(config: Config, choice: ProviderChoice, inputs: Sele
|
|
|
47
53
|
return setLlm(config, buildLlmForChoice(choice, inputs));
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resolve operator-identity inputs to the settings to persist, validating the
|
|
58
|
+
* way the server's boot check will (oss#796) — the pure decision core shared
|
|
59
|
+
* by the flag path and the interactive prompt, like buildLlmForChoice. An
|
|
60
|
+
* empty email with an empty name yields `undefined` (nothing to persist —
|
|
61
|
+
* identity stays whatever it was; the anonymous default is the absence of
|
|
62
|
+
* the section, not an empty one). Throws on inputs the server would refuse
|
|
63
|
+
* at boot, so the failure surfaces at setup time.
|
|
64
|
+
*/
|
|
65
|
+
export function buildOperatorForInputs(email: string, name: string): OperatorSettings | undefined {
|
|
66
|
+
const error = validateOperatorIdentity(email, name);
|
|
67
|
+
if (error !== undefined) {
|
|
68
|
+
throw new Error(error);
|
|
69
|
+
}
|
|
70
|
+
const trimmedEmail = email.trim();
|
|
71
|
+
if (trimmedEmail === "") return undefined;
|
|
72
|
+
const trimmedName = name.trim();
|
|
73
|
+
return trimmedName === "" ? { email: trimmedEmail } : { email: trimmedEmail, name: trimmedName };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Apply operator-identity inputs to a config, returning the updated copy. */
|
|
77
|
+
export function applyOperatorIdentity(config: Config, email: string, name: string): Config {
|
|
78
|
+
const settings = buildOperatorForInputs(email, name);
|
|
79
|
+
return settings === undefined ? config : setOperator(config, settings);
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
/**
|
|
51
83
|
* Run the interactive setup against the current terminal: one Anthropic
|
|
52
|
-
* API-key prompt
|
|
53
|
-
*
|
|
54
|
-
* entry is an explicit skip.
|
|
84
|
+
* API-key prompt, then the (skippable) operator-identity prompt. A key
|
|
85
|
+
* already in the environment is used without prompting (and never persisted —
|
|
86
|
+
* env precedence makes a copy redundant); an empty entry is an explicit skip.
|
|
55
87
|
*/
|
|
56
88
|
export async function runInteractiveWizard(config: Config, env: NodeJS.ProcessEnv = process.env): Promise<Config> {
|
|
57
89
|
process.stderr.write("\nStigmer runs local agents on Anthropic Claude models.\n");
|
|
58
90
|
process.stderr.write("The model is chosen automatically from the platform model registry.\n\n");
|
|
59
91
|
|
|
92
|
+
let updated: Config;
|
|
60
93
|
if (env.ANTHROPIC_API_KEY) {
|
|
61
94
|
process.stderr.write("Using ANTHROPIC_API_KEY from environment\n");
|
|
62
|
-
|
|
95
|
+
updated = applyChoice(config, "anthropic");
|
|
96
|
+
} else {
|
|
97
|
+
const key = (await promptSecret("Enter your Anthropic API key (press Enter to skip)")).trim();
|
|
98
|
+
if (key === "") {
|
|
99
|
+
process.stderr.write("Skipped. Agents won't execute until a provider is configured (stigmer setup).\n");
|
|
100
|
+
updated = applyChoice(config, "skip");
|
|
101
|
+
} else {
|
|
102
|
+
updated = applyChoice(config, "anthropic", { apiKey: key });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return runOperatorIdentityStep(updated, env);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The operator-identity step (oss#796): opt-in, skippable, and env-aware in
|
|
111
|
+
* the same way the API-key step is — an env-provided identity is used without
|
|
112
|
+
* prompting and never persisted (env precedence makes a copy redundant). An
|
|
113
|
+
* invalid entry is refused and NOT persisted (re-run `stigmer setup`), the
|
|
114
|
+
* same rule the server enforces at boot, just earlier.
|
|
115
|
+
*/
|
|
116
|
+
async function runOperatorIdentityStep(config: Config, env: NodeJS.ProcessEnv): Promise<Config> {
|
|
117
|
+
process.stderr.write(
|
|
118
|
+
"\nAn operator identity attributes local creates to you and unlocks identity-gated MCP tools.\n",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if ((env.STIGMER_OPERATOR_EMAIL ?? "").trim() !== "") {
|
|
122
|
+
process.stderr.write("Using STIGMER_OPERATOR_EMAIL from environment\n");
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const email = (await promptLine("Operator email (press Enter to skip): ")).trim();
|
|
127
|
+
if (email === "") {
|
|
128
|
+
return config;
|
|
63
129
|
}
|
|
130
|
+
const name = (await promptLine("Operator display name (optional): ")).trim();
|
|
64
131
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
132
|
+
try {
|
|
133
|
+
return applyOperatorIdentity(config, email, name);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`Not saved: ${error instanceof Error ? error.message : String(error)} — re-run stigmer setup.\n`,
|
|
137
|
+
);
|
|
138
|
+
return config;
|
|
69
139
|
}
|
|
70
|
-
return applyChoice(config, "anthropic", { apiKey: key });
|
|
71
140
|
}
|