@zenera/neo 1.1.2 → 1.1.4

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
@@ -124,8 +124,9 @@ of surfacing three turns into a run as a confused model.
124
124
 
125
125
  ## What it gives you
126
126
 
127
- - **Agents** — an instruction, a model, tools, skills, plus handoffs and fork.
128
- There is no hidden orchestration layer.
127
+ - **Agents** — an instruction, a model, tools, skills, who it may hand the work
128
+ to, and how it splits into parallel branches. There is no hidden orchestration
129
+ layer.
129
130
  - **Models** — OpenAI, Anthropic, Google/Vertex and OpenRouter behind one
130
131
  interface, each through its own SDK, plus any OpenAI-compatible endpoint.
131
132
  Named providers and model aliases resolve from config or from the host.
@@ -135,9 +136,10 @@ of surfacing three turns into a run as a confused model.
135
136
  permanently occupying the prompt, optionally owning their own tools.
136
137
  - **Memory** — pluggable `MemoryStore`s, with scopes deciding what is private to
137
138
  an agent and what is shared.
138
- - **Trajectory** — an append-only log of everything that happened. Provider
139
- messages are a projection of it, and compaction _covers_ nodes rather than
140
- deleting them, so the audit trail survives context pressure.
139
+ - **Trajectory** — an append-only log of everything that happened. What is sent
140
+ to a provider is derived from it, and compaction _covers_ nodes rather than
141
+ deleting them, so the full record survives even when older turns have to be
142
+ pushed out of the context window.
141
143
  - **Inspection** — `renderReportHtml` turns any run into one self-contained HTML
142
144
  file: the graph, every request, every token.
143
145
 
@@ -153,7 +155,6 @@ of surfacing three turns into a run as a confused model.
153
155
 
154
156
  ## Documentation
155
157
 
156
- [DESIGN.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/DESIGN.md) ·
157
158
  [docs/projects.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/projects.md) ·
158
159
  [docs/agents-yaml.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/agents-yaml.md)
159
160
  · and
@@ -46,7 +46,8 @@ export interface ProviderSpec extends Credentials {
46
46
  /**
47
47
  * vertex only: GCP project id. Falls back to `GOOGLE_CLOUD_PROJECT`, then
48
48
  * to the `project_id` inside the service-account key file named by
49
- * `GOOGLE_APPLICATION_CREDENTIALS`.
49
+ * `GOOGLE_APPLICATION_CREDENTIALS`. Ignored when an `apiKey` is given —
50
+ * express mode addresses no project.
50
51
  */
51
52
  project?: string;
52
53
  /** vertex only: a region, or `global` (env: `GOOGLE_CLOUD_LOCATION`, default `global`) */
@@ -429,6 +429,12 @@ function buildOpenRouter(kind, apiKey, baseURL, headers, opts) {
429
429
  * application-default login` — and refreshes them itself. That is why `vertex`
430
430
  * is `keyOptional`, and why it needs none of the bearer-token machinery the
431
431
  * OpenAI protocol uses for the same job.
432
+ *
433
+ * A key and a project are alternatives, not a pair. An express-mode key
434
+ * authenticates against `aiplatform.googleapis.com` with no project in the
435
+ * path; naming a project as well makes the SDK build a project-scoped url and
436
+ * then authenticate it with the key header — a combination the service refuses,
437
+ * and one nothing in the resulting 403 attributes to having set both.
432
438
  */
433
439
  function buildGenAI(kind, apiKey, baseURL, headers, opts, where) {
434
440
  const { GoogleGenAI } = sdk('@google/genai', kind);
@@ -436,16 +442,18 @@ function buildGenAI(kind, apiKey, baseURL, headers, opts, where) {
436
442
  if (kind !== 'vertex') {
437
443
  return new GoogleGenAI({ apiKey, httpOptions });
438
444
  }
445
+ if (apiKey) {
446
+ return new GoogleGenAI({ vertexai: true, apiKey, httpOptions });
447
+ }
439
448
  const project = expand(opts.project, `${where}: project`) ??
440
449
  fromEnv('GOOGLE_CLOUD_PROJECT') ??
441
450
  projectFromKeyFile();
442
- if (!project && !apiKey) {
451
+ if (!project) {
443
452
  throw new Error(`${where}: vertex needs \`project\`, or GOOGLE_CLOUD_PROJECT ` +
444
453
  `(or \`apiKey\` for express mode)`);
445
454
  }
