@folsom/fuse 0.37.1 → 0.41.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,15 +409,14 @@ 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",
@@ -296,6 +438,90 @@ var HealthState = {
296
438
  * ended. */
297
439
  Failing: "failing"
298
440
  };
441
+ var AuditEvent = {
442
+ Raised: "raised",
443
+ Decided: "decided",
444
+ Expired: "expired",
445
+ Cancelled: "cancelled",
446
+ InviteMinted: "invite_minted",
447
+ InviteUsed: "invite_used",
448
+ GrantCreated: "grant_created",
449
+ GrantUsed: "grant_used",
450
+ SecretAnswered: "secret_answered",
451
+ TakeoverStarted: "takeover_started",
452
+ TakeoverEnded: "takeover_ended",
453
+ TakeoverSession: "takeover_session"
454
+ };
455
+ var HitlKind = {
456
+ Ask: "ask",
457
+ Exec: "exec",
458
+ Lifecycle: "lifecycle",
459
+ Computer: "computer",
460
+ Review: "review",
461
+ Secret: "secret",
462
+ Takeover: "takeover",
463
+ Stall: "stall",
464
+ Expiry: "expiry"
465
+ };
466
+ var GateMode = {
467
+ Ask: "ask",
468
+ Notify: "notify",
469
+ Deny: "deny"
470
+ };
471
+ var DeciderVerdict = {
472
+ Allow: "allow",
473
+ Deny: "deny",
474
+ Ask: "ask"
475
+ };
476
+ var ACTOR_SOURCE_DECIDER = "decider";
477
+ var ReviewFormat = {
478
+ Text: "text",
479
+ Diff: "diff"
480
+ };
481
+ var TakeoverVia = {
482
+ Terminal: "terminal",
483
+ Desktop: "desktop"
484
+ };
485
+ var LifecycleAction = {
486
+ Destroy: "destroy",
487
+ Fork: "fork",
488
+ Migrate: "migrate",
489
+ Drain: "drain",
490
+ Snapshot: "snapshot",
491
+ RotateToken: "rotate-token"
492
+ };
493
+ var ALLOWLIST_GATE = "exec.allowlist";
494
+ var InterventionState = {
495
+ Pending: "pending",
496
+ Approved: "approved",
497
+ Rejected: "rejected",
498
+ Expired: "expired",
499
+ Cancelled: "cancelled"
500
+ };
501
+ var InterventionOutcome = {
502
+ Approve: "approve",
503
+ Reject: "reject"
504
+ };
505
+ function isInterventionTerminal(i) {
506
+ return !!i && !!i.state && i.state !== InterventionState.Pending;
507
+ }
508
+ function isInterventionApproved(i) {
509
+ return i?.decision?.outcome === InterventionOutcome.Approve;
510
+ }
511
+ function isInterventionSettled(i) {
512
+ if (!i || !isInterventionTerminal(i)) return false;
513
+ if (i.decision?.decided_by?.source === ACTOR_SOURCE_DECIDER) return true;
514
+ const runs = i.mode === GateMode.Ask && (!!i.payload?.exec || !!i.payload?.lifecycle || !!i.payload?.computer);
515
+ return !runs || !isInterventionApproved(i) || !!i.result;
516
+ }
517
+
518
+ // src/validate.ts
519
+ function requireArg(value, name) {
520
+ if (!value) {
521
+ throw new FuseError(`${name} is required`);
522
+ }
523
+ return value;
524
+ }
299
525
 
300
526
  // src/events.ts
301
527
  var DATA_PREFIX = "data:";
@@ -365,8 +591,182 @@ function parseEventStream(res, signal) {
365
591
  };
366
592
  }
367
593
 
368
- // src/environments.ts
594
+ // src/interventions.ts
369
595
  var MAX_PAGE_LIMIT = 200;
