@zenera/engine 1.1.47 → 1.1.49

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/README.md CHANGED
@@ -13,6 +13,10 @@ behind (`state.json`, `report.html`, run metadata).
13
13
  It never prints and never prompts. Warnings go to the `Logger` you pass, errors are `EngineError`s
14
14
  with a code, and installing a container engine is a `preflight` the host supplies.
15
15
 
16
+ Pass `user` to say who the run is acting for. The agent gets it from the `current_user` tool, and code in
17
+ the sandbox gets it from `ZEN_USER_*` or the `zenera` library. See
18
+ [docs/identity.md](../../docs/identity.md).
19
+
16
20
  ## Use
17
21
 
18
22
  ```ts
@@ -26,6 +30,7 @@ const engine = await openEngine({
26
30
  project: { dir: '/project', name: 'acme' },
27
31
  session,
28
32
  memoryDir: '/data/users/u1/memory',
33
+ user: { id: 'u1', email: 'ann@acme.com', role: 'editor', source: 'host' },
29
34
  logger: { warn: (m) => console.warn(m) },
30
35
  });
31
36
  try {
package/dist/engine.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { AgentRunner, Workspace, type AgentEvent, type AgentProject, type AgentState, type Input, type MemoryIndex, type RunResult } from '@zenera/neo';
1
+ import { AgentRunner, Workspace, type AgentEvent, type AgentProject, type AgentState, type Input, type MemoryIndex, type RunIdentity, type RunResult } from '@zenera/neo';
2
2
  import { type ModelIssue } from './audit.ts';
3
3
  import { type Logger } from './errors.ts';
4
4
  import { type Project } from './projects.ts';
5
5
  import { type Preflight, type SandboxSetup } from './sandbox.ts';
6
6
  import { type Held, type RunPaths, type SessionPaths } from './session.ts';
7
+ /** Who a run acts for — re-exported so a host needs only this package. */
8
+ export type { RunIdentity } from '@zenera/neo';
7
9
  /** A run drawn for a model to read, written beside it as `graph.mmd`. */
8
10
  export type GraphRenderer = (state: AgentState, run: {
9
11
  runId: string;
@@ -53,6 +55,18 @@ export interface EngineOptions {
53
55
  preflight?: Preflight;
54
56
  /** draws each finished run as `graph.mmd`; no graph when unset */
55
57
  graph?: GraphRenderer;
58
+ /**
59
+ * Who the run is acting for — the person the host authenticated, not the
60
+ * project's author and not the owner of the keys. `current_user` answers with it and
61
+ * every sandbox command sees it as `ZEN_USER_*`.
62
+ *
63
+ * Unset, the engine takes `ZEN_USER_*` from its own environment, marked
64
+ * `local`: that is how a `zen run` inside a sandbox keeps acting for the
65
+ * same person, and how `zen run --as` works. `null` means nobody, and reads
66
+ * nothing — a server that knows its runs have no user says so, rather than
67
+ * letting whatever its own environment holds speak for one.
68
+ */
69
+ user?: RunIdentity | null;
56
70
  }
57
71
  export interface Engine {
58
72
  project: AgentProject;
@@ -74,6 +88,8 @@ export interface Engine {
74
88
  sandbox: SandboxSetup;
75
89
  lock: Held;
76
90
  graph?: GraphRenderer;
91
+ /** who the run is acting for, when anyone */
92
+ user?: RunIdentity;
77
93
  close(): Promise<void>;
78
94
  }
79
95
  /**
@@ -82,6 +98,13 @@ export interface Engine {
82
98
  * and the whole conversation, its memory and its blobs travel with it.
83
99
  */
84
100
  export declare function openEngine(opts: EngineOptions): Promise<Engine>;
101
+ /**
102
+ * The identity a run acts for: what the host said, or — when it said nothing —
103
+ * what the environment carries, which is never more than `local`. Validated
104
+ * here because it is about to become variables and a container's argv: a
105
+ * newline in an id is not an id.
106
+ */
107
+ export declare function resolveUser(given: RunIdentity | null | undefined): RunIdentity | undefined;
85
108
  export interface Mount {
86
109
  /** the directory on this machine */
87
110
  host: string;
package/dist/engine.js CHANGED
@@ -1,11 +1,11 @@
1
- import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, fileTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
1
+ import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, compactIdentity, exaTools, fileTools, identityFromEnv, identityTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { writeFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { auditModels, describeIssue } from "./audit.js";
7
7
  import { loadProjectEnv } from "./env.js";
8
- import { EXIT, EngineError, invalidError, stderrLogger } from "./errors.js";
8
+ import { EXIT, EngineError, invalidError, stderrLogger, usageError, } from "./errors.js";
9
9
  import { readJson, writeJson } from "./home.js";
10
10
  import { KeyStore, assertUsable } from "./keys.js";
11
11
  import { projectMounts } from "./projects.js";
@@ -19,10 +19,15 @@ import { acquire, createRun, readSessionMeta, writeRunMeta, writeSessionMeta, }
19
19
  export async function openEngine(opts) {
20
20
  const logger = opts.logger ?? stderrLogger;
21
21
  const onIssue = opts.onIssue ?? ((issue) => logger.warn(describeIssue(issue)));
22
+ const user = resolveUser(opts.user);
22
23
  // Before the keyring, so the project's own file wins over the machine's
23
24
  // keys, and before the load, so `${VAR}` in agents.yaml can name anything
24
25
  // in it. The real environment still beats both.
25
26
  const env = loadProjectEnv(opts.project.dir);
27
+ if (env?.refused.length && !opts.quiet) {
28
+ logger.warn(`${env.path} sets ${env.refused.join(', ')} — ignored: who a run acts for ` +
29
+ 'is the host’s to say (zen run --as <user> to try one)');
30
+ }
26
31
  const keys = await KeyStore.open();
27
32
  keys.materialize();
28
33
  assertUsable(keys);
@@ -66,6 +71,7 @@ export async function openEngine(opts) {
66
71
  keys: opts.keys,
67
72
  env: env?.names,
68
73
  mounts,
74
+ identity: user,
69
75
  ...(opts.disposable ? { persist: false } : {}),
70
76
  });
71
77
  const workspaceOptions = {
@@ -90,6 +96,9 @@ export async function openEngine(opts) {
90
96
  // turn that tried.
91
97
  ...exaTools(),
92
98
  ],
99
+ // Every agent can ask who it is acting for without the project
100
+ // having to know the tool exists; `-current_user` still takes it away.
101
+ alwaysTools: identityTools(user),
93
102
  skillsAt: SKILLS_MOUNT,
94
103
  memory: opts.memory,
95
104
  memoryDir: opts.memoryDir,
@@ -135,6 +144,7 @@ export async function openEngine(opts) {
135
144
  sandbox,
136
145
  lock,
137
146
  graph: opts.graph,
147
+ user,
138
148
  close: async () => {
139
149
  await teardown(sandbox.pool, logger);
140
150
  // A host-supplied graph is the host's to release.
@@ -145,6 +155,30 @@ export async function openEngine(opts) {
145
155
  },
146
156
  };
147
157
  }
158
+ /**
159
+ * The identity a run acts for: what the host said, or — when it said nothing —
160
+ * what the environment carries, which is never more than `local`. Validated
161
+ * here because it is about to become variables and a container's argv: a
162
+ * newline in an id is not an id.
163
+ */
164
+ export function resolveUser(given) {
165
+ if (given === null) {
166
+ return undefined;
167
+ }
168
+ const user = given ? compactIdentity(given) : identityFromEnv(process.env);
169
+ if (!user) {
170
+ return undefined;
171
+ }
172
+ if (!user.id.trim()) {
173
+ throw usageError('the run’s user has an empty id');
174
+ }
175
+ for (const [key, value] of Object.entries(user)) {
176
+ if (typeof value === 'string' && /[\0\r\n]/.test(value)) {
177
+ throw usageError(`the run’s user ${key} has a control character in it`);
178
+ }
179
+ }
180
+ return user;
181
+ }
148
182
  async function loadState(session) {
149
183
  if (!existsSync(session.state)) {
150
184
  return undefined;
@@ -188,6 +222,7 @@ export function envelope(engine, outcome) {
188
222
  ...(outcome.graph ? { graph: outcome.graph } : {}),
189
223
  },
190
224
  mounts: mounts(engine),
225
+ ...(engine.user ? { user: engine.user } : {}),
191
226
  agent: outcome.result.agent,
192
227
  stopReason: outcome.result.stopReason,
193
228
  durationMs: outcome.durationMs,
@@ -285,6 +320,7 @@ async function record(engine, input, result, startedAt, durationMs) {
285
320
  usage: result.usage,
286
321
  workspace: engine.workspace,
287
322
  memory: engine.memory,
323
+ ...(engine.user ? { user: engine.user } : {}),
288
324
  error: result.state.error,
289
325
  });
290
326
  return { run, result, text, durationMs, report, graph };
package/dist/env.d.ts CHANGED
@@ -13,6 +13,12 @@ export interface ProjectEnv {
13
13
  names: string[];
14
14
  /** what the file says, credential paths made absolute */
15
15
  values: Record<string, string>;
16
+ /**
17
+ * Names the file declared and was not allowed to: `ZEN_USER_*` says who a
18
+ * run is acting for, and only the host gets to say that. A project that
19
+ * could set it in a committed file could be anyone it liked.
20
+ */
21
+ refused: string[];
16
22
  }
17
23
  /** The env file a project in `dir` reads: `$ZEN_ENV_FILE` when set, `.env` otherwise. */
18
24
  export declare function projectEnvPath(dir: string): string;
package/dist/env.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isIdentityEnv } from '@zenera/neo';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { isAbsolute, resolve } from 'node:path';
3
4
  import { parseEnv } from 'node:util';
@@ -43,12 +44,16 @@ export function readProjectEnv(dir) {
43
44
  return undefined;
44
45
  }
45
46
  const values = {};
47
+ const refused = [];
46
48
  for (const [name, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
47
- if (value !== undefined) {
49
+ if (isIdentityEnv(name)) {
50
+ refused.push(name);
51
+ }
52
+ else if (value !== undefined) {
48
53
  values[name] = located(dir, name, value);
49
54
  }
50
55
  }
51
- return { path, names: Object.keys(values), values };
56
+ return { path, names: Object.keys(values), values, refused };
52
57
  }
53
58
  /**
54
59
  * Reads the project's env file (`.env`, or `$ZEN_ENV_FILE`) into
package/dist/sandbox.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxMount, type SandboxSpec } from '@zenera/neo';
1
+ import { SandboxPool, type AgentProject, type ProjectConfig, type RunIdentity, type SandboxMount, type SandboxSpec } from '@zenera/neo';
2
2
  import { type Logger } from './errors.ts';
3
3
  import { type ResolvedBuild } from './image.ts';
4
4
  import type { SessionPaths } from './session.ts';
@@ -42,6 +42,11 @@ export interface SandboxInputs {
42
42
  env?: readonly string[];
43
43
  /** `false` overrides every `persist:` in the config, the agents' included */
44
44
  persist?: boolean;
45
+ /**
46
+ * Who the run is acting for. Set on every command as `ZEN_USER_*`, never
47
+ * on the container — see `execEnv` in the library for why.
48
+ */
49
+ identity?: RunIdentity;
45
50
  }
46
51
  export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
47
52
  /**
@@ -59,8 +64,15 @@ export declare function usesSandbox(project: AgentProject): boolean;
59
64
  * attached to it, so the host supplies this; the engine only calls it.
60
65
  */
61
66
  export type Preflight = (setup: SandboxSetup, yes?: boolean) => Promise<void>;
62
- /** Where the container's `$HOME` lives must exist before podman mounts it. */
67
+ /**
68
+ * Where the container's `$HOME` lives must exist before podman mounts it — and
69
+ * the `zenera` libraries go into it, written fresh on every open so they match
70
+ * the engine that set the variables they read.
71
+ */
63
72
  export declare function prepareHome(setup: SandboxSetup): void;
73
+ /** Where each one lands, inside the container. */
74
+ export declare const RUNTIME_PYTHON = "/home/agent/.zenera/python";
75
+ export declare const RUNTIME_NODE = "/home/agent/.node_modules/zenera";
64
76
  /** Best-effort teardown: losing a container must never lose a run. */
65
77
  export declare function teardown(pool: SandboxPool, logger?: Logger): Promise<void>;
66
78
  //# sourceMappingURL=sandbox.d.ts.map
package/dist/sandbox.js CHANGED
@@ -1,6 +1,7 @@
1
- import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
2
- import { mkdirSync } from 'node:fs';
3
- import { basename, join } from 'node:path';
1
+ import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP, SandboxPool, identityEnv, isIdentityEnv, } from '@zenera/neo';
2
+ import { cpSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { basename, join, relative } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { stderrLogger } from "./errors.js";
5
6
  import { resolveBuild } from "./image.js";
6
7
  import { credentials } from "./keys.js";
@@ -107,6 +108,8 @@ export function buildSandbox(opts) {
107
108
  key: opts.session.id,
108
109
  readOnly: opts.readOnly,
109
110
  mounts,
111
+ execEnv: identityEnv(opts.identity),
112
+ execPaths: RUNTIME_PATHS,
110
113
  });
111
114
  return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home, mounts };
112
115
  }
@@ -137,7 +140,7 @@ function secretsOf(secrets, env) {
137
140
  if (!secrets) {
138
141
  return undefined;
139
142
  }
140
- return [...new Set([...secrets, ...(env ?? [])])];
143
+ return [...new Set([...secrets, ...(env ?? [])])].filter((name) => !isIdentityEnv(name));
141
144
  }
142
145
  /**
143
146
  * Config names a variable; this reads it. A name that is not set on this host
@@ -155,7 +158,7 @@ function toSpec(config, extra, secrets) {
155
158
  const env = { ...extra };
156
159
  for (const name of config.env ?? []) {
157
160
  const value = process.env[name];
158
- if (value !== undefined && value !== '') {
161
+ if (value !== undefined && value !== '' && !isIdentityEnv(name)) {
159
162
  env[name] = value;
160
163
  }
161
164
  }
@@ -183,9 +186,45 @@ function toSpec(config, extra, secrets) {
183
186
  export function usesSandbox(project) {
184
187
  return project.agents.some((a) => a.tools.some((t) => t.group === SANDBOX_GROUP));
185
188
  }
186
- /** Where the container's `$HOME` lives must exist before podman mounts it. */
189
+ /**
190
+ * Where the container's `$HOME` lives must exist before podman mounts it — and
191
+ * the `zenera` libraries go into it, written fresh on every open so they match
192
+ * the engine that set the variables they read.
193
+ */
187
194
  export function prepareHome(setup) {
188
195
  mkdirSync(setup.home, { recursive: true });
196
+ installRuntime(setup.home);
197
+ }
198
+ // ---------------------------------------------------------------------------
199
+ // The `zenera` libraries
200
+ //
201
+ // What generated code calls to ask who it is running for. Python and Node,
202
+ // because those are what agents write; anything else reads `ZEN_USER_*`.
203
+ //
204
+ // They live in the home the container already mounts, not in a mount of their
205
+ // own: a new mount would rename every container that already exists, and a
206
+ // persisted one would lose what it had installed. Each language is put where
207
+ // its interpreter looks without being told — `require` searches
208
+ // `$HOME/.node_modules` on its own — except Python, which has no such place
209
+ // that does not depend on the version, so its directory is put in front of
210
+ // `PYTHONPATH` on every command, ahead of whatever the image set.
211
+ // ---------------------------------------------------------------------------
212
+ /** The packaged sources, beside `src/` and `dist/` alike. */
213
+ const RUNTIME_SOURCE = fileURLToPath(new URL('../runtime/', import.meta.url));
214
+ /** Where each one lands, inside the container. */
215
+ export const RUNTIME_PYTHON = `${HOME}/.zenera/python`;
216
+ export const RUNTIME_NODE = `${HOME}/.node_modules/zenera`;
217
+ const RUNTIME_PATHS = { PYTHONPATH: [RUNTIME_PYTHON] };
218
+ function installRuntime(home) {
219
+ const inside = (path) => join(home, relative(HOME, path));
220
+ for (const [from, to] of [
221
+ ['python', RUNTIME_PYTHON],
222
+ ['node', RUNTIME_NODE],
223
+ ]) {
224
+ const target = inside(to);
225
+ rmSync(target, { recursive: true, force: true });
226
+ cpSync(join(RUNTIME_SOURCE, from), target, { recursive: true });
227
+ }
189
228
  }
190
229
  /** Best-effort teardown: losing a container must never lose a run. */
191
230
  export async function teardown(pool, logger = stderrLogger) {
package/dist/session.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type RunIdentity } from '@zenera/neo';
1
2
  export interface SessionPaths {
2
3
  id: string;
3
4
  dir: string;
@@ -94,6 +95,8 @@ export interface RunMeta {
94
95
  usage: unknown;
95
96
  workspace: string;
96
97
  memory?: string;
98
+ /** who the run acted for, when anyone */
99
+ user?: RunIdentity;
97
100
  error?: string;
98
101
  }
99
102
  export declare function writeRunMeta(p: RunPaths, meta: RunMeta): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/engine",
3
- "version": "1.1.47",
3
+ "version": "1.1.49",
4
4
  "description": "Runs a @zenera/neo project on this machine: keyring, sandbox, sessions and the record each turn leaves behind.",
5
5
  "keywords": [
6
6
  "agents",
@@ -33,7 +33,8 @@
33
33
  },
34
34
  "files": [
35
35
  "dist",
36
- "!dist/**/*.map"
36
+ "!dist/**/*.map",
37
+ "runtime"
37
38
  ],
38
39
  "engines": {
39
40
  "node": ">=24"
@@ -44,6 +45,6 @@
44
45
  "prepack": "tsc -b"
45
46
  },
46
47
  "dependencies": {
47
- "@zenera/neo": "^1.1.47"
48
+ "@zenera/neo": "^1.1.49"
48
49
  }
49
50
  }
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ // Who this Zenera run is acting for. The host puts the signed-in user into every
4
+ // command's environment as ZEN_USER_*; this reads them.
5
+ //
6
+ // const { identity, requireIdentity } = require('zenera');
7
+ // const me = identity(); // null when no one is signed in
8
+
9
+ function get(name) {
10
+ const value = (process.env[name] || '').trim();
11
+ return value || undefined;
12
+ }
13
+
14
+ function identity() {
15
+ const id = get('ZEN_USER_ID');
16
+ if (!id) {
17
+ return null;
18
+ }
19
+ return {
20
+ id,
21
+ email: get('ZEN_USER_EMAIL'),
22
+ name: get('ZEN_USER_NAME'),
23
+ role: get('ZEN_USER_ROLE'),
24
+ source: get('ZEN_USER_SOURCE') || 'local',
25
+ };
26
+ }
27
+
28
+ class NoIdentityError extends Error {
29
+ constructor() {
30
+ super(
31
+ 'this run has no signed-in user (ZEN_USER_ID is not set); ' +
32
+ 'outside the Zenera app, start it with: zen run --as <user>',
33
+ );
34
+ this.name = 'NoIdentityError';
35
+ }
36
+ }
37
+
38
+ function requireIdentity() {
39
+ const me = identity();
40
+ if (!me) {
41
+ throw new NoIdentityError();
42
+ }
43
+ return me;
44
+ }
45
+
46
+ module.exports = { identity, requireIdentity, NoIdentityError };
@@ -0,0 +1,17 @@
1
+ /** The signed-in user a Zenera run is acting for. */
2
+ export interface Identity {
3
+ id: string;
4
+ email?: string;
5
+ name?: string;
6
+ role?: string;
7
+ /** `host`: the application authenticated them; `local`: only stated on this machine */
8
+ source: 'host' | 'local' | string;
9
+ }
10
+
11
+ /** The user this run acts for, or `null` when there is none. */
12
+ export function identity(): Identity | null;
13
+
14
+ /** The user this run acts for; throws `NoIdentityError` when there is none. */
15
+ export function requireIdentity(): Identity;
16
+
17
+ export class NoIdentityError extends Error {}
@@ -0,0 +1,15 @@
1
+ // The ES-module face of index.cjs, so there is one implementation.
2
+ //
3
+ // `import 'zenera'` only resolves from a directory with this package in a
4
+ // node_modules above it — ES modules do not search $HOME/.node_modules the way
5
+ // `require` does. Anywhere else, import it by path:
6
+ //
7
+ // import { identity } from '/home/agent/.node_modules/zenera/index.mjs';
8
+ import { createRequire } from 'node:module';
9
+
10
+ const lib = createRequire(import.meta.url)('./index.cjs');
11
+
12
+ export const identity = lib.identity;
13
+ export const requireIdentity = lib.requireIdentity;
14
+ export const NoIdentityError = lib.NoIdentityError;
15
+ export default lib;
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "zenera",
3
+ "version": "1.0.0",
4
+ "description": "Who this Zenera run is acting for. Installed into the sandbox by the engine.",
5
+ "license": "MIT",
6
+ "main": "./index.cjs",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.mjs",
12
+ "require": "./index.cjs"
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,65 @@
1
+ """Who this Zenera run is acting for.
2
+
3
+ The host that started the run - Zenera's app, or ``zen run --as`` - puts the
4
+ signed-in user into every command's environment as ``ZEN_USER_*``. This module
5
+ reads them, so code an agent writes can depend on who is asking::
6
+
7
+ import zenera
8
+
9
+ me = zenera.identity() # None when no one is signed in
10
+ if me:
11
+ print(me.id, me.email, me.role)
12
+
13
+ me = zenera.require_identity() # raises when no one is signed in
14
+
15
+ ``source`` is ``"host"`` when the application authenticated the user and
16
+ ``"local"`` when it was only stated on the machine running the project.
17
+ Standard library only, so it works in any image with a Python 3.
18
+ """
19
+
20
+ import os
21
+ from typing import NamedTuple, Optional
22
+
23
+ __all__ = ["Identity", "NoIdentityError", "identity", "require_identity"]
24
+
25
+
26
+ class Identity(NamedTuple):
27
+ id: str
28
+ email: Optional[str] = None
29
+ name: Optional[str] = None
30
+ role: Optional[str] = None
31
+ source: str = "local"
32
+
33
+
34
+ class NoIdentityError(RuntimeError):
35
+ """Raised by require_identity() when the run has no signed-in user."""
36
+
37
+
38
+ def _get(name: str) -> Optional[str]:
39
+ value = os.environ.get(name, "").strip()
40
+ return value or None
41
+
42
+
43
+ def identity() -> Optional[Identity]:
44
+ """The signed-in user this run acts for, or None when there is none."""
45
+ user_id = _get("ZEN_USER_ID")
46
+ if not user_id:
47
+ return None
48
+ return Identity(
49
+ id=user_id,
50
+ email=_get("ZEN_USER_EMAIL"),
51
+ name=_get("ZEN_USER_NAME"),
52
+ role=_get("ZEN_USER_ROLE"),
53
+ source=_get("ZEN_USER_SOURCE") or "local",
54
+ )
55
+
56
+
57
+ def require_identity() -> Identity:
58
+ """The signed-in user this run acts for; raises NoIdentityError when there is none."""
59
+ me = identity()
60
+ if me is None:
61
+ raise NoIdentityError(
62
+ "this run has no signed-in user (ZEN_USER_ID is not set); "
63
+ "outside the Zenera app, start it with: zen run --as <user>"
64
+ )
65
+ return me