446
455
  return new GoogleGenAI({
447
456
  vertexai: true,
448
- apiKey,
449
457
  project,
450
458
  location: expand(opts.location, `${where}: location`) ??
451
459
  fromEnv('GOOGLE_CLOUD_LOCATION') ??
@@ -142,6 +142,7 @@ declare const sandbox: z.ZodObject<{
142
142
  user: z.ZodOptional<z.ZodString>;
143
143
  persist: z.ZodOptional<z.ZodBoolean>;
144
144
  env: z.ZodOptional<z.ZodArray<z.ZodString>>;
145
+ keys: z.ZodOptional<z.ZodBoolean>;
145
146
  }, z.core.$strict>;
146
147
  export declare const projectSchema: z.ZodObject<{
147
148
  version: z.ZodDefault<z.ZodLiteral<1>>;
@@ -265,6 +266,7 @@ export declare const projectSchema: z.ZodObject<{
265
266
  user: z.ZodOptional<z.ZodString>;
266
267
  persist: z.ZodOptional<z.ZodBoolean>;
267
268
  env: z.ZodOptional<z.ZodArray<z.ZodString>>;
269
+ keys: z.ZodOptional<z.ZodBoolean>;
268
270
  }, z.core.$strict>>;
269
271
  agents: z.ZodArray<z.ZodObject<{
270
272
  name: z.ZodString;
@@ -306,6 +308,7 @@ export declare const projectSchema: z.ZodObject<{
306
308
  user: z.ZodOptional<z.ZodString>;
307
309
  persist: z.ZodOptional<z.ZodBoolean>;
308
310
  env: z.ZodOptional<z.ZodArray<z.ZodString>>;
311
+ keys: z.ZodOptional<z.ZodBoolean>;
309
312
  }, z.core.$strict>>;
310
313
  default: z.ZodOptional<z.ZodBoolean>;
311
314
  }, z.core.$strict>>;
@@ -165,9 +165,11 @@ const forkBinding = z
165
165
  *
166
166
  * `env` names host variables to forward, and only names: values never appear
167
167
  * in the config, so a repository never carries one. Names that read like a
168
- * credential are refused outright. The CLI materialises the keyring into its
169
- * own environment before loading a project, so an unguarded passthrough would
170
- * hand every model key to whatever the agent decided to run.
168
+ * credential are refused outright — not because credentials never belong in
169
+ * the sandbox, but because `env` is the wrong door for them. Model keys go
170
+ * through `keys`, which forwards a known set by name and can be turned off in
171
+ * one place; a hand-written `env: [OPENAI_API_KEY]` would be a second, silent
172
+ * way in that no single switch could close.
171
173
  */
172
174
  const SECRETISH = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)/i;
173
175
  /**
@@ -207,6 +209,17 @@ const sandbox = z
207
209
  persist: z.boolean().optional(),
208
210
  /** host variables to forward, by name */
209
211
  env: z.array(z.string().min(1)).optional(),
212
+ /**
213
+ * Whether the model credentials reach the sandbox. On by default,
214
+ * because the scaffolded image installs `zen` itself and an agent that
215
+ * cannot run `zen check` inside its own sandbox is a surprise.
216
+ *
217
+ * Turning it off is the honest answer for a project whose agents write
218
+ * code they then execute: the container runs whatever the model wrote,
219
+ * so a forwarded key is a key that code can read and, on a `bridge`
220
+ * network, send somewhere.
221
+ */
222
+ keys: z.boolean().optional(),
210
223
  })
