@folsom/fuse 0.37.1 → 0.42.0

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/dist/index.js CHANGED
@@ -6,7 +6,9 @@ var ErrorCode = {
6
6
  InvalidArgument: "invalid_argument",
7
7
  Unavailable: "unavailable",
8
8
  Internal: "internal",
9
- Unauthorized: "unauthorized"
9
+ Unauthorized: "unauthorized",
10
+ Forbidden: "forbidden",
11
+ Locked: "locked"
10
12
  };
11
13
  var STATUS_TEXT = {
12
14
  400: "bad request",
@@ -14,6 +16,7 @@ var STATUS_TEXT = {
14
16
  403: "forbidden",
15
17
  404: "not found",
16
18
  409: "conflict",
19
+ 423: "locked",
17
20
  500: "internal server error",
18
21
  502: "bad gateway",
19
22
  503: "service unavailable"
@@ -47,6 +50,44 @@ var FuseApiError = class _FuseApiError extends Error {
47
50
  Object.setPrototypeOf(this, _FuseApiError.prototype);
48
51
  }
49
52
  };
53
+ var ForbiddenReason = {
54
+ MissingScope: "missing_scope",
55
+ EnvScope: "env_scope",
56
+ SelfApproval: "self_approval",
57
+ ActorRequired: "actor_required",
58
+ ActorMismatch: "actor_mismatch",
59
+ ActorNotAllowed: "actor_not_allowed",
60
+ MintEscalation: "mint_escalation",
61
+ /** A hitl deny gate (or the exec allowlist in deny mode) refused the
62
+ * action. The message is the gate's reason; details.gate names the gate. */
63
+ HitlDenied: "hitl_denied"
64
+ };
65
+ var ForbiddenError = class _ForbiddenError extends FuseApiError {
66
+ reason;
67
+ scope;
68
+ constructor(init) {
69
+ super(init);
70
+ this.name = "ForbiddenError";
71
+ this.reason = init.details?.reason ?? "";
72
+ this.scope = init.details?.scope;
73
+ Object.setPrototypeOf(this, _ForbiddenError.prototype);
74
+ }
75
+ };
76
+ var LockedError = class _LockedError extends FuseApiError {
77
+ interventionId;
78
+ /** "takeover" or "stall". */
79
+ kind;
80
+ constructor(init) {
81
+ super(init);
82
+ this.name = "LockedError";
83
+ this.interventionId = init.details?.intervention_id ?? "";
84
+ this.kind = init.details?.kind ?? "";
85
+ Object.setPrototypeOf(this, _LockedError.prototype);
86
+ }
87
+ };
88
+ function isLocked(err) {
89
+ return err instanceof LockedError;
90
+ }
50
91
  var FuseError = class _FuseError extends Error {
51
92
  constructor(message, options) {
52
93
  super(message, options);
@@ -54,6 +95,39 @@ var FuseError = class _FuseError extends Error {
54
95
  Object.setPrototypeOf(this, _FuseError.prototype);
55
96
  }
56
97
  };
98
+ var PendingInterventionError = class _PendingInterventionError extends Error {
99
+ intervention;
100
+ constructor(intervention) {
101
+ super(
102
+ `held by hitl gate ${intervention.gate ?? ""}: waiting on intervention ${intervention.id}`
103
+ );
104
+ this.name = "PendingInterventionError";
105
+ this.intervention = intervention;
106
+ Object.setPrototypeOf(this, _PendingInterventionError.prototype);
107
+ }
108
+ };
109
+ var InterventionRejectedError = class _InterventionRejectedError extends Error {
110
+ intervention;
111
+ constructor(intervention) {
112
+ super(
113
+ `intervention ${intervention.id} ${intervention.state}: ${intervention.decision?.reason ?? ""}`
114
+ );
115
+ this.name = "InterventionRejectedError";
116
+ this.intervention = intervention;
117
+ Object.setPrototypeOf(this, _InterventionRejectedError.prototype);
118
+ }
119
+ };
120
+ var HeldActionError = class _HeldActionError extends Error {
121
+ intervention;
122
+ constructor(intervention) {
123
+ super(
124
+ `approved action of intervention ${intervention.id} failed: ${intervention.result?.error ?? ""}`
125
+ );
126
+ this.name = "HeldActionError";
127
+ this.intervention = intervention;
128
+ Object.setPrototypeOf(this, _HeldActionError.prototype);
129
+ }
130
+ };
57
131
  function isFuseApiError(err) {
58
132
  return err instanceof FuseApiError;
59
133
  }
@@ -69,6 +143,12 @@ function isConflict(err) {
69
143
  function isUnauthorized(err) {
70
144
  return hasCode(err, ErrorCode.Unauthorized);
71
145
  }
146
+ function isForbidden(err) {
147
+ return err instanceof ForbiddenError;
148
+ }
149
+ function isHitlDenied(err) {
150
+ return isForbidden(err) && err.reason === ForbiddenReason.HitlDenied;
151
+ }
72
152
  function isInvalidArgument(err) {
73
153
  return hasCode(err, ErrorCode.InvalidArgument);
74
154
  }
@@ -81,14 +161,17 @@ function parseApiError(status, requestId, body) {
81
161
  const env = JSON.parse(body);
82
162
  const e = env?.error;
83
163
  if (e && (e.code || e.message)) {
84
- return new FuseApiError({
164
+ const init = {
85
165
  status,
86
166
  code: e.code,
87
167
  message: e.message,
88
168
  details: e.details,
89
169
  requestId,
90
170
  body
91
- });
171
+ };
172
+ if (e.code === ErrorCode.Forbidden) return new ForbiddenError(init);
173
+ if (e.code === ErrorCode.Locked) return new LockedError(init);
174
+ return new FuseApiError(init);
92
175
  }
93
176
  } catch {
94
177
  }
@@ -112,6 +195,8 @@ async function errorFromResponse(res) {
112
195
 
113
196
  // src/transport.ts
114
197
  var REQUEST_ID_HEADER2 = "X-Request-ID";
198
+ var ACTOR_HEADER = "Fuse-Actor";
199
+ var ACTOR_NAME_HEADER = "Fuse-Actor-Name";
115
200
  function combineSignals(signals) {
116
201
  const active = signals.filter((s) => s != null);
117
202
  if (active.length === 0) return { cleanup: () => {
@@ -146,6 +231,10 @@ var Transport = class {
146
231
  requestId;
147
232
  timeoutMs;
148
233
  defaultHeaders;
234
+ actor;
235
+ /** The client's hitl wait options. */
236
+ hitlNoWait;
237
+ onHeld;
149
238
  constructor(cfg) {
150
239
  if (!cfg.baseUrl) {
151
240
  throw new FuseError("base url is required");
@@ -162,6 +251,9 @@ var Transport = class {
162
251
  this.requestId = cfg.requestId;
163
252
  this.timeoutMs = cfg.timeoutMs;
164
253
  this.defaultHeaders = cfg.headers;
254
+ this.actor = cfg.actor;
255
+ this.hitlNoWait = cfg.hitlNoWait ?? false;
256
+ this.onHeld = cfg.onHeld;
165
257
  const provided = cfg.fetch;
166
258
  if (provided) {
167
259
  this.fetchImpl = provided;
@@ -184,6 +276,53 @@ var Transport = class {
184
276
  cleanup();
185
277
  }
186
278
  }
279
+ /**
280
+ * Perform a call a hitl gate may hold. A 202 answers with the held
281
+ * intervention; anything else 2xx with the action's own (possibly empty)
282
+ * body.
283
+ */
284
+ async gated(method, path, spec = {}) {
285
+ const { signal: combined, cleanup } = this.withTimeout(spec.signal);
286
+ try {
287
+ const res = await this.execute(method, path, spec, combined);
288
+ if (!res.ok) throw await errorFromResponse(res);
289
+ if (res.status === 202) return { held: await this.decodeHeld(res) };
290
+ return { value: await this.decodeJson(res) };
291
+ } finally {
292
+ cleanup();
293
+ }
294
+ }
295
+ /** Like noContent for a call a hitl gate may hold: it resolves to the held
296
+ * intervention of a 202, or undefined when the action itself answered. */
297
+ async gatedNoContent(method, path, spec = {}) {
298
+ const { signal: combined, cleanup } = this.withTimeout(spec.signal);
299
+ try {
300
+ const res = await this.execute(method, path, spec, combined);
301
+ if (!res.ok) throw await errorFromResponse(res);
302
+ if (res.status === 202) return await this.decodeHeld(res);
303
+ await res.body?.cancel().catch(() => {
304
+ });
305
+ return void 0;
306
+ } finally {
307
+ cleanup();
308
+ }
309
+ }
310
+ /** Perform a request and return the response body as text. */
311
+ async text(method, path, spec = {}) {
312
+ const { signal: combined, cleanup } = this.withTimeout(spec.signal);
313
+ try {
314
+ const res = await this.execute(method, path, spec, combined);
315
+ if (!res.ok) throw await errorFromResponse(res);
316
+ return await res.text();
317
+ } finally {
318
+ cleanup();
319
+ }
320
+ }
321
+ async decodeHeld(res) {
322
+ const body = await this.decodeJson(res);
323
+ if (!body?.intervention?.id) throw new FuseError("decode held response");
324
+ return body.intervention;
325
+ }
187
326
  /** Perform a request that returns no body (e.g. 204 actions). */
188
327
  async noContent(method, path, spec = {}) {
189
328
  const { signal: combined, cleanup } = this.withTimeout(spec.signal);
@@ -246,6 +385,10 @@ var Transport = class {
246
385
  if (id) headers[REQUEST_ID_HEADER2] = id;
247
386
  }
248
387
  if (accept) headers["Accept"] = accept;
388
+ if (this.actor?.id) {
389
+ headers[ACTOR_HEADER] = this.actor.id;
390
+ if (this.actor.name) headers[ACTOR_NAME_HEADER] = this.actor.name;
391
+ }
249
392
  return headers;
250
393
  }
251
394
  async decodeJson(res) {
@@ -266,20 +409,21 @@ var Transport = class {
266
409
  }
267
410
  };
268
411
 
269
- // src/validate.ts
270
- function requireArg(value, name) {
271
- if (!value) {
272
- throw new FuseError(`${name} is required`);
273
- }
274
- return value;
275
- }
276
-
277
412
  // src/types.ts
413
+ var HitlScope = {
414
+ Read: "hitl:read",
415
+ Decide: "hitl:decide",
416
+ Secret: "hitl:secret",
417
+ Takeover: "hitl:takeover",
418
+ ActAs: "hitl:act-as"
419
+ };
278
420
  var State = {
279
421
  Provisioning: "provisioning",
280
422
  Running: "running",
281
423
  Draining: "draining",
282
424
  Destroying: "destroying",
425
+ /** A destroy that ran out of retries. The environment may still exist on its host; destroy it again to retry. */
426
+ DestroyFailed: "destroy_failed",
283
427
  Destroyed: "destroyed",
284
428
  Failed: "failed"
285
429
  };
@@ -296,6 +440,90 @@ var HealthState = {
296
440
  * ended. */
297
441
  Failing: "failing"
298
442
  };
443
+ var AuditEvent = {
444
+ Raised: "raised",
445
+ Decided: "decided",
446
+ Expired: "expired",
447
+ Cancelled: "cancelled",
448
+ InviteMinted: "invite_minted",
449
+ InviteUsed: "invite_used",
450
+ GrantCreated: "grant_created",
451
+ GrantUsed: "grant_used",
452
+ SecretAnswered: "secret_answered",
453
+ TakeoverStarted: "takeover_started",
454
+ TakeoverEnded: "takeover_ended",
455
+ TakeoverSession: "takeover_session"
456
+ };
457
+ var HitlKind = {
458
+ Ask: "ask",
459
+ Exec: "exec",
460
+ Lifecycle: "lifecycle",
461
+ Computer: "computer",
462
+ Review: "review",
463
+ Secret: "secret",
464
+ Takeover: "takeover",
465
+ Stall: "stall",
466
+ Expiry: "expiry"
467
+ };
468
+ var GateMode = {
469
+ Ask: "ask",
470
+ Notify: "notify",
471
+ Deny: "deny"
472
+ };
473
+ var DeciderVerdict = {
474
+ Allow: "allow",
475
+ Deny: "deny",
476
+ Ask: "ask"
477
+ };
478
+ var ACTOR_SOURCE_DECIDER = "decider";
479
+ var ReviewFormat = {
480
+ Text: "text",
481
+ Diff: "diff"
482
+ };
483
+ var TakeoverVia = {
484
+ Terminal: "terminal",
485
+ Desktop: "desktop"
486
+ };
487
+ var LifecycleAction = {
488
+ Destroy: "destroy",
489
+ Fork: "fork",
490
+ Migrate: "migrate",
491
+ Drain: "drain",
492
+ Snapshot: "snapshot",
493
+ RotateToken: "rotate-token"
494
+ };
495
+ var ALLOWLIST_GATE = "exec.allowlist";
496
+ var InterventionState = {
497
+ Pending: "pending",
498
+ Approved: "approved",
499
+ Rejected: "rejected",
500
+ Expired: "expired",
501
+ Cancelled: "cancelled"
502
+ };
503
+ var InterventionOutcome = {
504
+ Approve: "approve",
505
+ Reject: "reject"
506
+ };
507
+ function isInterventionTerminal(i) {
508
+ return !!i && !!i.state && i.state !== InterventionState.Pending;
509
+ }
510
+ function isInterventionApproved(i) {
511
+ return i?.decision?.outcome === InterventionOutcome.Approve;
512
+ }
513
+ function isInterventionSettled(i) {
514
+ if (!i || !isInterventionTerminal(i)) return false;
515
+ if (i.decision?.decided_by?.source === ACTOR_SOURCE_DECIDER) return true;
516
+ const runs = i.mode === GateMode.Ask && (!!i.payload?.exec || !!i.payload?.lifecycle || !!i.payload?.computer);
517
+ return !runs || !isInterventionApproved(i) || !!i.result;
518
+ }
519
+
520
+ // src/validate.ts
521
+ function requireArg(value, name) {
522
+ if (!value) {
523
+ throw new FuseError(`${name} is required`);
524
+ }
525
+ return value;
526
+ }
299
527
 
300
528
  // src/events.ts
301
529
  var DATA_PREFIX = "data:";
@@ -365,8 +593,182 @@ function parseEventStream(res, signal) {
365
593
  };
366
594
  }
367
595
 
368
- // src/environments.ts
596
+ // src/interventions.ts
369
597
  var MAX_PAGE_LIMIT = 200;
598
+ var WAIT_POLL = "30s";
599
+ var InterventionsService = class {
600
+ constructor(t) {
601
+ this.t = t;
602
+ }
603
+ t;
604
+ /** List every intervention matching options, oldest first, walking every
605
+ * page. */
606
+ async list(options = {}, opts = {}) {
607
+ const out = [];
608
+ let cursor = options.cursor;
609
+ for (; ; ) {
610
+ const page = await this.listPage(
611
+ { ...options, limit: MAX_PAGE_LIMIT, cursor },
612
+ opts
613
+ );
614
+ out.push(...page.interventions);
615
+ if (!page.nextCursor) break;
616
+ cursor = page.nextCursor;
617
+ }
618
+ return out;
619
+ }
620
+ /** List one page of interventions matching options. */
621
+ async listPage(options = {}, opts = {}) {
622
+ const out = await this.t.json("GET", "/v1/interventions", {
623
+ query: {
624
+ env: options.envId,
625
+ state: options.state,
626
+ actor: options.actor,
627
+ limit: options.limit ? String(options.limit) : void 0,
628
+ cursor: options.cursor
629
+ },
630
+ signal: opts.signal
631
+ });
632
+ return { interventions: out.interventions ?? [], nextCursor: out.next_cursor };
633
+ }
634
+ /** List every hitl audit row matching options, oldest first, walking every
635
+ * page. */
636
+ async audit(options = {}, opts = {}) {
637
+ const out = [];
638
+ let cursor = options.cursor;
639
+ for (; ; ) {
640
+ const page = await this.auditPage(
641
+ { ...options, limit: MAX_PAGE_LIMIT, cursor },
642
+ opts
643
+ );
644
+ out.push(...page.entries);
645
+ if (!page.nextCursor) break;
646
+ cursor = page.nextCursor;
647
+ }
648
+ return out;
649
+ }
650
+ /** List one page of the hitl audit log matching options. */
651
+ async auditPage(options = {}, opts = {}) {
652
+ const out = await this.t.json("GET", "/v1/hitl/audit", {
653
+ query: {
654
+ env: options.envId,
655
+ actor: options.actor,
656
+ since: options.since,
657
+ limit: options.limit ? String(options.limit) : void 0,
658
+ cursor: options.cursor
659
+ },
660
+ signal: opts.signal
661
+ });
662
+ return { entries: out.entries ?? [], nextCursor: out.next_cursor };
663
+ }
664
+ /** Fetch a takeover session's recording (an AuditEntry's recording_id):
665
+ * asciinema v2 cast text for a terminal session, json (an input-action log
666
+ * plus screenshots) for a desktop one. Needs the hitl:read scope. */
667
+ async getRecording(recordingId, opts = {}) {
668
+ requireArg(recordingId, "recording id");
669
+ return this.t.text("GET", `/v1/hitl/recordings/${encodeURIComponent(recordingId)}`, {
670
+ signal: opts.signal
671
+ });
672
+ }
673
+ /** Fetch one intervention without waiting. */
674
+ async get(id, opts = {}) {
675
+ return getIntervention(this.t, id, void 0, opts);
676
+ }
677
+ /**
678
+ * Block until the intervention settles: resolved, and for an approved gated
679
+ * action, performed, with the outcome on result. Every intervention has a deadline,
680
+ * so this always returns eventually. A dropped connection or an
681
+ * orchestrator restart is survived by polling again; the server, not the
682
+ * connection, holds the state. Pass `opts.signal` to stop early.
683
+ */
684
+ async wait(id, opts = {}) {
685
+ return waitIntervention(this.t, id, opts);
686
+ }
687
+ /** Resolve a pending intervention as approved. For a held exec or
688
+ * lifecycle action, the orchestrator then performs it (optionally with
689
+ * body.editedArgv, or granting repeats with body.grant); wait on the id for
690
+ * its result. Requires the master token. */
691
+ async approve(id, body = {}, opts = {}) {
692
+ return this.decide(id, "approve", body, opts);
693
+ }
694
+ /** Resolve a pending intervention as rejected. Requires the master token. */
695
+ async reject(id, body = {}, opts = {}) {
696
+ return this.decide(id, "reject", { reason: body.reason }, opts);
697
+ }
698
+ /**
699
+ * Answer a pending secret request. The value is written into the guest at
700
+ * the declared target and dropped; fuse never stores, logs or returns it.
701
+ * Needs the hitl:secret scope.
702
+ */
703
+ async answerSecret(id, value, opts = {}) {
704
+ requireArg(id, "intervention id");
705
+ return this.t.json(
706
+ "POST",
707
+ `/v1/interventions/${encodeURIComponent(id)}`,
708
+ { query: { action: "secret" }, body: { value }, signal: opts.signal }
709
+ );
710
+ }
711
+ async decide(id, action, body, opts) {
712
+ requireArg(id, "intervention id");
713
+ return this.t.json(
714
+ "POST",
715
+ `/v1/interventions/${encodeURIComponent(id)}`,
716
+ {
717
+ query: { action },
718
+ body: {
719
+ answer: body.answer || void 0,
720
+ reason: body.reason || void 0,
721
+ edited_argv: body.editedArgv?.length ? body.editedArgv : void 0,
722
+ edited_text: body.editedText,
723
+ grant: body.grant || void 0,
724
+ extend_seconds: body.extendSeconds || void 0
725
+ },
726
+ signal: opts.signal
727
+ }
728
+ );
729
+ }
730
+ };
731
+ async function waitIntervention(t, id, opts = {}) {
732
+ for (; ; ) {
733
+ const i = await getIntervention(t, id, WAIT_POLL, opts);
734
+ if (isInterventionSettled(i)) return i;
735
+ opts.signal?.throwIfAborted();
736
+ }
737
+ }
738
+ function getIntervention(t, id, wait, opts) {
739
+ requireArg(id, "intervention id");
740
+ return t.json("GET", `/v1/interventions/${encodeURIComponent(id)}`, {
741
+ query: { wait },
742
+ signal: opts.signal
743
+ });
744
+ }
745
+
746
+ // src/gates.ts
747
+ async function settleHeld(t, held, o, opts) {
748
+ const onHeld = o.onHeld ?? t.onHeld;
749
+ onHeld?.(held);
750
+ if (o.wait === false || t.hitlNoWait) throw new PendingInterventionError(held);
751
+ const i = await waitIntervention(t, held.id, opts);
752
+ if (!isInterventionApproved(i)) throw new InterventionRejectedError(i);
753
+ if (i.result?.error) throw new HeldActionError(i);
754
+ return i;
755
+ }
756
+
757
+ // src/takeover.ts
758
+ async function retryLocked(t, call, wait, opts) {
759
+ for (; ; ) {
760
+ try {
761
+ return await call();
762
+ } catch (err) {
763
+ if (wait === false || !isLocked(err) || !err.interventionId) throw err;
764
+ await waitIntervention(t, err.interventionId, opts);
765
+ opts.signal?.throwIfAborted();
766
+ }
767
+ }
768
+ }
769
+
770
+ // src/environments.ts
771
+ var MAX_PAGE_LIMIT2 = 200;
370
772
  var EnvironmentsService = class {
371
773
  constructor(t) {
372
774
  this.t = t;
@@ -380,7 +782,7 @@ var EnvironmentsService = class {
380
782
  let cursor = options.cursor;
381
783
  for (; ; ) {
382
784
  const page = await this.listPage(
383
- { ...options, limit: MAX_PAGE_LIMIT, cursor },
785
+ { ...options, limit: MAX_PAGE_LIMIT2, cursor },
384
786
  opts
385
787
  );
386
788
  out.push(...page.environments);
@@ -419,14 +821,18 @@ var EnvironmentsService = class {
419
821
  signal: opts.signal
420
822
  });
421
823
  }
422
- /** Fork an environment into a new one; returns the new environment. */
824
+ /** Fork an environment into a new one; returns the new environment. When a
825
+ * hitl gate holds the fork, it waits for the decision and returns the
826
+ * environment the orchestrator then forked. */
423
827
  async fork(vmId, body = {}, opts = {}) {
424
828
  requireArg(vmId, "vm id");
425
- return this.t.json(
829
+ const out = await this.t.gated(
426
830
  "POST",
427
831
  `/v1/environments/${encodeURIComponent(vmId)}`,
428
832
  { query: { action: "fork" }, body, signal: opts.signal }
429
833
  );
834
+ if (!out.held) return out.value;
835
+ return this.heldEnvironment(out.held, opts);
430
836
  }
431
837
  /**
432
838
  * Migrate an environment to another host; returns the new environment. The
@@ -434,11 +840,22 @@ var EnvironmentsService = class {
434
840
  */
435
841
  async migrate(vmId, body = {}, opts = {}) {
436
842
  requireArg(vmId, "vm id");
437
- return this.t.json(
843
+ const out = await this.t.gated(
438
844
  "POST",
439
845
  `/v1/environments/${encodeURIComponent(vmId)}`,
440
846
  { query: { action: "migrate" }, body, signal: opts.signal }
441
847
  );
848
+ if (!out.held) return out.value;
849
+ return this.heldEnvironment(out.held, opts);
850
+ }
851
+ /** heldEnvironment waits out a held fork or migrate and returns the
852
+ * environment it produced. */
853
+ async heldEnvironment(held, opts) {
854
+ const i = await settleHeld(this.t, held, {}, opts);
855
+ if (!i.result?.env_id) {
856
+ throw new FuseError(`intervention ${i.id} settled without an environment`);
857
+ }
858
+ return this.get(i.result.env_id, opts);
442
859
  }
443
860
  /**
444
861
  * Run a command inside a running environment's guest and return its exit code
@@ -449,6 +866,16 @@ var EnvironmentsService = class {
449
866
  *
450
867
  * Exec requires the master token.
451
868
  *
869
+ * When a hitl gate holds the exec, exec waits for the decision (bounded
870
+ * only by `opts.signal`); the orchestrator runs the approved (possibly
871
+ * edited) argv and exec returns its result. A rejection throws an
872
+ * InterventionRejectedError, a deny gate an error isHitlDenied matches.
873
+ * `wait: false` throws a PendingInterventionError at once instead.
874
+ *
875
+ * While a human holds a takeover (or an ask-mode stall is pending) the
876
+ * server answers 423; exec waits for that intervention to settle and runs
877
+ * the command then. `wait: false` throws a LockedError at once instead.
878
+ *
452
879
  * @example
453
880
  * const out = await client.environments.exec(id, { cmd: ["ls", "-l"] });
454
881
  * if (out.exit_code !== 0) console.error(out.stderr);
@@ -463,11 +890,23 @@ var EnvironmentsService = class {
463
890
  if (hasCmd && hasShell) {
464
891
  throw new FuseError("cmd and shell are mutually exclusive");
465
892
  }
466
- return this.t.json(
467
- "POST",
468
- `/v1/environments/${encodeURIComponent(vmId)}`,
469
- { query: { action: "exec" }, body, signal: opts.signal }
893
+ const { wait, onHeld, ...wire } = body;
894
+ const out = await retryLocked(
895
+ this.t,
896
+ () => this.t.gated("POST", `/v1/environments/${encodeURIComponent(vmId)}`, {
897
+ query: { action: "exec" },
898
+ body: wire,
899
+ signal: opts.signal
900
+ }),
901
+ wait,
902
+ opts
470
903
  );
904
+ if (!out.held) return out.value;
905
+ const i = await settleHeld(this.t, out.held, { wait, onHeld }, opts);
906
+ if (!i.result?.exec) {
907
+ throw new FuseError(`intervention ${i.id} settled without an exec result`);
908
+ }
909
+ return i.result.exec;
471
910
  }
472
911
  /**
473
912
  * Relay one computer-use action to the environment's desktop and return the
@@ -475,6 +914,13 @@ var EnvironmentsService = class {
475
914
  * booted from a desktop image; on any other image the server answers 503
476
915
  * with a reason.
477
916
  *
917
+ * Like exec, it waits out a takeover or a pending stall (a 423) and then
918
+ * acts; `opts.wait: false` throws a LockedError at once instead. When a
919
+ * computer gate holds the action (a 202), it waits for the decision and
920
+ * returns what the orchestrator got performing it (with the human's edited
921
+ * text, if any); a rejection throws an InterventionRejectedError, and
922
+ * `opts.wait: false` throws a PendingInterventionError at once instead.
923
+ *
478
924
  * @example
479
925
  * const res = await client.environments.computer(id, {
480
926
  * action: "left_click",
@@ -484,11 +930,7 @@ var EnvironmentsService = class {
484
930
  async computer(vmId, action, opts = {}) {
485
931
  requireArg(vmId, "vm id");
486
932
  requireArg(action?.action, "action");
487
- return this.t.json(
488
- "POST",
489
- `/v1/environments/${encodeURIComponent(vmId)}/computer`,
490
- { body: action, signal: opts.signal }
491
- );
933
+ return this.postComputer(vmId, action, opts);
492
934
  }
493
935
  /**
494
936
  * Execute one computer tool_use against the environment and shape the
@@ -499,7 +941,7 @@ var EnvironmentsService = class {
499
941
  * An action the environment refuses (a malformed coordinate, a display
500
942
  * that is not up) resolves to `is_error` content rather than throwing: the
501
943
  * agent loop should feed it to the model, which is the party that can
502
- * correct it. A throw is reserved for failures the model cannot fix —
944
+ * correct it. So does a hitl deny gate or a human rejecting a held action. A throw is reserved for failures the model cannot fix —
503
945
  * transport, auth, an unknown environment.
504
946
  *
505
947
  * @example
@@ -521,18 +963,22 @@ var EnvironmentsService = class {
521
963
  }
522
964
  let res;
523
965
  try {
524
- res = await this.t.json(
525
- "POST",
526
- `/v1/environments/${encodeURIComponent(vmId)}/computer`,
527
- { body: input, signal: opts.signal }
528
- );
966
+ res = await this.postComputer(vmId, input, opts);
529
967
  } catch (err) {
530
- if (isFuseApiError(err) && (err.status === 400 || err.status === 409 || err.status === 503)) {
968
+ if (isFuseApiError(err) && (err.status === 400 || err.status === 409 || err.status === 503 || isHitlDenied(err))) {
531
969
  return {
532
970
  is_error: true,
533
971
  content: [{ type: "text", text: err.message || "the computer action failed" }]
534
972
  };
535
973
  }
974
+ if (err instanceof InterventionRejectedError) {
975
+ return {
976
+ is_error: true,
977
+ content: [
978
+ { type: "text", text: `a human rejected this action: ${err.message}` }
979
+ ]
980
+ };
981
+ }
536
982
  throw err;
537
983
  }
538
984
  const content = [];
@@ -550,6 +996,137 @@ var EnvironmentsService = class {
550
996
  if (content.length === 0) content.push({ type: "text", text: "done" });
551
997
  return { content };
552
998
  }
999
+ async postComputer(vmId, body, opts) {
1000
+ const out = await retryLocked(
1001
+ this.t,
1002
+ () => this.t.gated(
1003
+ "POST",
1004
+ `/v1/environments/${encodeURIComponent(vmId)}/computer`,
1005
+ { body, signal: opts.signal }
1006
+ ),
1007
+ opts.wait,
1008
+ opts
1009
+ );
1010
+ if (!out.held) return out.value;
1011
+ const i = await settleHeld(
1012
+ this.t,
1013
+ out.held,
1014
+ { wait: opts.wait, onHeld: opts.onHeld },
1015
+ opts
1016
+ );
1017
+ if (!i.result?.computer) {
1018
+ throw new FuseError(`intervention ${i.id} settled without a computer result`);
1019
+ }
1020
+ return i.result.computer;
1021
+ }
1022
+ /**
1023
+ * Ask a human for something the environment's hitl policy declares. By
1024
+ * default it waits (by long-polling) until the intervention resolves
1025
+ * (answered, rejected, expired or cancelled); read the result off the
1026
+ * returned intervention's decision. Pass `wait: false` to get the pending
1027
+ * handle instead. Pass `opts.signal` to stop waiting early.
1028
+ *
1029
+ * @example
1030
+ * const res = await client.environments.requestHuman(id, {
1031
+ * question: "deploy to prod?",
1032
+ * choices: ["yes", "no"],
1033
+ * });
1034
+ * if (isInterventionApproved(res)) console.log(res.decision?.answer);
1035
+ */
1036
+ async requestHuman(vmId, req, opts = {}) {
1037
+ requireArg(vmId, "vm id");
1038
+ const pending = await this.t.json(
1039
+ "POST",
1040
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1041
+ {
1042
+ query: { action: "request" },
1043
+ body: {
1044
+ kind: req.kind || "ask",
1045
+ question: req.question || void 0,
1046
+ choices: req.choices,
1047
+ target: req.target || void 0,
1048
+ message: req.message || void 0,
1049
+ via: req.via || void 0,
1050
+ reason: req.reason || void 0,
1051
+ path: req.path || void 0,
1052
+ content: req.content || void 0,
1053
+ format: req.format || void 0,
1054
+ timeout_seconds: req.timeoutSeconds || void 0
1055
+ },
1056
+ signal: opts.signal
1057
+ }
1058
+ );
1059
+ if (req.wait === false) return pending;
1060
+ return waitIntervention(this.t, pending.id, opts);
1061
+ }
1062
+ /**
1063
+ * Submit a guest file or a text/diff blob for a human to approve or reject
1064
+ * with comments, and wait for the verdict. Needs hitl.requests.review.
1065
+ * With `wait: false` it returns at once with the pending intervention and
1066
+ * approved false.
1067
+ *
1068
+ * @example
1069
+ * const r = await client.environments.requestReview(id, {
1070
+ * content: diff,
1071
+ * format: ReviewFormat.Diff,
1072
+ * });
1073
+ * if (!r.approved) console.log(r.comments);
1074
+ */
1075
+ async requestReview(vmId, req, opts = {}) {
1076
+ const i = await this.requestHuman(
1077
+ vmId,
1078
+ {
1079
+ kind: HitlKind.Review,
1080
+ path: req.path,
1081
+ content: req.content,
1082
+ format: req.format,
1083
+ message: req.message,
1084
+ timeoutSeconds: req.timeoutSeconds,
1085
+ wait: req.wait
1086
+ },
1087
+ opts
1088
+ );
1089
+ return {
1090
+ approved: isInterventionApproved(i),
1091
+ comments: i.decision?.reason ?? "",
1092
+ intervention: i
1093
+ };
1094
+ }
1095
+ /**
1096
+ * Take over the environment as a human: claims the agent's pending
1097
+ * takeover request if there is one, else opens a new one. While held, the
1098
+ * agent's exec and computer calls wait. Returns the held intervention.
1099
+ * Needs the hitl:takeover scope; a conflict when someone else holds it.
1100
+ */
1101
+ async takeover(vmId, body = {}, opts = {}) {
1102
+ requireArg(vmId, "vm id");
1103
+ return this.t.json(
1104
+ "POST",
1105
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1106
+ {
1107
+ query: { action: "takeover" },
1108
+ body: { via: body.via || void 0, reason: body.reason || void 0 },
1109
+ signal: opts.signal
1110
+ }
1111
+ );
1112
+ }
1113
+ /**
1114
+ * End a takeover and hand the environment back to the agent. The note is
1115
+ * the takeover's answer. Returns the approved intervention; a conflict when
1116
+ * nothing is held.
1117
+ */
1118
+ async release(vmId, body = {}, opts = {}) {
1119
+ requireArg(vmId, "vm id");
1120
+ return this.t.json(
1121
+ "POST",
1122
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1123
+ {
1124
+ query: { action: "release" },
1125
+ body: { note: body.note || void 0 },
1126
+ signal: opts.signal
1127
+ }
1128
+ );
1129
+ }
553
1130
  /** Report whether the environment has a live display and at what geometry.
554
1131
  * up is false with a reason on an image with no desktop. */
555
1132
  async computerDisplay(vmId, opts = {}) {
@@ -563,31 +1140,42 @@ var EnvironmentsService = class {
563
1140
  /**
564
1141
  * Phase-1 only: quiesces the guest workload and moves the environment to
565
1142
  * "draining" so you can inspect its output, but does not destroy it. Call
566
- * destroy() afterward to remove it - a drained environment left
567
- * undestroyed keeps running (and billing) forever.
1143
+ * destroy() afterward to remove it. A drained environment left
1144
+ * undestroyed keeps running (and billing) until the orchestrator's
1145
+ * draining timeout (5m30s by default) passes, and then the orchestrator
1146
+ * destroys it.
568
1147
  */
569
1148
  async drain(vmId, opts = {}) {
570
1149
  requireArg(vmId, "vm id");
571
- return this.t.json(
1150
+ const out = await this.t.gated(
572
1151
  "POST",
573
1152
  `/v1/environments/${encodeURIComponent(vmId)}`,
574
1153
  { query: { action: "drain" }, signal: opts.signal }
575
1154
  );
1155
+ if (!out.held) return out.value;
1156
+ await settleHeld(this.t, out.held, {}, opts);
1157
+ return this.get(vmId, opts);
576
1158
  }
577
1159
  /** Rotate the per-VM credentials and push them to the guest. */
578
1160
  async rotateToken(vmId, opts = {}) {
579
1161
  requireArg(vmId, "vm id");
580
- await this.t.noContent("POST", `/v1/environments/${encodeURIComponent(vmId)}`, {
581
- query: { action: "rotate-token" },
582
- signal: opts.signal
583
- });
1162
+ const held = await this.t.gatedNoContent(
1163
+ "POST",
1164
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1165
+ { query: { action: "rotate-token" }, signal: opts.signal }
1166
+ );
1167
+ if (held) await settleHeld(this.t, held, {}, opts);
584
1168
  }
585
- /** Forcefully destroy an environment. Idempotent. */
1169
+ /** Forcefully destroy an environment. Idempotent. When a hitl gate holds
1170
+ * the destroy, it waits for the decision. */
586
1171
  async destroy(vmId, opts = {}) {
587
1172
  requireArg(vmId, "vm id");
588
- await this.t.noContent("DELETE", `/v1/environments/${encodeURIComponent(vmId)}`, {
589
- signal: opts.signal
590
- });
1173
+ const held = await this.t.gatedNoContent(
1174
+ "DELETE",
1175
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1176
+ { signal: opts.signal }
1177
+ );
1178
+ if (held) await settleHeld(this.t, held, {}, opts);
591
1179
  }
592
1180
  /**
593
1181
  * Open the SSE event stream for an environment. The returned promise rejects
@@ -608,7 +1196,7 @@ var EnvironmentsService = class {
608
1196
  };
609
1197
 
610
1198
  // src/snapshots.ts
611
- var MAX_PAGE_LIMIT2 = 200;
1199
+ var MAX_PAGE_LIMIT3 = 200;
612
1200
  var SnapshotsService = class {
613
1201
  constructor(t) {
614
1202
  this.t = t;
@@ -623,14 +1211,23 @@ var SnapshotsService = class {
623
1211
  * be `"disk"` even for a `live: true` request that reached a host too old to
624
1212
  * honour it. A caller that depends on the memory being there should check
625
1213
  * it. `live: true` against a backend with no live-snapshot support throws a
626
- * 501 `unimplemented`, not a conflict. */
1214
+ * 501 `unimplemented`, not a conflict.
1215
+ *
1216
+ * When a hitl gate holds the snapshot, create waits for the decision and
1217
+ * returns the snapshot the orchestrator then took. */
627
1218
  async create(vmId, body = {}, opts = {}) {
628
1219
  requireArg(vmId, "vm id");
629
- return this.t.json(
1220
+ const out = await this.t.gated(
630
1221
  "POST",
631
1222
  `/v1/environments/${encodeURIComponent(vmId)}/snapshots`,
632
1223
  { body, signal: opts.signal }
633
1224
  );
1225
+ if (!out.held) return out.value;
1226
+ const i = await settleHeld(this.t, out.held, {}, opts);
1227
+ if (!i.result?.snapshot_id) {
1228
+ throw new FuseError(`intervention ${i.id} settled without a snapshot`);
1229
+ }
1230
+ return this.get(i.result.snapshot_id, opts);
634
1231
  }
635
1232
  /** List snapshots, optionally filtered. Transparently walks every result
636
1233
  * page. For explicit single-page control (e.g. a cursor from a previous
@@ -640,7 +1237,7 @@ var SnapshotsService = class {
640
1237
  let cursor = options.cursor;
641
1238
  for (; ; ) {
642
1239
  const page = await this.listPage(
643
- { ...options, limit: MAX_PAGE_LIMIT2, cursor },
1240
+ { ...options, limit: MAX_PAGE_LIMIT3, cursor },
644
1241
  opts
645
1242
  );
646
1243
  out.push(...page.snapshots);
@@ -718,7 +1315,7 @@ var SnapshotsService = class {
718
1315
  };
719
1316
 
720
1317
  // src/hosts.ts
721
- var MAX_PAGE_LIMIT3 = 200;
1318
+ var MAX_PAGE_LIMIT4 = 200;
722
1319
  var HostsService = class {
723
1320
  constructor(t) {
724
1321
  this.t = t;
@@ -735,7 +1332,7 @@ var HostsService = class {
735
1332
  const out = [];
736
1333
  let cursor;
737
1334
  for (; ; ) {
738
- const page = await this.listPage({ limit: MAX_PAGE_LIMIT3, cursor }, opts);
1335
+ const page = await this.listPage({ limit: MAX_PAGE_LIMIT4, cursor }, opts);
739
1336
  out.push(...page.hosts);
740
1337
  if (!page.nextCursor) break;
741
1338
  cursor = page.nextCursor;
@@ -791,12 +1388,19 @@ var ApiKeysService = class {
791
1388
  }
792
1389
  t;
793
1390
  /**
794
- * Issue a new API key. The raw secret is returned once in `key` and is
795
- * unrecoverable afterward.
1391
+ * Issue a new API key. Pass a label, or options for a scoped key. The raw
1392
+ * secret is returned once in `key` and is unrecoverable afterward.
796
1393
  */
797
- async create(label, opts = {}) {
1394
+ async create(labelOrOptions, opts = {}) {
1395
+ const o = typeof labelOrOptions === "string" ? { label: labelOrOptions } : labelOrOptions ?? {};
798
1396
  return this.t.json("POST", "/v1/api-keys", {
799
- body: label ? { label } : {},
1397
+ body: {
1398
+ label: o.label || void 0,
1399
+ scopes: o.scopes,
1400
+ env_id: o.envId || void 0,
1401
+ ttl_seconds: o.ttlSeconds || void 0,
1402
+ actor: o.actor
1403
+ },
800
1404
  signal: opts.signal
801
1405
  });
802
1406
  }
@@ -817,15 +1421,18 @@ var ApiKeysService = class {
817
1421
  };
818
1422
 
819
1423
  // src/version.ts
820
- var VERSION = "0.37.1";
1424
+ var VERSION = "0.42.0";
821
1425
 
822
1426
  // src/client.ts
823
- var FuseClient = class {
1427
+ var FuseClient = class _FuseClient {
824
1428
  environments;
825
1429
  snapshots;
826
1430
  hosts;
827
1431
  apiKeys;
1432
+ interventions;
1433
+ options;
828
1434
  constructor(options) {
1435
+ this.options = options;
829
1436
  const transport = new Transport({
830
1437
  baseUrl: options.baseUrl,
831
1438
  token: options.token,
@@ -833,15 +1440,26 @@ var FuseClient = class {
833
1440
  userAgent: options.userAgent ?? `fuse-ts/${VERSION}`,
834
1441
  requestId: options.requestId,
835
1442
  timeoutMs: options.timeoutMs,
836
- headers: options.headers
1443
+ headers: options.headers,
1444
+ actor: options.actor,
1445
+ hitlNoWait: options.hitlNoWait,
1446
+ onHeld: options.onHeld
837
1447
  });
838
1448
  this.environments = new EnvironmentsService(transport);
839
1449
  this.snapshots = new SnapshotsService(transport);
840
1450
  this.hosts = new HostsService(transport);
841
1451
  this.apiKeys = new ApiKeysService(transport);
1452
+ this.interventions = new InterventionsService(transport);
1453
+ }
1454
+ /**
1455
+ * withActor returns a copy of this client, with the same options otherwise,
1456
+ * that sends `actor` as the Fuse-Actor headers. This client is unchanged.
1457
+ */
1458
+ withActor(actor) {
1459
+ return new _FuseClient({ ...this.options, actor });
842
1460
  }
843
1461
  };
844
1462
 
845
- export { ApiKeysService, EnvironmentsService, ErrorCode, FuseApiError, FuseClient, FuseError, HealthState, HostsService, SnapshotsService, State, VERSION, isConflict, isFuseApiError, isInvalidArgument, isNotFound, isTerminalState, isUnauthorized, isUnavailable };
1463
+ export { ACTOR_SOURCE_DECIDER, ALLOWLIST_GATE, ApiKeysService, AuditEvent, DeciderVerdict, EnvironmentsService, ErrorCode, ForbiddenError, ForbiddenReason, FuseApiError, FuseClient, FuseError, GateMode, HealthState, HeldActionError, HitlKind, HitlScope, HostsService, InterventionOutcome, InterventionRejectedError, InterventionState, InterventionsService, LifecycleAction, LockedError, PendingInterventionError, ReviewFormat, SnapshotsService, State, TakeoverVia, VERSION, isConflict, isForbidden, isFuseApiError, isHitlDenied, isInterventionApproved, isInterventionSettled, isInterventionTerminal, isInvalidArgument, isLocked, isNotFound, isTerminalState, isUnauthorized, isUnavailable };
846
1464
  //# sourceMappingURL=index.js.map
847
1465
  //# sourceMappingURL=index.js.map