metergraph-cli 0.2.2 → 0.2.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.
Files changed (66) hide show
  1. package/README.md +121 -13
  2. package/assets/skill/SKILL.md +124 -31
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +31 -31
  5. package/assets/skills/metergraph-analyze/SKILL.md +161 -58
  6. package/assets/skills/metergraph-candidates/SKILL.md +50 -8
  7. package/assets/skills/metergraph-evals/SKILL.md +83 -16
  8. package/assets/skills/metergraph-investigate/SKILL.md +90 -34
  9. package/assets/skills/metergraph-iterate/SKILL.md +82 -12
  10. package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
  11. package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
  12. package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
  13. package/assets/skills/metergraph-report/SKILL.md +60 -15
  14. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  15. package/bin/metergraph.js +1 -1
  16. package/native/README.md +85 -0
  17. package/native/anchored-fs.c +243 -0
  18. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  19. package/native/bin/anchored-fs-darwin-x64 +0 -0
  20. package/native/bin/anchored-fs-linux-arm64 +0 -0
  21. package/native/bin/anchored-fs-linux-x64 +0 -0
  22. package/native/build.mjs +55 -0
  23. package/native/manifest.json +9 -0
  24. package/native/targets.mjs +24 -0
  25. package/package.json +10 -3
  26. package/src/action-help.js +130 -0
  27. package/src/agent-key.js +22 -12
  28. package/src/anchored-fs.js +163 -0
  29. package/src/args.js +335 -31
  30. package/src/auth-binding.js +12 -7
  31. package/src/auth-callback.js +10 -2
  32. package/src/auth-login.js +111 -28
  33. package/src/auth-oauth.js +133 -36
  34. package/src/auth-session.js +19 -12
  35. package/src/auth-store.js +28 -7
  36. package/src/cli.js +147 -15
  37. package/src/constants.js +70 -1
  38. package/src/experiments-contract.js +462 -0
  39. package/src/experiments-output.js +190 -0
  40. package/src/experiments.js +406 -0
  41. package/src/output.js +374 -68
  42. package/src/pricing-args.js +163 -0
  43. package/src/pricing-output.js +206 -0
  44. package/src/pricing-reference.js +195 -0
  45. package/src/pricing.js +307 -0
  46. package/src/read-contract.js +64 -10
  47. package/src/read-output.js +66 -13
  48. package/src/read.js +105 -16
  49. package/src/repository-identity.js +2 -2
  50. package/src/setup-deployment.js +7 -0
  51. package/src/setup-env-git.js +1 -1
  52. package/src/setup-env.js +16 -9
  53. package/src/setup-interactive.js +356 -0
  54. package/src/setup-refusal.js +2 -2
  55. package/src/setup-state.js +9 -4
  56. package/src/setup.js +15 -3
  57. package/src/skill-bundle.js +2 -2
  58. package/src/skill-trusted.js +56 -0
  59. package/src/skill.js +28 -9
  60. package/src/style.js +33 -7
  61. package/src/tools-output.js +73 -0
  62. package/src/tools.js +249 -0
  63. package/src/trace-contract.js +1 -1
  64. package/src/transport.js +26 -3
  65. package/src/verify-output.js +2 -2
  66. package/src/verify.js +5 -2
package/src/read.js CHANGED
@@ -2,12 +2,22 @@ import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key
2
2
  import { readBinding, resolveProject } from "./auth-binding.js";
3
3
  import { checkDeployment } from "./doctor.js";
4
4
  import { sessionFor } from "./auth-session.js";