596
+ var WAIT_POLL = "30s";
597
+ var InterventionsService = class {
598
+ constructor(t) {
599
+ this.t = t;
600
+ }
601
+ t;
602
+ /** List every intervention matching options, oldest first, walking every
603
+ * page. */
604
+ async list(options = {}, opts = {}) {
605
+ const out = [];
606
+ let cursor = options.cursor;
607
+ for (; ; ) {
608
+ const page = await this.listPage(
609
+ { ...options, limit: MAX_PAGE_LIMIT, cursor },
610
+ opts
611
+ );
612
+ out.push(...page.interventions);
613
+ if (!page.nextCursor) break;
614
+ cursor = page.nextCursor;
615
+ }
616
+ return out;
617
+ }
618
+ /** List one page of interventions matching options. */
619
+ async listPage(options = {}, opts = {}) {
620
+ const out = await this.t.json("GET", "/v1/interventions", {
621
+ query: {
622
+ env: options.envId,
623
+ state: options.state,
624
+ actor: options.actor,
625
+ limit: options.limit ? String(options.limit) : void 0,
626
+ cursor: options.cursor
627
+ },
628
+ signal: opts.signal
629
+ });
630
+ return { interventions: out.interventions ?? [], nextCursor: out.next_cursor };
631
+ }
632
+ /** List every hitl audit row matching options, oldest first, walking every
633
+ * page. */
634
+ async audit(options = {}, opts = {}) {
635
+ const out = [];
636
+ let cursor = options.cursor;
637
+ for (; ; ) {
638
+ const page = await this.auditPage(
639
+ { ...options, limit: MAX_PAGE_LIMIT, cursor },
640
+ opts
641
+ );
642
+ out.push(...page.entries);
643
+ if (!page.nextCursor) break;
644
+ cursor = page.nextCursor;
645
+ }
646
+ return out;
647
+ }
648
+ /** List one page of the hitl audit log matching options. */
649
+ async auditPage(options = {}, opts = {}) {
650
+ const out = await this.t.json("GET", "/v1/hitl/audit", {
651
+ query: {
652
+ env: options.envId,
653
+ actor: options.actor,
654
+ since: options.since,
655
+ limit: options.limit ? String(options.limit) : void 0,
656
+ cursor: options.cursor
657
+ },
658
+ signal: opts.signal
659
+ });
660
+ return { entries: out.entries ?? [], nextCursor: out.next_cursor };
661
+ }
662
+ /** Fetch a takeover session's recording (an AuditEntry's recording_id):
663
+ * asciinema v2 cast text for a terminal session, json (an input-action log
664
+ * plus screenshots) for a desktop one. Needs the hitl:read scope. */
665
+ async getRecording(recordingId, opts = {}) {
666
+ requireArg(recordingId, "recording id");
667
+ return this.t.text("GET", `/v1/hitl/recordings/${encodeURIComponent(recordingId)}`, {
668
+ signal: opts.signal
669
+ });
670
+ }
671
+ /** Fetch one intervention without waiting. */
672
+ async get(id, opts = {}) {
673
+ return getIntervention(this.t, id, void 0, opts);
674
+ }
675
+ /**
676
+ * Block until the intervention settles: resolved, and for an approved gated
677
+ * action, performed, with the outcome on result. Every intervention has a deadline,
678
+ * so this always returns eventually. A dropped connection or an
679
+ * orchestrator restart is survived by polling again; the server, not the
680
+ * connection, holds the state. Pass `opts.signal` to stop early.
681
+ */
682
+ async wait(id, opts = {}) {
683
+ return waitIntervention(this.t, id, opts);
684
+ }
685
+ /** Resolve a pending intervention as approved. For a held exec or
686
+ * lifecycle action, the orchestrator then performs it (optionally with
687
+ * body.editedArgv, or granting repeats with body.grant); wait on the id for
688
+ * its result. Requires the master token. */
689
+ async approve(id, body = {}, opts = {}) {
690
+ return this.decide(id, "approve", body, opts);
691
+ }
692
+ /** Resolve a pending intervention as rejected. Requires the master token. */
693
+ async reject(id, body = {}, opts = {}) {
694
+ return this.decide(id, "reject", { reason: body.reason }, opts);
695
+ }
696
+ /**
697
+ * Answer a pending secret request. The value is written into the guest at
698
+ * the declared target and dropped; fuse never stores, logs or returns it.
699
+ * Needs the hitl:secret scope.
700
+ */
701
+ async answerSecret(id, value, opts = {}) {
702
+ requireArg(id, "intervention id");
703
+ return this.t.json(
704
+ "POST",
705
+ `/v1/interventions/${encodeURIComponent(id)}`,
706
+ { query: { action: "secret" }, body: { value }, signal: opts.signal }
707
+ );
708
+ }
709
+ async decide(id, action, body, opts) {
710
+ requireArg(id, "intervention id");
711
+ return this.t.json(
712
+ "POST",
713
+ `/v1/interventions/${encodeURIComponent(id)}`,
714
+ {
715
+ query: { action },
716
+ body: {
717
+ answer: body.answer || void 0,
718
+ reason: body.reason || void 0,
719
+ edited_argv: body.editedArgv?.length ? body.editedArgv : void 0,
720
+ edited_text: body.editedText,
721
+ grant: body.grant || void 0,
722
+ extend_seconds: body.extendSeconds || void 0
723
+ },
724
+ signal: opts.signal
725
+ }
726
+ );
727
+ }
728
+ };
729
+ async function waitIntervention(t, id, opts = {}) {
730
+ for (; ; ) {
731
+ const i = await getIntervention(t, id, WAIT_POLL, opts);
732
+ if (isInterventionSettled(i)) return i;
733
+ opts.signal?.throwIfAborted();
734
+ }
735
+ }
736
+ function getIntervention(t, id, wait, opts) {
737
+ requireArg(id, "intervention id");
738
+ return t.json("GET", `/v1/interventions/${encodeURIComponent(id)}`, {
739
+ query: { wait },
740
+ signal: opts.signal
741
+ });
742
+ }
743
+
744
+ // src/gates.ts
745
+ async function settleHeld(t, held, o, opts) {
746
+ const onHeld = o.onHeld ?? t.onHeld;
747
+ onHeld?.(held);
748
+ if (o.wait === false || t.hitlNoWait) throw new PendingInterventionError(held);
749
+ const i = await waitIntervention(t, held.id, opts);
750
+ if (!isInterventionApproved(i)) throw new InterventionRejectedError(i);
751
+ if (i.result?.error) throw new HeldActionError(i);
752
+ return i;
753
+ }
754
+
755
+ // src/takeover.ts
756
+ async function retryLocked(t, call, wait, opts) {
757
+ for (; ; ) {
758
+ try {
759
+ return await call();
760
+ } catch (err) {
761
+ if (wait === false || !isLocked(err) || !err.interventionId) throw err;
762
+ await waitIntervention(t, err.interventionId, opts);
763
+ opts.signal?.throwIfAborted();
764
+ }
765
+ }
766
+ }
767
+
768
+ // src/environments.ts
769
+ var MAX_PAGE_LIMIT2 = 200;
370
770
  var EnvironmentsService = class {
371
771
  constructor(t) {
372
772
  this.t = t;
@@ -380,7 +780,7 @@ var EnvironmentsService = class {
380
780
  let cursor = options.cursor;
381
781
  for (; ; ) {
382
782
  const page = await this.listPage(
383
- { ...options, limit: MAX_PAGE_LIMIT, cursor },
783
+ { ...options, limit: MAX_PAGE_LIMIT2, cursor },
384
784
  opts
385
785
  );
386
786
  out.push(...page.environments);
@@ -419,14 +819,18 @@ var EnvironmentsService = class {
419
819
  signal: opts.signal
420
820
  });
421
821
  }
422
- /** Fork an environment into a new one; returns the new environment. */
822
+ /** Fork an environment into a new one; returns the new environment. When a
823
+ * hitl gate holds the fork, it waits for the decision and returns the
824
+ * environment the orchestrator then forked. */
423
825
  async fork(vmId, body = {}, opts = {}) {
424
826
  requireArg(vmId, "vm id");
425
- return this.t.json(
827
+ const out = await this.t.gated(
426
828
  "POST",
427
829
  `/v1/environments/${encodeURIComponent(vmId)}`,
428
830
  { query: { action: "fork" }, body, signal: opts.signal }
429
831
  );
832
+ if (!out.held) return out.value;
833
+ return this.heldEnvironment(out.held, opts);
430
834
  }
431
835
  /**
432
836
  * Migrate an environment to another host; returns the new environment. The
@@ -434,11 +838,22 @@ var EnvironmentsService = class {
434
838
  */
435
839
  async migrate(vmId, body = {}, opts = {}) {
436
840
  requireArg(vmId, "vm id");
437
- return this.t.json(
841
+ const out = await this.t.gated(
438
842
  "POST",
439
843
  `/v1/environments/${encodeURIComponent(vmId)}`,
440
844
  { query: { action: "migrate" }, body, signal: opts.signal }
441
845
  );
846
+ if (!out.held) return out.value;
847
+ return this.heldEnvironment(out.held, opts);
848
+ }
849
+ /** heldEnvironment waits out a held fork or migrate and returns the
850
+ * environment it produced. */
851
+ async heldEnvironment(held, opts) {
852
+ const i = await settleHeld(this.t, held, {}, opts);
853
+ if (!i.result?.env_id) {
854
+ throw new FuseError(`intervention ${i.id} settled without an environment`);
855
+ }
856
+ return this.get(i.result.env_id, opts);
442
857
  }
443
858
  /**
444
859
  * Run a command inside a running environment's guest and return its exit code
@@ -449,6 +864,16 @@ var EnvironmentsService = class {
449
864
  *
450
865
  * Exec requires the master token.
451
866
  *
867
+ * When a hitl gate holds the exec, exec waits for the decision (bounded
868
+ * only by `opts.signal`); the orchestrator runs the approved (possibly
869
+ * edited) argv and exec returns its result. A rejection throws an
870
+ * InterventionRejectedError, a deny gate an error isHitlDenied matches.
871
+ * `wait: false` throws a PendingInterventionError at once instead.
872
+ *
873
+ * While a human holds a takeover (or an ask-mode stall is pending) the
874
+ * server answers 423; exec waits for that intervention to settle and runs
875
+ * the command then. `wait: false` throws a LockedError at once instead.
876
+ *
452
877
  * @example
453
878
  * const out = await client.environments.exec(id, { cmd: ["ls", "-l"] });
454
879
  * if (out.exit_code !== 0) console.error(out.stderr);
@@ -463,11 +888,23 @@ var EnvironmentsService = class {
463
888
  if (hasCmd && hasShell) {
464
889
  throw new FuseError("cmd and shell are mutually exclusive");
465
890
  }
466
- return this.t.json(
467
- "POST",
468
- `/v1/environments/${encodeURIComponent(vmId)}`,
469
- { query: { action: "exec" }, body, signal: opts.signal }
891
+ const { wait, onHeld, ...wire } = body;
892
+ const out = await retryLocked(
893
+ this.t,
894
+ () => this.t.gated("POST", `/v1/environments/${encodeURIComponent(vmId)}`, {
895
+ query: { action: "exec" },
896
+ body: wire,
897
+ signal: opts.signal
898
+ }),
899
+ wait,
900
+ opts
470
901
  );
902
+ if (!out.held) return out.value;
903
+ const i = await settleHeld(this.t, out.held, { wait, onHeld }, opts);
904
+ if (!i.result?.exec) {
905
+ throw new FuseError(`intervention ${i.id} settled without an exec result`);
906
+ }
907
+ return i.result.exec;
471
908
  }
472
909
  /**
473
910
  * Relay one computer-use action to the environment's desktop and return the
@@ -475,6 +912,13 @@ var EnvironmentsService = class {
475
912
  * booted from a desktop image; on any other image the server answers 503
476
913
  * with a reason.
477
914
  *
915
+ * Like exec, it waits out a takeover or a pending stall (a 423) and then
916
+ * acts; `opts.wait: false` throws a LockedError at once instead. When a
917
+ * computer gate holds the action (a 202), it waits for the decision and
918
+ * returns what the orchestrator got performing it (with the human's edited
919
+ * text, if any); a rejection throws an InterventionRejectedError, and
920
+ * `opts.wait: false` throws a PendingInterventionError at once instead.
921
+ *
478
922
  * @example
479
923
  * const res = await client.environments.computer(id, {
480
924
  * action: "left_click",
@@ -484,11 +928,7 @@ var EnvironmentsService = class {
484
928
  async computer(vmId, action, opts = {}) {
485
929
  requireArg(vmId, "vm id");
486
930
  requireArg(action?.action, "action");
487
- return this.t.json(
488
- "POST",
489
- `/v1/environments/${encodeURIComponent(vmId)}/computer`,
490
- { body: action, signal: opts.signal }
491
- );
931
+ return this.postComputer(vmId, action, opts);
492
932
  }
493
933
  /**
494
934
  * Execute one computer tool_use against the environment and shape the
@@ -499,7 +939,7 @@ var EnvironmentsService = class {
499
939
  * An action the environment refuses (a malformed coordinate, a display
500
940
  * that is not up) resolves to `is_error` content rather than throwing: the
501
941
  * 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 —
942
+ * 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
943
  * transport, auth, an unknown environment.
504
944
  *
505
945
  * @example
@@ -521,18 +961,22 @@ var EnvironmentsService = class {
521
961
  }
522
962
  let res;
523
963
  try {
524
- res = await this.t.json(
525
- "POST",
526
- `/v1/environments/${encodeURIComponent(vmId)}/computer`,
527
- { body: input, signal: opts.signal }
528
- );
964
+ res = await this.postComputer(vmId, input, opts);
529
965
  } catch (err) {
530
- if (isFuseApiError(err) && (err.status === 400 || err.status === 409 || err.status === 503)) {
966
+ if (isFuseApiError(err) && (err.status === 400 || err.status === 409 || err.status === 503 || isHitlDenied(err))) {
531
967
  return {
532
968
  is_error: true,
533
969
  content: [{ type: "text", text: err.message || "the computer action failed" }]
534
970
  };
535
971
  }
972
+ if (err instanceof InterventionRejectedError) {
973
+ return {
974
+ is_error: true,
975
+ content: [
976
+ { type: "text", text: `a human rejected this action: ${err.message}` }
977
+ ]
978
+ };
979
+ }
536
980
  throw err;
537
981
  }
538
982
  const content = [];
@@ -550,6 +994,137 @@ var EnvironmentsService = class {
550
994
  if (content.length === 0) content.push({ type: "text", text: "done" });
551
995
  return { content };
552
996
  }
997
+ async postComputer(vmId, body, opts) {
998
+ const out = await retryLocked(
999
+ this.t,
1000
+ () => this.t.gated(
1001
+ "POST",
1002
+ `/v1/environments/${encodeURIComponent(vmId)}/computer`,
1003
+ { body, signal: opts.signal }
1004
+ ),
1005
+ opts.wait,
1006
+ opts
1007
+ );
1008
+ if (!out.held) return out.value;
1009
+ const i = await settleHeld(
1010
+ this.t,
1011
+ out.held,
1012
+ { wait: opts.wait, onHeld: opts.onHeld },
1013
+ opts
1014
+ );
1015
+ if (!i.result?.computer) {
1016
+ throw new FuseError(`intervention ${i.id} settled without a computer result`);
1017
+ }
1018
+ return i.result.computer;
1019
+ }
1020
+ /**
1021
+ * Ask a human for something the environment's hitl policy declares. By
1022
+ * default it waits (by long-polling) until the intervention resolves
1023
+ * (answered, rejected, expired or cancelled); read the result off the
1024
+ * returned intervention's decision. Pass `wait: false` to get the pending
1025
+ * handle instead. Pass `opts.signal` to stop waiting early.
1026
+ *
1027
+ * @example
1028
+ * const res = await client.environments.requestHuman(id, {
1029
+ * question: "deploy to prod?",
1030
+ * choices: ["yes", "no"],
1031
+ * });
1032
+ * if (isInterventionApproved(res)) console.log(res.decision?.answer);
1033
+ */
1034
+ async requestHuman(vmId, req, opts = {}) {
1035
+ requireArg(vmId, "vm id");
1036
+ const pending = await this.t.json(
1037
+ "POST",
1038
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1039
+ {
1040
+ query: { action: "request" },
1041
+ body: {
1042
+ kind: req.kind || "ask",
1043
+ question: req.question || void 0,
1044
+ choices: req.choices,
1045
+ target: req.target || void 0,
1046
+ message: req.message || void 0,
1047
+ via: req.via || void 0,
1048
+ reason: req.reason || void 0,
1049
+ path: req.path || void 0,
1050
+ content: req.content || void 0,
1051
+ format: req.format || void 0,
1052
+ timeout_seconds: req.timeoutSeconds || void 0
1053
+ },
1054
+ signal: opts.signal
1055
+ }
1056
+ );
1057
+ if (req.wait === false) return pending;
1058
+ return waitIntervention(this.t, pending.id, opts);
1059
+ }
1060
+ /**
1061
+ * Submit a guest file or a text/diff blob for a human to approve or reject
1062
+ * with comments, and wait for the verdict. Needs hitl.requests.review.
1063
+ * With `wait: false` it returns at once with the pending intervention and
1064
+ * approved false.
1065
+ *
1066
+ * @example
1067
+ * const r = await client.environments.requestReview(id, {
1068
+ * content: diff,
1069
+ * format: ReviewFormat.Diff,
1070
+ * });
1071
+ * if (!r.approved) console.log(r.comments);
1072
+ */
1073
+ async requestReview(vmId, req, opts = {}) {
1074
+ const i = await this.requestHuman(
1075
+ vmId,
1076
+ {
1077
+ kind: HitlKind.Review,
1078
+ path: req.path,
1079
+ content: req.content,
1080
+ format: req.format,
1081
+ message: req.message,
1082
+ timeoutSeconds: req.timeoutSeconds,
1083
+ wait: req.wait
1084
+ },
1085
+ opts
1086
+ );
1087
+ return {
1088
+ approved: isInterventionApproved(i),
1089
+ comments: i.decision?.reason ?? "",
1090
+ intervention: i
1091
+ };
1092
+ }
1093
+ /**
1094
+ * Take over the environment as a human: claims the agent's pending
1095
+ * takeover request if there is one, else opens a new one. While held, the
1096
+ * agent's exec and computer calls wait. Returns the held intervention.
1097
+ * Needs the hitl:takeover scope; a conflict when someone else holds it.
1098
+ */
1099
+ async takeover(vmId, body = {}, opts = {}) {
1100
+ requireArg(vmId, "vm id");
1101
+ return this.t.json(
1102
+ "POST",
1103
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1104
+ {
1105
+ query: { action: "takeover" },
1106
+ body: { via: body.via || void 0, reason: body.reason || void 0 },
1107
+ signal: opts.signal
1108
+ }
1109
+ );
1110
+ }
1111
+ /**
1112
+ * End a takeover and hand the environment back to the agent. The note is
1113
+ * the takeover's answer. Returns the approved intervention; a conflict when
1114
+ * nothing is held.
1115
+ */
1116
+ async release(vmId, body = {}, opts = {}) {
1117
+ requireArg(vmId, "vm id");
1118
+ return this.t.json(
1119
+ "POST",
1120
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1121
+ {
1122
+ query: { action: "release" },
1123
+ body: { note: body.note || void 0 },
1124
+ signal: opts.signal
1125
+ }
1126
+ );
1127
+ }
553
1128
  /** Report whether the environment has a live display and at what geometry.
554
1129
  * up is false with a reason on an image with no desktop. */
555
1130
  async computerDisplay(vmId, opts = {}) {
@@ -563,31 +1138,42 @@ var EnvironmentsService = class {
563
1138
  /**
564
1139
  * Phase-1 only: quiesces the guest workload and moves the environment to
565
1140
  * "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.
1141
+ * destroy() afterward to remove it. A drained environment left
1142
+ * undestroyed keeps running (and billing) until the orchestrator's
1143
+ * draining timeout (5m30s by default) passes, and then the orchestrator
1144
+ * destroys it.
568
1145
  */
569
1146
  async drain(vmId, opts = {}) {
570
1147
  requireArg(vmId, "vm id");
571
- return this.t.json(
1148
+ const out = await this.t.gated(
572
1149
  "POST",
573
1150
  `/v1/environments/${encodeURIComponent(vmId)}`,
574
1151
  { query: { action: "drain" }, signal: opts.signal }
575
1152
  );
1153
+ if (!out.held) return out.value;
1154
+ await settleHeld(this.t, out.held, {}, opts);
1155
+ return this.get(vmId, opts);
576
1156
  }
577
1157
  /** Rotate the per-VM credentials and push them to the guest. */
578
1158
  async rotateToken(vmId, opts = {}) {
579
1159
  requireArg(vmId, "vm id");
580
- await this.t.noContent("POST", `/v1/environments/${encodeURIComponent(vmId)}`, {
581
- query: { action: "rotate-token" },
582
- signal: opts.signal
583
- });
1160
+ const held = await this.t.gatedNoContent(
1161
+ "POST",
1162
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1163
+ { query: { action: "rotate-token" }, signal: opts.signal }
1164
+ );
1165
+ if (held) await settleHeld(this.t, held, {}, opts);
584
1166
  }
585
- /** Forcefully destroy an environment. Idempotent. */
1167
+ /** Forcefully destroy an environment. Idempotent. When a hitl gate holds
1168
+ * the destroy, it waits for the decision. */
586
1169
  async destroy(vmId, opts = {}) {
587
1170
  requireArg(vmId, "vm id");
588
- await this.t.noContent("DELETE", `/v1/environments/${encodeURIComponent(vmId)}`, {
589
- signal: opts.signal
590
- });
1171
+ const held = await this.t.gatedNoContent(
1172
+ "DELETE",
1173
+ `/v1/environments/${encodeURIComponent(vmId)}`,
1174
+ { signal: opts.signal }
1175
+ );
1176
+ if (held) await settleHeld(this.t, held, {}, opts);
591
1177
  }
592
1178
  /**
593
1179
  * Open the SSE event stream for an environment. The returned promise rejects
@@ -608,7 +1194,7 @@ var EnvironmentsService = class {
608
1194
  };
609
1195
 
610
1196
  // src/snapshots.ts
611
- var MAX_PAGE_LIMIT2 = 200;
1197
+ var MAX_PAGE_LIMIT3 = 200;
612
1198
  var SnapshotsService = class {
613
1199
  constructor(t) {
614
1200
  this.t = t;
@@ -623,14 +1209,23 @@ var SnapshotsService = class {
623
1209
  * be `"disk"` even for a `live: true` request that reached a host too old to
624
1210
  * honour it. A caller that depends on the memory being there should check
625
1211
  * it. `live: true` against a backend with no live-snapshot support throws a
626
- * 501 `unimplemented`, not a conflict. */
1212
+ * 501 `unimplemented`, not a conflict.
1213
+ *
1214
+ * When a hitl gate holds the snapshot, create waits for the decision and
1215
+ * returns the snapshot the orchestrator then took. */
627
1216
  async create(vmId, body = {}, opts = {}) {
628
1217
  requireArg(vmId, "vm id");
629
- return this.t.json(
1218
+ const out = await this.t.gated(
630
1219
  "POST",
631
1220
  `/v1/environments/${encodeURIComponent(vmId)}/snapshots`,
632
1221
  { body, signal: opts.signal }
633
1222
  );
1223
+ if (!out.held) return out.value;
1224
+ const i = await settleHeld(this.t, out.held, {}, opts);
1225
+ if (!i.result?.snapshot_id) {
1226
+ throw new FuseError(`intervention ${i.id} settled without a snapshot`);
1227
+ }
1228
+ return this.get(i.result.snapshot_id, opts);
634
1229
  }
635
1230
  /** List snapshots, optionally filtered. Transparently walks every result
636
1231
  * page. For explicit single-page control (e.g. a cursor from a previous
@@ -640,7 +1235,7 @@ var SnapshotsService = class {
640
1235
  let cursor = options.cursor;
641
1236
  for (; ; ) {
642
1237
  const page = await this.listPage(
643
- { ...options, limit: MAX_PAGE_LIMIT2, cursor },
1238
+ { ...options, limit: MAX_PAGE_LIMIT3, cursor },
644
1239
  opts
645
1240
  );
646
1241
  out.push(...page.snapshots);
@@ -718,7 +1313,7 @@ var SnapshotsService = class {
718
1313
  };
719
1314
 
720
1315
  // src/hosts.ts
721
- var MAX_PAGE_LIMIT3 = 200;
1316
+ var MAX_PAGE_LIMIT4 = 200;
722
1317
  var HostsService = class {
723
1318
  constructor(t) {
724
1319
  this.t = t;
@@ -735,7 +1330,7 @@ var HostsService = class {
735
1330
  const out = [];
736
1331
  let cursor;
737
1332
  for (; ; ) {
738
- const page = await this.listPage({ limit: MAX_PAGE_LIMIT3, cursor }, opts);
1333
+ const page = await this.listPage({ limit: MAX_PAGE_LIMIT4, cursor }, opts);
739
1334
  out.push(...page.hosts);
740
1335
  if (!page.nextCursor) break;
741
1336
  cursor = page.nextCursor;
@@ -791,12 +1386,19 @@ var ApiKeysService = class {
791
1386
  }
792
1387
  t;
793
1388
  /**
794
- * Issue a new API key. The raw secret is returned once in `key` and is
795
- * unrecoverable afterward.
1389
+ * Issue a new API key. Pass a label, or options for a scoped key. The raw
1390
+ * secret is returned once in `key` and is unrecoverable afterward.
796
1391
  */
797
- async create(label, opts = {}) {
1392
+ async create(labelOrOptions, opts = {}) {
1393
+ const o = typeof labelOrOptions === "string" ? { label: labelOrOptions } : labelOrOptions ?? {};
798
1394
  return this.t.json("POST", "/v1/api-keys", {
799
- body: label ? { label } : {},
1395
+ body: {
1396
+ label: o.label || void 0,
1397
+ scopes: o.scopes,
1398
+ env_id: o.envId || void 0,
1399
+ ttl_seconds: o.ttlSeconds || void 0,
1400
+ actor: o.actor
1401
+ },
800
1402
  signal: opts.signal
801
1403
  });
802
1404
  }
@@ -817,15 +1419,18 @@ var ApiKeysService = class {
817
1419
  };
818
1420
 
819
1421
  // src/version.ts
820
- var VERSION = "0.37.1";
1422
+ var VERSION = "0.41.0";
821
1423
 
822
1424
  // src/client.ts
823
- var FuseClient = class {
1425
+ var FuseClient = class _FuseClient {
824
1426
  environments;
825
1427
  snapshots;
826
1428
  hosts;
827
1429
  apiKeys;
1430
+ interventions;
1431
+ options;
828
1432
  constructor(options) {
1433
+ this.options = options;
829
1434
  const transport = new Transport({
830
1435
  baseUrl: options.baseUrl,
831
1436
  token: options.token,
@@ -833,15 +1438,26 @@ var FuseClient = class {
833
1438
  userAgent: options.userAgent ?? `fuse-ts/${VERSION}`,
834
1439
  requestId: options.requestId,
835
1440
  timeoutMs: options.timeoutMs,
836
- headers: options.headers
1441
+ headers: options.headers,
1442
+ actor: options.actor,
1443
+ hitlNoWait: options.hitlNoWait,
1444
+ onHeld: options.onHeld
837
1445
  });
838
1446
  this.environments = new EnvironmentsService(transport);
839
1447
  this.snapshots = new SnapshotsService(transport);
840
1448
  this.hosts = new HostsService(transport);
841
1449
  this.apiKeys = new ApiKeysService(transport);
1450
+ this.interventions = new InterventionsService(transport);
1451
+ }
1452
+ /**
1453
+ * withActor returns a copy of this client, with the same options otherwise,
1454
+ * that sends `actor` as the Fuse-Actor headers. This client is unchanged.
1455
+ */
1456
+ withActor(actor) {
1457
+ return new _FuseClient({ ...this.options, actor });
842
1458
  }
843
1459
  };
844
1460
 
845
- export { ApiKeysService, EnvironmentsService, ErrorCode, FuseApiError, FuseClient, FuseError, HealthState, HostsService, SnapshotsService, State, VERSION, isConflict, isFuseApiError, isInvalidArgument, isNotFound, isTerminalState, isUnauthorized, isUnavailable };
1461
+ 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
1462
  //# sourceMappingURL=index.js.map
847
1463
  //# sourceMappingURL=index.js.map