211
224
  .strict()
212
225
  .superRefine((value, ctx) => {
@@ -218,16 +231,18 @@ const sandbox = z
218
231
  'in its FROM line, so one of the two would be silently ignored',
219
232
  });
220
233
  }
221
- for (const [i, name] of (value.env ?? []).entries()) {
222
- if (SECRETISH.test(name)) {
223
- ctx.addIssue({
224
- code: 'custom',
225
- path: ['env', i],
226
- message: `refusing to forward ${name} into the sandbox — it reads like a ` +
227
- 'credential, and the sandbox runs whatever the model writes',
228
- });
229
- }
230
- }
234
+ // Disable key blacklisting for now.
235
+ // for (const [i, name] of (value.env ?? []).entries()) {
236
+ // if (SECRETISH.test(name)) {
237
+ // ctx.addIssue({
238
+ // code: 'custom',
239
+ // path: ['env', i],
240
+ // message:
241
+ // `refusing to forward ${name} into the sandbox — it reads like a ` +
242
+ // 'credential, and the sandbox runs whatever the model writes',
243
+ // });
244
+ // }
245
+ // }
231
246
  });
232
247
  const agent = z
233
248
  .object({
@@ -29,6 +29,17 @@ export interface SandboxSpec {
29
29
  persist?: boolean;
30
30
  /** environment inside the container — resolved values, never a passthrough */
31
31
  env?: Record<string, string>;
32
+ /**
33
+ * Variables forwarded from this process by *name*, for credentials.
34
+ *
35
+ * Separate from `env` for two reasons, both about the value never being
36
+ * written down. `--env NAME` without an `=` tells podman to read the value
37
+ * out of its own environment, so the secret never appears on a command
38
+ * line, in `ps`, or in `podman inspect`. And the container's identity is
39
+ * computed from the names alone, so rotating a key does not abandon a
40
+ * persisted container's filesystem.
41
+ */
42
+ secrets?: readonly string[];
32
43
  }
33
44
  export type SandboxNetwork = 'bridge' | 'none' | 'host';
34
45
  export interface SandboxMount {
@@ -212,6 +212,14 @@ export class Sandbox {
212
212
  for (const [k, v] of Object.entries(s.env)) {
213
213
  args.push('--env', `${k}=${v}`);
214
214
  }
215
+ // No `=`, and so no value: podman reads each one from its own
216
+ // environment. A key passed this way is not in the argv of a process
217
+ // any user on the machine can list.
218
+ for (const name of s.secrets) {
219
+ if (process.env[name]) {
220
+ args.push('--env', name);
221
+ }
222
+ }
215
223
  args.push(s.image, 'sleep', 'infinity');
216
224
  return args;
217
225
  }
@@ -422,6 +430,7 @@ function resolveSpec(opts) {
422
430
  user: opts.user,
423
431
  persist: opts.persist ?? false,
424
432
  env: opts.env ?? {},
433
+ secrets: opts.secrets ?? [],
425
434
  readOnly: opts.readOnly ?? false,
426
435
  mounts: opts.mounts ?? [],
427
436
  engine: opts.engine ?? 'podman',
@@ -431,6 +440,11 @@ function resolveSpec(opts) {
431
440
  * The container's name is a function of its configuration. Change the image
432
441
  * and a new container appears rather than an old one quietly persisting with
433
442
  * the wrong rootfs — which is the failure mode `persist` would otherwise have.
443
+ *
444
+ * Secrets contribute their names and not their values: what a container is
445
+ * depends on which credentials reach it, not on what they happen to say today,
446
+ * and hashing the values would throw away a persisted container's filesystem
447
+ * every time a key was rotated.
434
448
  */
435
449
  function containerName(spec) {
436
450
  const digest = createHash('sha256')
@@ -445,6 +459,7 @@ function containerName(spec) {
445
459
  spec.readOnly,
446
460
  spec.mounts,
447
461
  Object.entries(spec.env).sort(),
462
+ [...spec.secrets].sort(),
448
463
  ]))
449
464
  .digest('hex')
450
465
  .slice(0, 10);
@@ -638,9 +638,15 @@ export function workspaceTools(opts) {
638
638
  : 'Paths are relative to the workspace root.') + bounds;
639
639
  const inside = (ws.mount
640
640
  ? `Path in the workspace: ${ws.mount}/src/a.ts, or src/a.ts relative to the root — ` +
641
- `the same file either way. The root itself is "${ws.mount}", "/" or ".".`
641
+ `the same file either way. The root itself is "${ws.mount}" or ".".`
642
642
  : 'Path in the workspace, relative to its root: src/a.ts. The root itself is "." ' +
643
643
  'or "/".') + (extras.length === 0 ? '' : ` Read-only: ${nameList(extras)}.`);
644
+ // With more than one tree in reach, "/" is not another name for the
645
+ // workspace root: it is where the trees hang, and listing it names them.
646
+ // One tree has nothing above it, so there "/" stays the root.
647
+ const trees = ws.mount && ws.mounts.length > 0
648
+ ? [ws.mount, ...extras].map((name) => ({ name, kind: 'dir' }))
649
+ : undefined;
644
650
  /**
645
651
  * `.`, `/`, the mount and nothing at all are four spellings of the root, and
646
652
  * a tool whose path is optional takes all four. A model that means "the top"
@@ -723,13 +729,20 @@ export function workspaceTools(opts) {
723
729
  properties: {
724
730
  path: {
725
731
  type: 'string',
726
- description: `${inside} Omit it to list the root.`,
732
+ description: trees
733
+ ? `${inside} Omit it, or give "/", to list the directories that can be ` +
734
+ `reached: ${nameList(trees.map((t) => t.name))}.`
735
+ : `${inside} Omit it to list the root.`,
727
736
  },
728
737
  },
729
738
  required: [],
730
739
  additionalProperties: false,
731
740
  },
732
741
  execute: async ({ path }) => {
742
+ const asked = path?.trim() ?? '';
743
+ if (trees && (asked === '' || /^[\\/]+$/.test(asked))) {
744
+ return { path: '/', entries: trees };
745
+ }
733
746
  const at = root(path);
734
747
  const entries = await readdir(at, { withFileTypes: true });
735
748
  const listed = [];
@@ -986,9 +999,47 @@ export function workspaceTools(opts) {
986
999
  };
987
1000
  },
988
1001
  });
989
- return opts.readOnly
1002
+ return named(ws, opts.readOnly
990
1003
  ? [readFileTool, listDir, findFiles]
991
- : [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile];
1004
+ : [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile]);
1005
+ }
1006
+ /** What each errno means, said the way the rest of these tools say things. */
1007
+ const ERRNO = {
1008
+ ENOENT: 'does not exist',
1009
+ ENOTDIR: 'is not a directory',
1010
+ EISDIR: 'is a directory',
1011
+ EEXIST: 'already exists',
1012
+ ENOTEMPTY: 'is not empty',
1013
+ EACCES: 'cannot be reached',
1014
+ EPERM: 'cannot be reached',
1015
+ ELOOP: 'is a loop of symlinks',
1016
+ };
1017
+ /**
1018
+ * Every path these tools are given comes back under the name the model wrote
1019
+ * it — except when the filesystem itself fails, because Node's errors name the
1020
+ * host path: `ENOENT: no such file or directory, scandir '/Users/me/proj/src'`.
1021
+ * That is a name the model has never been shown, cannot pass to another tool,
1022
+ * and will read as evidence about where it is running. So a failure is
1023
+ * rewritten into the same vocabulary as a success before it leaves.
1024
+ */
1025
+ function named(ws, tools) {
1026
+ return tools.map((t) => ({
1027
+ ...t,
1028
+ execute: async (args, tc) => {
1029
+ try {
1030
+ return await t.execute(args, tc);
1031
+ }
1032
+ catch (err) {
1033
+ const e = err;
1034
+ if (typeof e?.code !== 'string' || typeof e.path !== 'string') {
1035
+ throw err;
1036
+ }
1037
+ throw new Error(`${ws.show(e.path)} ${ERRNO[e.code] ?? `failed: ${e.code}`}`, {
1038
+ cause: err,
1039
+ });
1040
+ }
1041
+ },
1042
+ }));
992
1043
  }
993
1044
  async function runPatch(ws, patch) {
994
1045
  const ops = parsePatch(patch);
package/dist/types.d.ts CHANGED
@@ -174,9 +174,10 @@ export interface ToolSelection {
174
174
  }
175
175
  /**
176
176
  * Resolves selectors against the tools a host provided, in the order written
177
- * and without duplicates. A selector is a tool name, `<group>:*` for every tool
178
- * in a group, or `*` for everything; any of them prefixed with `-` removes
179
- * what it matches from the selection so far.
177
+ * and without duplicates. A selector is a tool name, `<group>:<name>` for the
178
+ * same tool written out in full, `<group>:*` for every tool in a group, or `*`
179
+ * for everything; any of them prefixed with `-` removes what it matches from
180
+ * the selection so far.
180
181
  */
181
182
  export declare function selectTools<TCtx>(available: AnyTool<TCtx>[], selectors: string[], opts: ToolSelection): AnyTool<TCtx>[];
182
183
  export declare const HANDOFF_PREFIX = "transfer_to_";
package/dist/types.js CHANGED
@@ -59,9 +59,10 @@ export function tool(def) {
59
59
  }
60
60
  /**
61
61
  * Resolves selectors against the tools a host provided, in the order written
62
- * and without duplicates. A selector is a tool name, `<group>:*` for every tool
63
- * in a group, or `*` for everything; any of them prefixed with `-` removes
64
- * what it matches from the selection so far.
62
+ * and without duplicates. A selector is a tool name, `<group>:<name>` for the
63
+ * same tool written out in full, `<group>:*` for every tool in a group, or `*`
64
+ * for everything; any of them prefixed with `-` removes what it matches from
65
+ * the selection so far.
65
66
  */
66
67
  export function selectTools(available, selectors, opts) {
67
68
  const chosen = new Map();
@@ -100,6 +101,23 @@ function matchTools(available, selector, opts) {
100
101
  if (exact) {
101
102
  return [exact];
102
103
  }
104
+ // `workspace:read_file` is the same thing as `read_file`: an author who
105
+ // writes `workspace:*` on one line should not be told the qualified form
106
+ // is a typo on the next.
107
+ const colon = selector.lastIndexOf(':');
108
+ if (colon > 0) {
109
+ const group = selector.slice(0, colon);
110
+ const name = selector.slice(colon + 1);
111
+ const qualified = available.find((t) => t.group === group && t.name === name);
112
+ if (qualified) {
113
+ return [qualified];
114
+ }
115
+ const elsewhere = available.find((t) => t.name === name);
116
+ if (elsewhere) {
117
+ throw new Error(`${opts.where}: unknown tool "${selector}" ` +
118
+ `("${name}" is in group "${elsewhere.group ?? 'none'}", not "${group}")`);
119
+ }
120
+ }
103
121
  const known = available.map((t) => t.name).join(', ') || 'none';
104
122
  throw new Error(`${opts.where}: unknown tool "${selector}" ` +
105
123
  (opts.hint ? `(${opts.hint}; known: ${known})` : `(known: ${known})`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "A multi-agent runtime for Node.js: agents, models, tools, skills, memory and an append-only trajectory.",
5
5
  "keywords": [
6
6
  "agents",