5
- import { Stop, openStore, readCredential, resolveConfigDir } from "./auth-store.js";
6
- import { DEFAULT_ORIGIN, MAX_BODY_BYTES, METADATA_SCOPE, READ_MAX_BYTES, READ_MAX_LIMIT, READ_PATHS } from "./constants.js";
5
+ import { Stop, openStore, readProjectCredential, resolveConfigDir } from "./auth-store.js";
6
+ import {
7
+ DEFAULT_ORIGIN,
8
+ MAX_BODY_BYTES,
9
+ READ_MAX_BYTES,
10
+ READ_MAX_LIMIT,
11
+ READ_PATHS,
12
+ WORKFLOW_DEPLOYMENT_CAPABILITY,
13
+ WORKFLOW_PERMISSIONS,
14
+ } from "./constants.js";
7
15
  import { parseJsonObject } from "./http.js";
8
16
  import {
9
17
  readQuery,
10
18
  groupedRequest,
19
+ workspaceLabel,
20
+ workspacePinned,
11
21
  READ_ENVIRONMENTS,
12
22
  CAPABILITY_NAMES,
13
23
  capabilitySummary,
@@ -19,6 +29,8 @@ import {
19
29
  } from "./read-contract.js";
20
30
  import { compareReports, priorWindow, rankReport } from "./usage-aggregates.js";
21
31
  import { deadline, failureOf, send, trapSignals } from "./transport.js";
32
+ import { experimentNextAction, experimentRead } from "./experiments.js";
33
+ import { readArguments, toolsNextAction, toolsRead } from "./tools.js";
22
34
  import { agentKeyAction, classifyEnvironment, handoffPages } from "./environment.js";
23
35
 
24
36
  // The read commands: status, context, capabilities, usage, routes and traces.
@@ -60,6 +72,11 @@ export async function runRead(options) {
60
72
  healthy: null,
61
73
  profileVerified: null,
62
74
  authenticated: false,
75
+ // The credential's access ("metadata" or "debug") and scopes, once verified.
76
+ access: null,
77
+ scopes: [],
78
+ // The verified workspace's slug and name, for output. Never its ID.
79
+ workspaceLabel: { slug: null, name: null },
63
80
  verifiedWorkspace: null,
64
81
  capabilities: null,
65
82
  };
@@ -81,13 +98,16 @@ export async function runRead(options) {
81
98
  if (holdsKnown(data, known)) {
82
99
  notices.clear();
83
100
  ctx.reason = "credential_in_metadata_response";
101
+ // The workspace's name or slug may be what echoed the credential.
102
+ ctx.workspaceLabel = { slug: null, name: null };
84
103
  return {
85
104
  outcome: "verification_failed",
86
105
  reason: "credential_in_metadata_response",
87
106
  data: build("verification_failed", null),
107
+ validateOutput: (text) => !outputHoldsKnown(text, known),
88
108
  };
89
109
  }
90
- return { outcome, reason, data };
110
+ return { outcome, reason, data, validateOutput: (text) => !outputHoldsKnown(text, known) };
91
111
  };
92
112
  // A deadline or Ctrl+C reported by a lower layer as a timeout or
93
113
  // cancellation is reported here by what actually happened.
@@ -101,6 +121,11 @@ export async function runRead(options) {
101
121
  };
102
122
 
103
123
  try {
124
+ if (options.command === "tools" && options.action === "call") {
125
+ const args = readArguments(options.tool);
126
+ if (!args.ok) return end(args.outcome, args.reason);
127
+ options = { ...options, toolArguments: args };
128
+ }
104
129
  const root = resolveProject(options.project);
105
130
  const configDir = resolveConfigDir(options.configDir);
106
131
  const found = readBinding(root);
@@ -115,7 +140,7 @@ export async function runRead(options) {
115
140
  if (binding !== null) {
116
141
  try {
117
142
  store = openStore(configDir, { create: false });
118
- const record = store === null ? null : readCredential(store, binding.credential_slot);
143
+ const record = store === null ? null : readProjectCredential(store, binding.credential_slot, root);
119
144
  grantSaved = record !== null;
120
145
  originConfirmed = record !== null && record.origin === binding.origin;
121
146
  } catch (error) {
@@ -178,18 +203,26 @@ export async function runRead(options) {
178
203
  }
179
204
 
180
205
  const session = useKey
181
- ? await keySession({ target, source, env, signal: limit.signal, verifiedProfile })
182
- : await sessionFor(binding, store, limit.signal);
206
+ ? await keySession({ target, source, env, signal: limit.signal, verifiedProfile, access: "read" })
207
+ : await sessionFor(binding, store, limit.signal, root);
183
208
  if (!session.ok) {
184
209
  const settled = settle(session);
185
210
  return end(settled.outcome, settled.reason);
186
211
  }
187
212
  ctx.profile = session.session.profile;
213
+ ctx.access = session.session.access;
214
+ ctx.scopes = session.session.scopes;
215
+ known = session.knownCredentials;
216
+ ctx.workspaceLabel = workspaceLabel(session.documents.workspace?.workspace);
217
+ // A workspace named by slug or name is checked against the verified one.
218
+ if (target.workspaceText !== null && !workspacePinned(session.documents.workspace?.workspace, target.workspaceText)) {
219
+ return end("verification_failed", "workspace_context_mismatch");
220
+ }
188
221
  if (ctx.workspaceId === null) {
189
222
  ctx.workspaceId = session.session.workspaceId;
190
- notices.add("workspace_not_pinned");
223
+ if (target.workspaceText === null) notices.add("workspace_not_pinned");
224
+ else ctx.intendedWorkspace = ctx.workspaceId;
191
225
  }
192
- known = session.knownCredentials;
193
226
  ctx.authenticated = true;
194
227
  const { documents } = session;
195
228
 
@@ -211,6 +244,25 @@ export async function runRead(options) {
211
244
  return end("ok", null, context.value);
212
245
  }
213
246
  if (options.command === "capabilities") return end("ok", null, capabilities.value);
247
+ if (options.command === "tools") {
248
+ const tools = await toolsRead(session.session, options, limit.signal);
249
+ if (!tools.ok) {
250
+ if (ctx.credential === "agent_key" && tools.reason === "access_revoked") tools.reason = "agent_key_rejected";
251
+ const settled = ["refused", "unconfirmed", "action_required"].includes(tools.outcome) ? tools : settle(tools);
252
+ return end(settled.outcome, settled.reason, tools.report);
253
+ }
254
+ return end("ok", null, tools.value);
255
+ }
256
+ if (options.command === "experiments") {
257
+ const read = await experimentRead(session.session, options, ctx, documents, limit.signal, notices);
258
+ if (!read.ok) {
259
+ const settled = read.outcome === "refused" ? read : settle(read);
260
+ const report = read.refusal ? { refusal: read.refusal }
261
+ : read.retryAfter !== undefined ? { retry_after_seconds: read.retryAfter } : null;
262
+ return end(settled.outcome, settled.reason, report);
263
+ }
264
+ return end("ok", null, read.value);
265
+ }
214
266
 
215
267
  const read = grouped ? GROUPED_USAGE : READS[options.command];
216
268
  const entry = capabilities.value.agent[read.capability];
@@ -328,7 +380,7 @@ const INSUFFICIENT_SCOPE = /(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?:$|[\
328
380
  // True when any string in value (keys included) contains one of the known
329
381
  // credential values. An exact containment check against values this run
330
382
  // holds, not a guess at what looks secret.
331
- function holdsKnown(value, known) {
383
+ export function holdsKnown(value, known) {
332
384
  if (known.length === 0) return false;
333
385
  if (typeof value === "string") return known.some((secret) => value.includes(secret));
334
386
  if (value === null || typeof value !== "object") return false;
@@ -340,7 +392,7 @@ function holdsKnown(value, known) {
340
392
 
341
393
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
342
394
 
343
- function retryAfterOf(response) {
395
+ export function retryAfterOf(response) {
344
396
  const header = response.headers["retry-after"];
345
397
  const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
346
398
  return seconds !== null && seconds <= 86400 ? seconds : null;
@@ -394,15 +446,18 @@ async function fetchRead(session, path, query, signal) {
394
446
  function readData(ctx, report, notices, outcome, options, extra = {}) {
395
447
  return {
396
448
  origin: ctx.origin,
397
- workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
449
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId, ...ctx.workspaceLabel },
398
450
  deployment_profile: ctx.profile,
399
451
  environment: ctx.environment.kind,
400
452
  credential: ctx.credential,
401
453
  authenticated: ctx.authenticated,
402
- scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
454
+ scopes: ctx.authenticated ? ctx.scopes : [],
403
455
  result: outcome === "ok" ? report : null,
404
456
  retry_after_seconds: outcome === "rate_limited" || report?.retry_after_seconds !== undefined
405
457
  ? report?.retry_after_seconds ?? null : null,
458
+ ...(options.command === "experiments" ? { refusal: outcome === "refused" ? report?.refusal ?? null : null } : {}),
459
+ // A tool's own error document, as the service returned it.
460
+ ...(options.command === "tools" ? { tool_error: outcome === "refused" ? report?.tool_error ?? null : null } : {}),
406
461
  notices: [...notices].sort(),
407
462
  next_action: nextAction(outcome, ctx, options, extra),
408
463
  };
@@ -434,17 +489,38 @@ function statusData(ctx, notices, outcome, options) {
434
489
  ctx.verifiedWorkspace === null || ctx.intendedWorkspace === null
435
490
  ? null
436
491
  : ctx.verifiedWorkspace === ctx.intendedWorkspace,
492
+ // The verified workspace's slug and name, which output shows instead
493
+ // of either ID.
494
+ ...ctx.workspaceLabel,
437
495
  },
438
- scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
496
+ scopes: ctx.authenticated ? ctx.scopes : [],
439
497
  capabilities:
440
498
  agent === null ? null : Object.fromEntries(CAPABILITY_NAMES.map((name) => [name, agent[name]?.available ?? null])),
441
- content_access: false,
499
+ // Whether this credential may read content through Agent Access. The
500
+ // read commands never do, whatever it allows.
501
+ content_access: agent !== null && Object.values(agent).some((entry) => entry?.available === true && entry.content === true),
502
+ access: accessFor(ctx),
442
503
  application_traffic_verified: false,
443
504
  notices: [...notices].sort(),
444
505
  next_action: nextAction(outcome, ctx, options),
445
506
  };
446
507
  }
447
508
 
509
+ // The credential's role and workflow permissions, by the names the Keys page
510
+ // uses. Either role reads. Writing and testing evaluations and starting
511
+ // analyses need Debug with all three permissions, on a deployment that offers
512
+ // managed evaluations: permissions_offered is false where it does not, and
513
+ // null when the capabilities were not read.
514
+ export function accessFor(ctx) {
515
+ if (!ctx.authenticated) return null;
516
+ const deployment = ctx.capabilities?.deployment_capabilities ?? null;
517
+ return {
518
+ role: ctx.access,
519
+ permissions: Object.fromEntries(WORKFLOW_PERMISSIONS.map(({ field, scope }) => [field, ctx.scopes.includes(scope)])),
520
+ permissions_offered: deployment === null ? null : deployment.includes(WORKFLOW_DEPLOYMENT_CAPABILITY),
521
+ };
522
+ }
523
+
448
524
  const NARROW =
449
525
  "Narrow the read: a shorter --days or --since/--until window, exact --route, --provider or --model filters, " +
450
526
  "fewer --group-by dimensions or a lower --limit.";
@@ -460,6 +536,10 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
460
536
  const reason = ctx.reason;
461
537
  const capabilities = ctx.capabilities;
462
538
  if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
539
+ const experiment = experimentNextAction(outcome, ctx, options, report);
540
+ if (experiment !== undefined) return experiment;
541
+ const tool = toolsNextAction(ctx, options);
542
+ if (tool !== undefined) return tool;
463
543
  if (reason === "usage_aggregates_unavailable" || (grouped && reason === "endpoint_unavailable")) {
464
544
  // Offer the route-by-day read only when this grant actually has it.
465
545
  if (capabilities?.agent?.usage?.available !== true) return null;
@@ -513,8 +593,8 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
513
593
  kind: "use_metadata_only_key",
514
594
  url: handoffPages(origin).keys_url,
515
595
  message:
516
- "This key grants more than Metadata access, so the CLI does not use it. Ask the person for a " +
517
- "coding-agent key limited to Metadata access.",
596
+ "This key has Replay access or another scope the CLI does not use. Ask the person for a coding-agent " +
597
+ "key with the Metadata or Debug role, and to replace the secret.",
518
598
  };
519
599
  }
520
600
  if (keyed && reason === "workspace_context_mismatch") {
@@ -561,3 +641,12 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
561
641
  }
562
642
  return null;
563
643
  }
644
+
645
+ // The final renderer may join independent service fields or insert styling.
646
+ // Check exact emitted bytes and their terminal-visible equivalent.
647
+ export function outputHoldsKnown(text, known) {
648
+ const visible = (value) => value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
649
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
650
+ .replace(/[\x00-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").normalize("NFKC");
651
+ return holdsKnown(text, known) || known.some((secret) => visible(text).includes(visible(secret)));
652
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
2
+ import fs, { withRollback } from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  // Repository identity for the SDKs: `.metergraph/config.json` with
@@ -157,7 +157,7 @@ function writeNewConfig(root, repository) {
157
157
  try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
158
158
  fs.linkSync(temp, dest);
159
159
  } finally {
160
- try { fs.unlinkSync(temp); } catch { /* already gone */ }
160
+ try { withRollback(() => fs.unlinkSync(temp)); } catch { /* already gone */ }
161
161
  }
162
162
  }
163
163
 
@@ -64,6 +64,13 @@ const ACTION_GUIDE = Object.freeze({
64
64
  retry: "Rerun the same command.",
65
65
  });
66
66
 
67
+ // Each prerequisite a non-hosted route asserts, with its guidance, for a
68
+ // person confirming them at a terminal.
69
+ export function prerequisitesFor(deployment) {
70
+ const profile = EXPECTED_PROFILE[deployment];
71
+ return (PREREQUISITES[deployment] ?? []).map((name) => ({ name, guide: prerequisiteGuide(name, profile) }));
72
+ }
73
+
67
74
  export function prerequisiteGuide(name, profile) {
68
75
  if (name === "metadata_agent_credential") return METADATA_CREDENTIAL_GUIDE[profile] ?? null;
69
76
  return PREREQUISITE_GUIDE[name] ?? null;
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
2
+ import fs from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  import { Stop } from "./auth-store.js";
package/src/setup-env.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import fs from "node:fs";
2
+ import fs, { validatePath, withRollback } from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  import { resolveProject } from "./auth-binding.js";
@@ -62,6 +62,8 @@ const PLANS = new WeakMap();
62
62
  const HANDLES = new WeakMap();
63
63
 
64
64
  export const ENV_MESSAGES = Object.freeze({
65
+ secure_writes_unavailable:
66
+ "Secure automatic file writes are unavailable on this platform or the bundled filesystem helper failed verification. Nothing was written.",
65
67
  invalid_project: "--project must name an existing directory.",
66
68
  env_path_invalid:
67
69
  "The env file path is not allowed. Use a project-relative path with forward slashes to a file named .env, .env.<name> or <name>.env.",
@@ -193,12 +195,12 @@ export async function rollbackEnv(handle, { signal = null, waitMs = DEFAULT_WAIT
193
195
  if (state === undefined) throw new Stop("internal_error", "invalid_plan");
194
196
  checkCancel(signal);
195
197
  if (state.journal.length > 0) {
196
- const lock = await takeLock(state.lockDir, waitMs, signal);
198
+ const lock = await takeLock(state.lockDir, waitMs, signal, true);
197
199
  try {
198
200
  checkCancel(signal);
199
201
  deferSignals(() => {
200
202
  try {
201
- undo(state.journal);
203
+ withRollback(() => undo(state.journal));
202
204
  } catch (error) {
203
205
  if (error instanceof Stop && error.outcome === "conflict") throw new Stop("conflict", "rollback_conflict");
204
206
  throw new Stop("filesystem_error", "rollback_incomplete");
@@ -352,6 +354,8 @@ function apply(state, updates) {
352
354
  }
353
355
  }
354
356
 
357
+ validatePath(target.dir);
358
+ if (ignore !== null) validatePath(state.root);
355
359
  const receipt = freeze({
356
360
  path: target.rel,
357
361
  location: target.location,
@@ -363,7 +367,7 @@ function apply(state, updates) {
363
367
  return { receipt, journal };
364
368
  } catch (error) {
365
369
  try {
366
- undo(journal);
370
+ withRollback(() => undo(journal));
367
371
  } catch {
368
372
  throw new Stop("filesystem_error", "rollback_incomplete");
369
373
  }
@@ -575,7 +579,7 @@ function replaceFile(file, content, { before, mode, protect, reasons, changed })
575
579
  } catch (error) {
576
580
  if (created) {
577
581
  try {
578
- fs.unlinkSync(temp);
582
+ withRollback(() => fs.unlinkSync(temp));
579
583
  } catch {
580
584
  // Reported by the caller's failure; nothing else to do.
581
585
  }
@@ -602,7 +606,7 @@ function syncDir(dir) {
602
606
  // An exclusive lock file in the project (or, outside a project, in the env
603
607
  // file's directory). A held lock is waited on for at most waitMs, then
604
608
  // reported; it is never taken over or removed, even if it looks stale.
605
- async function takeLock(dir, waitMs, signal) {
609
+ async function takeLock(dir, waitMs, signal, rollback = false) {
606
610
  const file = path.join(dir, LOCK);
607
611
  const wait = Number.isFinite(waitMs) ? Math.min(Math.max(waitMs, 0), MAX_WAIT_MS) : DEFAULT_WAIT_MS;
608
612
  const until = Date.now() + wait;
@@ -610,7 +614,7 @@ async function takeLock(dir, waitMs, signal) {
610
614
  checkCancel(signal);
611
615
  let fd;
612
616
  try {
613
- fd = fs.openSync(file, "wx", 0o600);
617
+ fd = rollback ? withRollback(() => fs.openSync(file, "wx", 0o600)) : fs.openSync(file, "wx", 0o600);
614
618
  } catch (error) {
615
619
  if (error.code !== "EEXIST") throw fixed(error, "env_write_failed");
616
620
  if (Date.now() >= until) throw new Stop("conflict", "env_locked");
@@ -629,8 +633,10 @@ async function takeLock(dir, waitMs, signal) {
629
633
  // Removes the lock only if it is still the file this process created.
630
634
  function dropLock(lock) {
631
635
  try {
632
- const stat = fs.lstatSync(lock.file);
633
- if (IS_WINDOWS || (stat.dev === lock.dev && stat.ino === lock.ino)) fs.unlinkSync(lock.file);
636
+ withRollback(() => {
637
+ const stat = fs.lstatSync(lock.file);
638
+ if (stat.dev === lock.dev && stat.ino === lock.ino) fs.unlinkSync(lock.file);
639
+ });
634
640
  } catch {
635
641
  // Reported by the next run as a held lock.
636
642
  }
@@ -670,6 +676,7 @@ function deferSignals(work) {
670
676
  // error, which can hold a path, is never passed on.
671
677
  function fixed(error, reason) {
672
678
  if (error instanceof Stop) return error;
679
+ if (error?.code === "ENOTSUP") return new Stop("unsupported", "secure_writes_unavailable");
673
680
  if (DENIED.has(error?.code)) {
674
681
  return new Stop("filesystem_error", reason === "env_read_failed" ? "env_read_denied" : "env_write_denied");
675
682
  }