@ref0/cli 0.13.0 → 0.14.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.
Files changed (2) hide show
  1. package/dist/main.mjs +519 -429
  2. package/package.json +2 -2
package/dist/main.mjs CHANGED
@@ -1038,7 +1038,7 @@ const TokenDelegated = eventKind("token.delegated", Schema.Struct({
1038
1038
  /** The credential this one was made under, when there is one. */
1039
1039
  parent: Schema.optionalKey(CredentialRef),
1040
1040
  expiresAt: Schema.optionalKey(Schema.DateTimeUtcFromMillis),
1041
- /** The key it's bound to, by its thumbprint, for a host grant made with one. */
1041
+ /** The key it's bound to, by its thumbprint, for a host grant or a session's token made with one. */
1042
1042
  cnf: Schema.optionalKey(KeyBinding)
1043
1043
  }));
1044
1044
  const TokenRevoked = eventKind("token.revoked", Schema.Struct({
@@ -5543,6 +5543,11 @@ Schema.Struct({
5543
5543
  /** For a child session: when its parent's token expires, which the child's never outlives. */
5544
5544
  notAfter: Schema.optionalKey(Schema.DateTimeUtcFromMillis),
5545
5545
  /**
5546
+ * The key the start request's proof was signed by, which the session's token is bound to: as
5547
+ * the Worker checked the proof, never a field of the request. None for a start with no proof.
5548
+ */
5549
+ cnf: Schema.optionalKey(KeyBinding),
5550
+ /**
5546
5551
  * The generations the session token pins, from the host grant that started it, or its parent;
5547
5552
  * and in an organization's project, for a person's own agent, its attachment's authorization's
5548
5553
  * and the agent's target generation there.
@@ -5743,6 +5748,11 @@ Schema.Struct({
5743
5748
  session: SessionId,
5744
5749
  pins: SessionPins,
5745
5750
  expiresAt: Schema.DateTimeUtcFromMillis,
5751
+ /**
5752
+ * The key the session's start was signed by, which its token is bound to. A parent bound to a
5753
+ * key is bound to this one, or the token isn't registered.
5754
+ */
5755
+ cnf: Schema.optionalKey(KeyBinding),
5746
5756
  cause: Schema.Struct({
5747
5757
  log: ProjectId,
5748
5758
  event: EventId
@@ -14750,7 +14760,10 @@ const HistoryPack = Schema.Struct({
14750
14760
  first: MergeSequence,
14751
14761
  last: MergeSequence
14752
14762
  })),
14753
- /** A baseline or deepen pack: the baseline it belongs to. After a rebuild, a retired baseline's sessions still deepen from its own chain. */
14763
+ /**
14764
+ * A baseline, deepen or rolled-up pack: the baseline it belongs to. After a rebuild, a retired
14765
+ * baseline's sessions still deepen from its own chain, and its rollups hold nothing of the new one's.
14766
+ */
14754
14767
  baseline: Schema.optionalKey(GitRepositoryId),
14755
14768
  /** A deepen pack: how deep its baseline reaches with it and every pack before it. */
14756
14769
  depth: Schema.optionalKey(Depth),
@@ -15379,6 +15392,7 @@ Schema.Union([Schema.Struct({ found: Schema.Literal(false) }), Schema.Struct({
15379
15392
  generation: AttachedSoFar
15380
15393
  })]);
15381
15394
  Context.Service()("ref0/contracts/Caller");
15395
+ Context.Service()("ref0/contracts/ProvedKey");
15382
15396
  /** A browser session's cookie, as `/v1` reads it. */
15383
15397
  const browserSession = HttpApiSecurity.apiKey({
15384
15398
  in: "cookie",
@@ -15387,8 +15401,10 @@ const browserSession = HttpApiSecurity.apiKey({
15387
15401
  /**
15388
15402
  * Every endpoint's credential, checked before its handler runs: a bearer token, a token bound to
15389
15403
  * a key with a proof that key signed (`Authorization: DPoP` and one `DPoP` header), or a browser
15390
- * session's cookie. A request with a cookie and a token is refused before it gets here. A client
15391
- * that signs proofs may fail before it sends anything, when its key store doesn't sign.
15404
+ * session's cookie. A request with a cookie and a token is refused before it gets here. Its
15405
+ * handler learns who's acting, and the key its proof was signed by, if it came with one, which a
15406
+ * session's start binds the session to. A client that signs proofs may fail before it sends
15407
+ * anything, when its key store doesn't sign.
15392
15408
  */
15393
15409
  var Authorization = class extends HttpApiMiddleware.Service()("ref0/contracts/Authorization", {
15394
15410
  requiredForClient: true,
@@ -15430,7 +15446,6 @@ var ApprovalSecret = class extends HttpApiMiddleware.Service()("ref0/contracts/A
15430
15446
  * what to change. Any other part that fails its contract is answered with Effect's empty 400.
15431
15447
  */
15432
15448
  var MachineNameRefusal = class extends HttpApiMiddleware.Service()("ref0/contracts/MachineNameRefusal", { error: CommandRefused }) {};
15433
- Context.Service()("ref0/contracts/ProvedKey");
15434
15449
  /**
15435
15450
  * A request with no credential, signed by a key it names, such as a machine's ask to connect: one
15436
15451
  * `DPoP` header, a proof with no `ath`, since there's no token, checked before the request is
@@ -19303,6 +19318,21 @@ const dpopCredential = (credential) => Layer.mergeAll(HttpApiMiddleware.layerCli
19303
19318
  */
19304
19319
  const boundClient = (credential) => ref0Client(credential.origin).pipe(Effect.provide(dpopCredential(credential)));
19305
19320
  /**
19321
+ * A bound credential's requests made by hand, each with a path under its origin, such as a
19322
+ * command built from its endpoint's route: sent there with its token and a proof its key store
19323
+ * signs afresh for each attempt, as `boundClient` sends its own, and never anywhere else. It needs
19324
+ * the process's `ProofClock`, and its errors hold their requests, as `boundClient`'s do.
19325
+ */
19326
+ const boundSend = (credential) => Effect.gen(function* () {
19327
+ const http = yield* HttpClient.HttpClient;
19328
+ const clock = yield* ProofClock;
19329
+ const attempts = signedAttempts(credential, clock.offset);
19330
+ return (request) => attempts({
19331
+ request,
19332
+ next: (signed) => http.execute(HttpClientRequest.prependUrl(signed, credential.origin))
19333
+ });
19334
+ });
19335
+ /**
19306
19336
  * A request with no credential, signed by the key it names, such as a machine's ask to connect:
19307
19337
  * a proof its key store signs for each attempt, with no `ath`, since there's no token. Any other
19308
19338
  * request is sent with nothing added.
@@ -19316,7 +19346,7 @@ const keyProof = (signer) => Layer.mergeAll(HttpApiMiddleware.layerClient(KeyPro
19316
19346
  const provingClient = (signer) => ref0Client(signer.origin).pipe(Effect.provide(keyProof(signer)));
19317
19347
  //#endregion
19318
19348
  //#region ../sdk/src/client/request-id.ts
19319
- const decodeRequestId$1 = Schema.decodeUnknownEffect(RequestId);
19349
+ const decodeRequestId = Schema.decodeUnknownEffect(RequestId);
19320
19350
  /**
19321
19351
  * A new delivery ID, for a request that changes something's `Idempotency-Key`: a UUIDv7 from the
19322
19352
  * clock and ten random bytes, so a retry sends the same one and gets the first answer.
@@ -19325,7 +19355,7 @@ const newRequestId = Effect.gen(function* () {
19325
19355
  const millis = yield* Clock.currentTimeMillis;
19326
19356
  const random = /* @__PURE__ */ new Uint8Array(10);
19327
19357
  for (let index = 0; index < random.length; index += 1) random[index] = yield* Random.nextIntBetween(0, 255);
19328
- return yield* Effect.orDie(decodeRequestId$1(`request_${encodeSuffix(uuidV7(millis, random))}`));
19358
+ return yield* Effect.orDie(decodeRequestId(`request_${encodeSuffix(uuidV7(millis, random))}`));
19329
19359
  });
19330
19360
  //#endregion
19331
19361
  //#region src/sha256.ts
@@ -19396,6 +19426,317 @@ var Ref0Config = class Ref0Config extends Context.Service()("ref0/cli/Ref0Config
19396
19426
  })).pipe(Layer.provide(NodeServices.layer));
19397
19427
  };
19398
19428
  //#endregion
19429
+ //#region src/keychain.ts
19430
+ /**
19431
+ * The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
19432
+ * makes, and a session's token. Never a file. On macOS through `security`, on Linux through
19433
+ * `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
19434
+ * an argument, since any process on the machine can read another's arguments.
19435
+ */
19436
+ var KeychainError = class extends Schema.TaggedError()("KeychainError", {
19437
+ nextStep: Schema.String,
19438
+ /** The keychain answered, with an item ref0 can't read, rather than not answering. */
19439
+ unreadable: Schema.optionalKey(Schema.Literal(true))
19440
+ }) {};
19441
+ /**
19442
+ * The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
19443
+ * grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
19444
+ */
19445
+ const keychainIn = Effect.fnUntraced(function* (service) {
19446
+ const crypto = yield* Crypto.Crypto;
19447
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
19448
+ const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
19449
+ const unavailable = new KeychainError({ nextStep: macOS ? "The keychain didn’t answer. Unlock it, and try again." : "The keychain didn’t answer. Unlock the keychain or start a secret service, and try again." });
19450
+ const missing = new KeychainError({ nextStep: "The keychain didn’t answer: ref0 needs `secret-tool`, from libsecret. Install `secret-tool` with `sudo apt install libsecret-tools` on Debian or Ubuntu, `sudo dnf install libsecret` on Fedora or `sudo pacman -S libsecret` on Arch." });
19451
+ const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
19452
+ const handle = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: Stream.make(new TextEncoder().encode(stdin)) })).pipe(Effect.mapError((error) => !macOS && Predicate.isTagged(error.reason, "NotFound") ? missing : unavailable));
19453
+ const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
19454
+ const code = yield* handle.exitCode;
19455
+ return {
19456
+ output: output.trim(),
19457
+ complaint: complaint.trim(),
19458
+ code,
19459
+ succeeded: code === ChildProcessSpawner.ExitCode(0)
19460
+ };
19461
+ })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
19462
+ const get = Effect.fn("Keychain.get")(function* (account) {
19463
+ const found = macOS ? yield* run("security", [
19464
+ "find-generic-password",
19465
+ "-s",
19466
+ service,
19467
+ "-a",
19468
+ account,
19469
+ "-w"
19470
+ ], "") : yield* run("secret-tool", [
19471
+ "lookup",
19472
+ "service",
19473
+ service,
19474
+ "account",
19475
+ account
19476
+ ], "");
19477
+ if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
19478
+ if (macOS) {
19479
+ if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
19480
+ return yield* unavailable;
19481
+ }
19482
+ if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
19483
+ const listed = yield* run("secret-tool", [
19484
+ "search",
19485
+ "--all",
19486
+ "service",
19487
+ service,
19488
+ "account",
19489
+ account
19490
+ ], "");
19491
+ if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
19492
+ return Option.none();
19493
+ });
19494
+ const set = Effect.fn("Keychain.set")(function* (account, secret) {
19495
+ const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
19496
+ const value = Redacted.value(secret);
19497
+ if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
19498
+ const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
19499
+ if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
19500
+ if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
19501
+ "store",
19502
+ "--label",
19503
+ `ref0 ${account}`,
19504
+ "service",
19505
+ service,
19506
+ "account",
19507
+ account
19508
+ ], Redacted.value(secret))).succeeded) return yield* unavailable;
19509
+ });
19510
+ const remove = Effect.fn("Keychain.remove")(function* (account) {
19511
+ if (!(yield* macOS ? run("security", [
19512
+ "delete-generic-password",
19513
+ "-s",
19514
+ service,
19515
+ "-a",
19516
+ account
19517
+ ], "") : run("secret-tool", [
19518
+ "clear",
19519
+ "service",
19520
+ service,
19521
+ "account",
19522
+ account
19523
+ ], "")).succeeded) return yield* unavailable;
19524
+ });
19525
+ const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
19526
+ return {
19527
+ probe: Effect.gen(function* () {
19528
+ const account = `connect-probe/${yield* random}`;
19529
+ const value = Redacted.make(yield* random);
19530
+ yield* Effect.gen(function* () {
19531
+ yield* set(account, value);
19532
+ const kept = yield* get(account);
19533
+ if (Option.isNone(kept) || Redacted.value(kept.value) !== Redacted.value(value)) return yield* unavailable;
19534
+ yield* remove(account);
19535
+ }).pipe(Effect.catch((error) => remove(account).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))));
19536
+ }).pipe(Effect.timeoutOrElse({
19537
+ duration: Duration.minutes(1),
19538
+ orElse: () => Effect.fail(unavailable)
19539
+ })),
19540
+ get,
19541
+ set,
19542
+ remove
19543
+ };
19544
+ });
19545
+ var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
19546
+ static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
19547
+ };
19548
+ //#endregion
19549
+ //#region src/machine-key.ts
19550
+ /**
19551
+ * This machine's key, which signs a proof for its ask to connect and for each request a host
19552
+ * grant makes: one ES256 key for each OS account and origin, in the origin's keychain service
19553
+ * under account `machine-key`, as `{ store: 'software', jwk }`. `ref0 connect` makes it, under its
19554
+ * lock, only when the keychain says it holds none, and nothing else makes one. A process reads it
19555
+ * once and keeps it, since each read is a `security` or `secret-tool` process. Its private half
19556
+ * goes to the keychain on stdin and into a Web Crypto key that can't be exported, and nowhere
19557
+ * else: no argument, environment, config, error or log.
19558
+ */
19559
+ /** The keychain account the key is kept under. */
19560
+ const account = "machine-key";
19561
+ /** How long reading and importing the key may take for a request, and signing with it. */
19562
+ const deadline = Duration.seconds(3);
19563
+ /**
19564
+ * How long `ref0 connect` may wait on the keychain to read, make and keep the key, as its probe
19565
+ * may: a prompt to unlock the keychain can take that long.
19566
+ */
19567
+ const connectDeadline = Duration.minutes(1);
19568
+ /** A P-256 coordinate or private scalar: 32 bytes, in base64url. */
19569
+ const Scalar = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/));
19570
+ /** A P-256 private key as a JWK: its public point, and its private scalar `d`. */
19571
+ const PrivateJwk = Schema.Struct({
19572
+ kty: Schema.Literal("EC"),
19573
+ crv: Schema.Literal("P-256"),
19574
+ x: Scalar,
19575
+ y: Scalar,
19576
+ d: Scalar
19577
+ });
19578
+ /** The keychain's record: which store keeps the key, and the key. A hardware store keeps its own. */
19579
+ const KeptKey = Schema.Struct({
19580
+ store: Schema.Literal("software"),
19581
+ jwk: PrivateJwk
19582
+ });
19583
+ const decodeKept$3 = Schema.decodeUnknownOption(Schema.fromJsonString(KeptKey));
19584
+ const encodeKept$3 = Schema.encodeSync(Schema.fromJsonString(KeptKey));
19585
+ const decodePrivateJwk = Schema.decodeUnknownEffect(PrivateJwk);
19586
+ const decodePublicJwk = Schema.decodeSync(PublicJwk);
19587
+ const ecdsa = {
19588
+ name: "ECDSA",
19589
+ namedCurve: "P-256"
19590
+ };
19591
+ const es256 = {
19592
+ name: "ECDSA",
19593
+ hash: "SHA-256"
19594
+ };
19595
+ /** What a key signs to prove it signs for its public half. */
19596
+ const proved = new TextEncoder().encode("ref0 machine key");
19597
+ const timedOut = new KeychainError({ nextStep: "The keychain didn’t answer in time. Check that it’s unlocked, and try again." });
19598
+ const unreadable$1 = new KeychainError({ nextStep: "ref0 can’t read this machine’s key in the keychain, so it signs nothing and makes no other. Update ref0, which may have kept it in a newer form, or repair the keychain, and try again." });
19599
+ const notKept = new KeychainError({ nextStep: "The keychain didn’t keep this machine’s key. Check that it’s unlocked, then run ref0 connect again." });
19600
+ const windows = new KeychainError({ nextStep: "ref0 connect doesn’t run on Windows yet: it keeps this machine’s key in the macOS keychain or a Linux secret service. Run it in WSL instead." });
19601
+ const didNotSign = new KeyStoreUnavailable({ nextStep: "This machine’s key didn’t sign in time. Try again." });
19602
+ /** A fresh key's private JWK, with only the members the record keeps. */
19603
+ const freshKey = Effect.promise(async () => {
19604
+ const pair = await crypto.subtle.generateKey(ecdsa, true, ["sign", "verify"]);
19605
+ return await crypto.subtle.exportKey("jwk", pair.privateKey);
19606
+ }).pipe(Effect.flatMap(({ kty, crv, x, y, d }) => decodePrivateJwk({
19607
+ kty,
19608
+ crv,
19609
+ x,
19610
+ y,
19611
+ d
19612
+ })), Effect.orDie);
19613
+ /**
19614
+ * A key store over a private JWK: imported once, as a key that can't be exported, and proved to
19615
+ * sign for its public half, or it's no key to bind anything to.
19616
+ */
19617
+ const softwareStore = Effect.fnUntraced(function* (jwk) {
19618
+ const publicKey = decodePublicJwk({
19619
+ kty: jwk.kty,
19620
+ crv: jwk.crv,
19621
+ x: jwk.x,
19622
+ y: jwk.y
19623
+ });
19624
+ const signs = yield* Effect.tryPromise({
19625
+ try: async () => {
19626
+ const [signing, verifying] = await Promise.all([crypto.subtle.importKey("jwk", jwk, ecdsa, false, ["sign"]), crypto.subtle.importKey("jwk", publicKey, ecdsa, false, ["verify"])]);
19627
+ const signature = await crypto.subtle.sign(es256, signing, proved);
19628
+ return await crypto.subtle.verify(es256, verifying, signature, proved) ? signing : void 0;
19629
+ },
19630
+ catch: () => unreadable$1
19631
+ });
19632
+ if (signs === void 0) return yield* unreadable$1;
19633
+ const sign = (bytes) => Effect.tryPromise({
19634
+ try: async () => new Uint8Array(await crypto.subtle.sign(es256, signs, new Uint8Array(bytes))),
19635
+ catch: () => didNotSign
19636
+ }).pipe(Effect.timeoutOrElse({
19637
+ duration: deadline,
19638
+ orElse: () => Effect.fail(didNotSign)
19639
+ }));
19640
+ return {
19641
+ store: {
19642
+ publicKey,
19643
+ sign
19644
+ },
19645
+ binding: { jkt: yield* keyThumbprint(publicKey) }
19646
+ };
19647
+ });
19648
+ /**
19649
+ * This machine's key, read once for the process. `supported` refuses a platform with no keychain
19650
+ * that keeps it, such as Windows, before `ref0 connect` asks the keychain anything. `ensure` is
19651
+ * `ref0 connect`'s: the key the keychain holds, or one made and kept now if it says it holds none,
19652
+ * under the connect lock, once the keychain is probed. `boundTo` is for a kept
19653
+ * credential bound to a key: the key it names, read again once if the one held is another, such
19654
+ * as one replaced since, or `InvalidDpopProof`, without sending anything, when the keychain has
19655
+ * none or another. Neither makes a key from an item it can't read.
19656
+ */
19657
+ var MachineKey = class MachineKey extends Context.Service()("ref0/cli/MachineKey") {
19658
+ /** On the platform Node names, such as `win32`, where no keychain keeps the key yet. */
19659
+ static layerOn = (platform) => Layer.effect(MachineKey, Effect.gen(function* () {
19660
+ const keychain = yield* Keychain;
19661
+ const held = yield* Ref.make(Option.none());
19662
+ const reading = yield* Semaphore.make(1);
19663
+ const read = Effect.gen(function* () {
19664
+ const kept = yield* keychain.get(account);
19665
+ if (Option.isNone(kept)) return Option.none();
19666
+ const record = decodeKept$3(Redacted.value(kept.value));
19667
+ if (Option.isNone(record)) return yield* unreadable$1;
19668
+ const key = yield* softwareStore(record.value.jwk);
19669
+ yield* Ref.set(held, Option.some(key));
19670
+ return Option.some(key);
19671
+ });
19672
+ const made = Effect.gen(function* () {
19673
+ const jwk = yield* freshKey;
19674
+ yield* keychain.set(account, Redacted.make(encodeKept$3({
19675
+ store: "software",
19676
+ jwk
19677
+ })));
19678
+ const back = yield* read;
19679
+ const same = Option.exists(back, (key) => key.store.publicKey.x === jwk.x && key.store.publicKey.y === jwk.y);
19680
+ if (Option.isNone(back) || !same) return yield* notKept;
19681
+ return back.value;
19682
+ });
19683
+ const supported = platform === "win32" ? Effect.fail(windows) : Effect.void;
19684
+ const ensure = Effect.gen(function* () {
19685
+ const kept = yield* read;
19686
+ return Option.isSome(kept) ? kept.value : yield* made;
19687
+ }).pipe(Effect.timeoutOrElse({
19688
+ duration: connectDeadline,
19689
+ orElse: () => Effect.fail(timedOut)
19690
+ }), reading.withPermits(1));
19691
+ const boundTo = (binding) => Effect.gen(function* () {
19692
+ const now = yield* Ref.get(held);
19693
+ if (Option.isSome(now) && now.value.binding.jkt === binding.jkt) return now.value.store;
19694
+ const kept = yield* read.pipe(Effect.timeoutOrElse({
19695
+ duration: deadline,
19696
+ orElse: () => Effect.fail(timedOut)
19697
+ }));
19698
+ if (Option.isSome(kept) && kept.value.binding.jkt === binding.jkt) return kept.value.store;
19699
+ return yield* invalidDpopProof;
19700
+ }).pipe(reading.withPermits(1));
19701
+ return MachineKey.of({
19702
+ supported,
19703
+ ensure,
19704
+ boundTo
19705
+ });
19706
+ }));
19707
+ static layer = MachineKey.layerOn(process.platform);
19708
+ };
19709
+ //#endregion
19710
+ //#region src/kept-client.ts
19711
+ const decodeProofOrigin$1 = Schema.decodeUnknownEffect(ProofOrigin);
19712
+ /** A bound credential: its token, at ref0's origin, with the key it's bound to, read once. */
19713
+ const boundTo = Effect.fnUntraced(function* (kept, cnf) {
19714
+ const config = yield* Ref0Config;
19715
+ const store = yield* (yield* MachineKey).boundTo(cnf);
19716
+ return {
19717
+ origin: yield* Effect.orDie(decodeProofOrigin$1(config.api)),
19718
+ store,
19719
+ token: kept.token
19720
+ };
19721
+ });
19722
+ /** ref0's `/v1` as a typed client acting with a kept credential. */
19723
+ const keptClient = Effect.fnUntraced(function* (kept) {
19724
+ if (kept.cnf === void 0) {
19725
+ const config = yield* Ref0Config;
19726
+ return yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(kept.token)));
19727
+ }
19728
+ return yield* boundClient(yield* boundTo(kept, kept.cnf));
19729
+ });
19730
+ /** Requests made by hand, each with a path under ref0's origin, sent with a kept credential. */
19731
+ const keptSend = Effect.fnUntraced(function* (kept) {
19732
+ if (kept.cnf === void 0) {
19733
+ const config = yield* Ref0Config;
19734
+ const http = yield* HttpClient.HttpClient;
19735
+ return (request) => http.execute(HttpClientRequest.prependUrl(HttpClientRequest.bearerToken(request, kept.token), config.api));
19736
+ }
19737
+ return yield* boundSend(yield* boundTo(kept, kept.cnf));
19738
+ });
19739
+ //#endregion
19399
19740
  //#region src/secret.ts
19400
19741
  const hashed = (secret) => Effect.gen(function* () {
19401
19742
  const digest = yield* Effect.promise(async () => await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)));
@@ -19598,15 +19939,36 @@ const sayFailed = (words) => printBlocks(Predicate.isString(words) ? [[words]] :
19598
19939
  //#endregion
19599
19940
  //#region src/session.ts
19600
19941
  /**
19942
+ * A session's credential as this machine keeps it, in one keychain write: its token, its
19943
+ * session, the key it's bound to, if it is, and the host grant its chain started from, by its
19944
+ * ID, so a grant kept since tells whether it's the one that started it.
19945
+ */
19946
+ const KeptSession = Schema.Struct({
19947
+ token: Schema.RedactedFromValue(Schema.String),
19948
+ session: SessionId,
19949
+ /** This machine's key, by its thumbprint, when ref0 bound the session to it. */
19950
+ cnf: Schema.optional(KeyBinding),
19951
+ hostGrant: CredentialRef
19952
+ });
19953
+ const decodeKeptSession = Schema.decodeUnknownOption(Schema.fromJsonString(KeptSession));
19954
+ const encodeKeptSession = Schema.encodeSync(Schema.fromJsonString(KeptSession));
19955
+ /**
19956
+ * A kept session's record, from the keychain's value: none for one this ref0 can't read, such as
19957
+ * the bare token an earlier ref0 kept, which the next prompt replaces with a session of its own.
19958
+ */
19959
+ const readKeptSession = (value) => Option.flatMap(value, (kept) => decodeKeptSession(Redacted.value(kept)));
19960
+ /** A kept session's record, as the keychain keeps it. */
19961
+ const keptSessionValue = (kept) => Redacted.make(encodeKeptSession(kept));
19962
+ /**
19601
19963
  * An agent's command in a harness session that has no ref0 session, though an agent is connected
19602
19964
  * for its config folder: why, as far as ref0 can tell, and what to do. It never runs as its
19603
19965
  * person instead.
19604
19966
  */
19605
19967
  var NoSession = class extends Schema.TaggedError()("NoSession", { nextStep: Schema.String }) {};
19606
19968
  /**
19607
- * The token of the session this command runs in, if it runs in one: an agent's, which its
19608
- * harness bound when the session started. Where it's found is the harness's to say. Outside a
19609
- * session, there's none, and every command is the person's, asked by approval.
19969
+ * The session this command runs in, as its credential is kept, if it runs in one: an agent's,
19970
+ * which its harness bound when the session started. Where it's found is the harness's to say.
19971
+ * Outside a session, there's none, and every command is the person's, asked by approval.
19610
19972
  */
19611
19973
  var SessionToken = class SessionToken extends Context.Service()("ref0/cli/SessionToken") {
19612
19974
  /** No session: until a harness binds one, the person asks, by approval. */
@@ -19623,9 +19985,11 @@ var SessionThread = class SessionThread extends Context.Service()("ref0/cli/Sess
19623
19985
  //#endregion
19624
19986
  //#region src/call.ts
19625
19987
  /**
19626
- * Every command's one way to ref0: an endpoint's request, sent with the session's token when
19627
- * the command runs in a session, or else asked of the person, by approval, from this terminal.
19628
- * Either way the answer is the endpoint's own, as JSON.
19988
+ * Every command's one way to ref0: an endpoint's request, sent as the session when the command
19989
+ * runs in one, signed with this machine's key when the session's bound to it, or else asked of
19990
+ * the person, by approval, from this terminal. Either way the answer is the endpoint's own, as
19991
+ * JSON. A session's request its key can't sign fails here, as the session's: never asked of the
19992
+ * person instead.
19629
19993
  */
19630
19994
  /** What ref0 said no to, or what went wrong on the way: its status, its kind, and what to do. */
19631
19995
  var CallFailed = class extends Schema.TaggedError()("CallFailed", {
@@ -19682,7 +20046,7 @@ const refusedBy = (status, refusal, change) => new CallFailed({
19682
20046
  * An answer none of its endpoint's schemas reads: another endpoint's, or one a bug in ref0 or
19683
20047
  * the CLI made. A change it answered may have run.
19684
20048
  */
19685
- const unreadable$1 = (endpoint, change) => new CallFailed({
20049
+ const unreadable = (endpoint, change) => new CallFailed({
19686
20050
  status: 0,
19687
20051
  kind: "Unexpected",
19688
20052
  nextStep: change ? `ref0’s answer to ${endpoint} isn’t one that endpoint gives, so it may have run. Check before you run it again.` : `ref0’s answer to ${endpoint} isn’t one that endpoint gives. Try again.`
@@ -19731,7 +20095,7 @@ const waitForAnswer = Effect.fnUntraced(function* (collect, expiresAt, change, e
19731
20095
  const state = answered.value;
19732
20096
  heardAt = now;
19733
20097
  if (state.state === "ran") return yield* Option.match(ranOf(endpoint, state), {
19734
- onNone: () => Effect.fail(unreadable$1(endpoint, change)),
20098
+ onNone: () => Effect.fail(unreadable(endpoint, change)),
19735
20099
  onSome: Result.match({
19736
20100
  onSuccess: () => Effect.succeed(state.result),
19737
20101
  onFailure: (refusal) => Effect.fail(refusedBy(state.status, refusal, change))
@@ -19761,42 +20125,52 @@ var Ref0 = class Ref0 extends Context.Service()("ref0/cli/Ref0") {
19761
20125
  const config = yield* Ref0Config;
19762
20126
  const session = yield* SessionToken;
19763
20127
  const http = yield* HttpClient.HttpClient;
19764
- const asSession = Effect.fnUntraced(function* (token, request, endpoint) {
20128
+ const signing = yield* Effect.context();
20129
+ const asSession = Effect.fnUntraced(function* (kept, request, endpoint) {
19765
20130
  const timeout = timeoutOf(request.command.endpoint);
19766
- const base = HttpClientRequest.make(request.method)(`${config.api}${request.url}`).pipe(HttpClientRequest.bearerToken(token), HttpClientRequest.setHeader("idempotency-key", yield* newRequestId));
20131
+ const send = yield* keptSend(kept).pipe(Effect.provide(signing), Effect.provideService(Ref0Config, config), Effect.provideService(HttpClient.HttpClient, http), Effect.mapError((error) => new CallFailed({
20132
+ status: 0,
20133
+ kind: error._tag,
20134
+ nextStep: error.nextStep
20135
+ })));
20136
+ const base = HttpClientRequest.make(request.method)(request.url).pipe(HttpClientRequest.setHeader("idempotency-key", yield* newRequestId));
19767
20137
  const sent = request.body === void 0 ? base : HttpClientRequest.bodyText(base, request.body, "application/json");
19768
20138
  const change = request.method !== "GET";
19769
20139
  const lost = change ? unanswered$1 : unreachable;
19770
- const { response, text } = yield* http.execute(sent).pipe(Effect.flatMap((response) => Effect.map(response.text, (text) => ({
20140
+ const { response, text } = yield* send(sent).pipe(Effect.flatMap((response) => Effect.map(response.text, (text) => ({
19771
20141
  response,
19772
20142
  text
20143
+ }))), Effect.catchTag("KeyStoreUnavailable", (unsigned) => Effect.fail(new CallFailed({
20144
+ status: 0,
20145
+ kind: unsigned._tag,
20146
+ nextStep: unsigned.nextStep
19773
20147
  }))), Effect.timeoutOrElse({
19774
20148
  duration: timeout,
19775
20149
  orElse: () => Effect.fail(lost)
19776
- }), Effect.mapError(() => lost));
20150
+ }), Effect.mapError((error) => error instanceof CallFailed ? error : lost));
19777
20151
  const body = text.length === 0 ? null : yield* Effect.orElseSucceed(decodeJson$1(text), () => null);
19778
20152
  if (response.status === 202) {
19779
20153
  const needed = decodeNeeded(body);
19780
20154
  if (Option.isNone(needed)) return yield* failedWith$16(202, body, change);
19781
20155
  yield* printBlocks([[`Waiting for your person to approve it under Waiting on you, at ${config.api}${webPages.home}: ${needed.value.says}.`]]);
19782
- return yield* waitForAnswer(yield* collectAsSession(token, needed.value.approval, change), needed.value.expiresAt, change, endpoint);
20156
+ return yield* waitForAnswer(yield* collectAsSession(kept, needed.value.approval, change), needed.value.expiresAt, change, endpoint);
19783
20157
  }
19784
20158
  return response.status < 300 ? body : yield* failedWith$16(response.status, body, change);
19785
20159
  });
19786
- const collectAsSession = Effect.fnUntraced(function* (token, approval, change) {
19787
- const client = yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(token)), Effect.provideService(HttpClient.HttpClient, http));
20160
+ const collectAsSession = Effect.fnUntraced(function* (kept, approval, change) {
19788
20161
  const refused = (status) => (error) => Effect.fail(new CallFailed({
19789
20162
  status,
19790
20163
  kind: error._tag,
19791
20164
  nextStep: error.nextStep
19792
20165
  }));
19793
- return client.approvals.get({ params: { approval } }).pipe(Effect.map(Option.some), Effect.timeoutOrElse({
20166
+ return (yield* keptClient(kept).pipe(Effect.provide(signing), Effect.provideService(Ref0Config, config), Effect.provideService(HttpClient.HttpClient, http), Effect.catch(refused(0)))).approvals.get({ params: { approval } }).pipe(Effect.map(Option.some), Effect.timeoutOrElse({
19794
20167
  duration: requestTimeout$1,
19795
20168
  orElse: () => Effect.succeedNone
19796
20169
  }), Effect.catchTags({
19797
20170
  ApprovalNotFound: (missing) => Effect.fail(gone(change, missing.nextStep)),
19798
20171
  Unauthenticated: refused(401),
19799
- SessionHasEnded: refused(410)
20172
+ SessionHasEnded: refused(410),
20173
+ InvalidDpopProof: refused(401)
19800
20174
  }), Effect.catch((error) => error instanceof CallFailed ? Effect.fail(error) : Effect.succeedNone));
19801
20175
  });
19802
20176
  const asPerson = Effect.fnUntraced(function* (command, change, endpoint) {
@@ -19833,22 +20207,22 @@ var Ref0 = class Ref0 extends Context.Service()("ref0/cli/Ref0") {
19833
20207
  return yield* waitForAnswer(collect, started.expiresAt, change, endpoint);
19834
20208
  });
19835
20209
  const call = Effect.fn("Ref0.call")(function* (command) {
19836
- const token = yield* session.current.pipe(Effect.mapError((error) => new CallFailed({
20210
+ const kept = yield* session.current.pipe(Effect.mapError((error) => new CallFailed({
19837
20211
  status: 0,
19838
20212
  kind: error._tag,
19839
20213
  nextStep: error.nextStep
19840
20214
  })));
19841
- const request = yield* (Option.isSome(token) ? commandRequest(command) : approvalRequest(command)).pipe(Effect.mapError((refused) => new CallFailed({
20215
+ const request = yield* (Option.isSome(kept) ? commandRequest(command) : approvalRequest(command)).pipe(Effect.mapError((refused) => new CallFailed({
19842
20216
  status: 400,
19843
20217
  kind: refused._tag,
19844
20218
  nextStep: refused.nextStep
19845
20219
  })));
19846
20220
  const change = request.method !== "GET";
19847
- const answer = yield* Option.match(token, {
20221
+ const answer = yield* Option.match(kept, {
19848
20222
  onNone: () => asPerson(request.command, change, command.endpoint),
19849
- onSome: (bearer) => asSession(bearer, request, command.endpoint)
20223
+ onSome: (one) => asSession(one, request, command.endpoint)
19850
20224
  });
19851
- if (!isSuccessOf(request.command.endpoint, answer)) return yield* unreadable$1(request.command.endpoint, change);
20225
+ if (!isSuccessOf(request.command.endpoint, answer)) return yield* unreadable(request.command.endpoint, change);
19852
20226
  return answer;
19853
20227
  });
19854
20228
  return Ref0.of({ call });
@@ -19863,7 +20237,7 @@ const callEndpoint = (command) => Effect.gen(function* () {
19863
20237
  return {
19864
20238
  json,
19865
20239
  value: yield* Option.match(successOf(command.endpoint, json), {
19866
- onNone: () => Effect.fail(unreadable$1(command.endpoint, false)),
20240
+ onNone: () => Effect.fail(unreadable(command.endpoint, false)),
19867
20241
  onSome: Effect.succeed
19868
20242
  })
19869
20243
  };
@@ -21528,287 +21902,6 @@ const connectLineFor = Effect.fnUntraced(function* (harness, configFolder) {
21528
21902
  return `${configFolders[harness].variable}=${shellWord(configFolder)} ref0 connect`;
21529
21903
  });
21530
21904
  //#endregion
21531
- //#region src/keychain.ts
21532
- /**
21533
- * The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
21534
- * makes, and a session's token. Never a file. On macOS through `security`, on Linux through
21535
- * `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
21536
- * an argument, since any process on the machine can read another's arguments.
21537
- */
21538
- var KeychainError = class extends Schema.TaggedError()("KeychainError", {
21539
- nextStep: Schema.String,
21540
- /** The keychain answered, with an item ref0 can't read, rather than not answering. */
21541
- unreadable: Schema.optionalKey(Schema.Literal(true))
21542
- }) {};
21543
- /**
21544
- * The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
21545
- * grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
21546
- */
21547
- const keychainIn = Effect.fnUntraced(function* (service) {
21548
- const crypto = yield* Crypto.Crypto;
21549
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
21550
- const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
21551
- const unavailable = new KeychainError({ nextStep: macOS ? "The keychain didn’t answer. Unlock it, and try again." : "The keychain didn’t answer. Unlock the keychain or start a secret service, and try again." });
21552
- const missing = new KeychainError({ nextStep: "The keychain didn’t answer: ref0 needs `secret-tool`, from libsecret. Install `secret-tool` with `sudo apt install libsecret-tools` on Debian or Ubuntu, `sudo dnf install libsecret` on Fedora or `sudo pacman -S libsecret` on Arch." });
21553
- const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
21554
- const handle = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: Stream.make(new TextEncoder().encode(stdin)) })).pipe(Effect.mapError((error) => !macOS && Predicate.isTagged(error.reason, "NotFound") ? missing : unavailable));
21555
- const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
21556
- const code = yield* handle.exitCode;
21557
- return {
21558
- output: output.trim(),
21559
- complaint: complaint.trim(),
21560
- code,
21561
- succeeded: code === ChildProcessSpawner.ExitCode(0)
21562
- };
21563
- })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
21564
- const get = Effect.fn("Keychain.get")(function* (account) {
21565
- const found = macOS ? yield* run("security", [
21566
- "find-generic-password",
21567
- "-s",
21568
- service,
21569
- "-a",
21570
- account,
21571
- "-w"
21572
- ], "") : yield* run("secret-tool", [
21573
- "lookup",
21574
- "service",
21575
- service,
21576
- "account",
21577
- account
21578
- ], "");
21579
- if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
21580
- if (macOS) {
21581
- if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
21582
- return yield* unavailable;
21583
- }
21584
- if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
21585
- const listed = yield* run("secret-tool", [
21586
- "search",
21587
- "--all",
21588
- "service",
21589
- service,
21590
- "account",
21591
- account
21592
- ], "");
21593
- if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
21594
- return Option.none();
21595
- });
21596
- const set = Effect.fn("Keychain.set")(function* (account, secret) {
21597
- const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
21598
- const value = Redacted.value(secret);
21599
- if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
21600
- const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
21601
- if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
21602
- if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
21603
- "store",
21604
- "--label",
21605
- `ref0 ${account}`,
21606
- "service",
21607
- service,
21608
- "account",
21609
- account
21610
- ], Redacted.value(secret))).succeeded) return yield* unavailable;
21611
- });
21612
- const remove = Effect.fn("Keychain.remove")(function* (account) {
21613
- if (!(yield* macOS ? run("security", [
21614
- "delete-generic-password",
21615
- "-s",
21616
- service,
21617
- "-a",
21618
- account
21619
- ], "") : run("secret-tool", [
21620
- "clear",
21621
- "service",
21622
- service,
21623
- "account",
21624
- account
21625
- ], "")).succeeded) return yield* unavailable;
21626
- });
21627
- const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
21628
- return {
21629
- probe: Effect.gen(function* () {
21630
- const account = `connect-probe/${yield* random}`;
21631
- const value = Redacted.make(yield* random);
21632
- yield* Effect.gen(function* () {
21633
- yield* set(account, value);
21634
- const kept = yield* get(account);
21635
- if (Option.isNone(kept) || Redacted.value(kept.value) !== Redacted.value(value)) return yield* unavailable;
21636
- yield* remove(account);
21637
- }).pipe(Effect.catch((error) => remove(account).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))));
21638
- }).pipe(Effect.timeoutOrElse({
21639
- duration: Duration.minutes(1),
21640
- orElse: () => Effect.fail(unavailable)
21641
- })),
21642
- get,
21643
- set,
21644
- remove
21645
- };
21646
- });
21647
- var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
21648
- static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
21649
- };
21650
- //#endregion
21651
- //#region src/machine-key.ts
21652
- /**
21653
- * This machine's key, which signs a proof for its ask to connect and for each request a host
21654
- * grant makes: one ES256 key for each OS account and origin, in the origin's keychain service
21655
- * under account `machine-key`, as `{ store: 'software', jwk }`. `ref0 connect` makes it, under its
21656
- * lock, only when the keychain says it holds none, and nothing else makes one. A process reads it
21657
- * once and keeps it, since each read is a `security` or `secret-tool` process. Its private half
21658
- * goes to the keychain on stdin and into a Web Crypto key that can't be exported, and nowhere
21659
- * else: no argument, environment, config, error or log.
21660
- */
21661
- /** The keychain account the key is kept under. */
21662
- const account = "machine-key";
21663
- /** How long reading and importing the key may take for a request, and signing with it. */
21664
- const deadline = Duration.seconds(3);
21665
- /**
21666
- * How long `ref0 connect` may wait on the keychain to read, make and keep the key, as its probe
21667
- * may: a prompt to unlock the keychain can take that long.
21668
- */
21669
- const connectDeadline = Duration.minutes(1);
21670
- /** A P-256 coordinate or private scalar: 32 bytes, in base64url. */
21671
- const Scalar = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/));
21672
- /** A P-256 private key as a JWK: its public point, and its private scalar `d`. */
21673
- const PrivateJwk = Schema.Struct({
21674
- kty: Schema.Literal("EC"),
21675
- crv: Schema.Literal("P-256"),
21676
- x: Scalar,
21677
- y: Scalar,
21678
- d: Scalar
21679
- });
21680
- /** The keychain's record: which store keeps the key, and the key. A hardware store keeps its own. */
21681
- const KeptKey = Schema.Struct({
21682
- store: Schema.Literal("software"),
21683
- jwk: PrivateJwk
21684
- });
21685
- const decodeKept$3 = Schema.decodeUnknownOption(Schema.fromJsonString(KeptKey));
21686
- const encodeKept$3 = Schema.encodeSync(Schema.fromJsonString(KeptKey));
21687
- const decodePrivateJwk = Schema.decodeUnknownEffect(PrivateJwk);
21688
- const decodePublicJwk = Schema.decodeSync(PublicJwk);
21689
- const ecdsa = {
21690
- name: "ECDSA",
21691
- namedCurve: "P-256"
21692
- };
21693
- const es256 = {
21694
- name: "ECDSA",
21695
- hash: "SHA-256"
21696
- };
21697
- /** What a key signs to prove it signs for its public half. */
21698
- const proved = new TextEncoder().encode("ref0 machine key");
21699
- const timedOut = new KeychainError({ nextStep: "The keychain didn’t answer in time. Check that it’s unlocked, and try again." });
21700
- const unreadable = new KeychainError({ nextStep: "ref0 can’t read this machine’s key in the keychain, so it signs nothing and makes no other. Update ref0, which may have kept it in a newer form, or repair the keychain, and try again." });
21701
- const notKept = new KeychainError({ nextStep: "The keychain didn’t keep this machine’s key. Check that it’s unlocked, then run ref0 connect again." });
21702
- const windows = new KeychainError({ nextStep: "ref0 connect doesn’t run on Windows yet: it keeps this machine’s key in the macOS keychain or a Linux secret service. Run it in WSL instead." });
21703
- const didNotSign = new KeyStoreUnavailable({ nextStep: "This machine’s key didn’t sign in time. Try again." });
21704
- /** A fresh key's private JWK, with only the members the record keeps. */
21705
- const freshKey = Effect.promise(async () => {
21706
- const pair = await crypto.subtle.generateKey(ecdsa, true, ["sign", "verify"]);
21707
- return await crypto.subtle.exportKey("jwk", pair.privateKey);
21708
- }).pipe(Effect.flatMap(({ kty, crv, x, y, d }) => decodePrivateJwk({
21709
- kty,
21710
- crv,
21711
- x,
21712
- y,
21713
- d
21714
- })), Effect.orDie);
21715
- /**
21716
- * A key store over a private JWK: imported once, as a key that can't be exported, and proved to
21717
- * sign for its public half, or it's no key to bind anything to.
21718
- */
21719
- const softwareStore = Effect.fnUntraced(function* (jwk) {
21720
- const publicKey = decodePublicJwk({
21721
- kty: jwk.kty,
21722
- crv: jwk.crv,
21723
- x: jwk.x,
21724
- y: jwk.y
21725
- });
21726
- const signs = yield* Effect.tryPromise({
21727
- try: async () => {
21728
- const [signing, verifying] = await Promise.all([crypto.subtle.importKey("jwk", jwk, ecdsa, false, ["sign"]), crypto.subtle.importKey("jwk", publicKey, ecdsa, false, ["verify"])]);
21729
- const signature = await crypto.subtle.sign(es256, signing, proved);
21730
- return await crypto.subtle.verify(es256, verifying, signature, proved) ? signing : void 0;
21731
- },
21732
- catch: () => unreadable
21733
- });
21734
- if (signs === void 0) return yield* unreadable;
21735
- const sign = (bytes) => Effect.tryPromise({
21736
- try: async () => new Uint8Array(await crypto.subtle.sign(es256, signs, new Uint8Array(bytes))),
21737
- catch: () => didNotSign
21738
- }).pipe(Effect.timeoutOrElse({
21739
- duration: deadline,
21740
- orElse: () => Effect.fail(didNotSign)
21741
- }));
21742
- return {
21743
- store: {
21744
- publicKey,
21745
- sign
21746
- },
21747
- binding: { jkt: yield* keyThumbprint(publicKey) }
21748
- };
21749
- });
21750
- /**
21751
- * This machine's key, read once for the process. `supported` refuses a platform with no keychain
21752
- * that keeps it, such as Windows, before `ref0 connect` asks the keychain anything. `ensure` is
21753
- * `ref0 connect`'s: the key the keychain holds, or one made and kept now if it says it holds none,
21754
- * under the connect lock, once the keychain is probed. `boundTo` is for a kept
21755
- * credential bound to a key: the key it names, read again once if the one held is another, such
21756
- * as one replaced since, or `InvalidDpopProof`, without sending anything, when the keychain has
21757
- * none or another. Neither makes a key from an item it can't read.
21758
- */
21759
- var MachineKey = class MachineKey extends Context.Service()("ref0/cli/MachineKey") {
21760
- /** On the platform Node names, such as `win32`, where no keychain keeps the key yet. */
21761
- static layerOn = (platform) => Layer.effect(MachineKey, Effect.gen(function* () {
21762
- const keychain = yield* Keychain;
21763
- const held = yield* Ref.make(Option.none());
21764
- const reading = yield* Semaphore.make(1);
21765
- const read = Effect.gen(function* () {
21766
- const kept = yield* keychain.get(account);
21767
- if (Option.isNone(kept)) return Option.none();
21768
- const record = decodeKept$3(Redacted.value(kept.value));
21769
- if (Option.isNone(record)) return yield* unreadable;
21770
- const key = yield* softwareStore(record.value.jwk);
21771
- yield* Ref.set(held, Option.some(key));
21772
- return Option.some(key);
21773
- });
21774
- const made = Effect.gen(function* () {
21775
- const jwk = yield* freshKey;
21776
- yield* keychain.set(account, Redacted.make(encodeKept$3({
21777
- store: "software",
21778
- jwk
21779
- })));
21780
- const back = yield* read;
21781
- const same = Option.exists(back, (key) => key.store.publicKey.x === jwk.x && key.store.publicKey.y === jwk.y);
21782
- if (Option.isNone(back) || !same) return yield* notKept;
21783
- return back.value;
21784
- });
21785
- const supported = platform === "win32" ? Effect.fail(windows) : Effect.void;
21786
- const ensure = Effect.gen(function* () {
21787
- const kept = yield* read;
21788
- return Option.isSome(kept) ? kept.value : yield* made;
21789
- }).pipe(Effect.timeoutOrElse({
21790
- duration: connectDeadline,
21791
- orElse: () => Effect.fail(timedOut)
21792
- }), reading.withPermits(1));
21793
- const boundTo = (binding) => Effect.gen(function* () {
21794
- const now = yield* Ref.get(held);
21795
- if (Option.isSome(now) && now.value.binding.jkt === binding.jkt) return now.value.store;
21796
- const kept = yield* read.pipe(Effect.timeoutOrElse({
21797
- duration: deadline,
21798
- orElse: () => Effect.fail(timedOut)
21799
- }));
21800
- if (Option.isSome(kept) && kept.value.binding.jkt === binding.jkt) return kept.value.store;
21801
- return yield* invalidDpopProof;
21802
- }).pipe(reading.withPermits(1));
21803
- return MachineKey.of({
21804
- supported,
21805
- ensure,
21806
- boundTo
21807
- });
21808
- }));
21809
- static layer = MachineKey.layerOn(process.platform);
21810
- };
21811
- //#endregion
21812
21905
  //#region src/host-grant.ts
21813
21906
  /**
21814
21907
  * All lasting authority for one config folder, stored as one keychain value: the agent the folder
@@ -21832,6 +21925,15 @@ const encodeGrant = Schema.encodeSync(Schema.fromJsonString(KeptHostGrant));
21832
21925
  * read as a grant, such as one an earlier ref0 kept, still names the grant it held.
21833
21926
  */
21834
21927
  const decodeHeldToken = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ token: KeptHostGrant.fields.token })));
21928
+ const decodeCredentialRef = Schema.decodeUnknownOption(CredentialRef);
21929
+ /**
21930
+ * A kept grant by its locator, whose it is and which row, as its token names them: what a
21931
+ * session it started, and a start it authorized, are kept beside. None for a token that isn't one.
21932
+ */
21933
+ const grantRef = (grant) => Option.flatMap(parseToken(Redacted.value(grant.token)), (parts) => decodeCredentialRef({
21934
+ person: parts.person,
21935
+ id: parts.token
21936
+ }));
21835
21937
  /** Grants in the origin’s keychain service, account host-grant/<resolved config folder>. */
21836
21938
  var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant") {
21837
21939
  static layer = Layer.effect(HostGrant, Effect.gen(function* () {
@@ -21867,24 +21969,6 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
21867
21969
  }
21868
21970
  return Option.none();
21869
21971
  });
21870
- const decodeProofOrigin$1 = Schema.decodeUnknownEffect(ProofOrigin);
21871
- /**
21872
- * ref0's `/v1` as a typed client acting with a kept host grant: with a proof this machine's key
21873
- * signs for each request when the grant is bound to it, or as a bearer token when it's a grant
21874
- * from before binding. A grant bound to a key the keychain no longer holds is refused here, with
21875
- * `InvalidDpopProof`, and nothing is sent.
21876
- */
21877
- const grantClient = Effect.fnUntraced(function* (grant) {
21878
- const config = yield* Ref0Config;
21879
- if (grant.cnf === void 0) return yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(grant.token)));
21880
- const store = yield* (yield* MachineKey).boundTo(grant.cnf);
21881
- const origin = yield* Effect.orDie(decodeProofOrigin$1(config.api));
21882
- return yield* boundClient({
21883
- origin,
21884
- store,
21885
- token: grant.token
21886
- });
21887
- });
21888
21972
  //#endregion
21889
21973
  //#region src/version.ts
21890
21974
  /**
@@ -21893,7 +21977,7 @@ const grantClient = Effect.fnUntraced(function* (grant) {
21893
21977
  * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
21894
21978
  * and only as a default export.
21895
21979
  */
21896
- const cliVersion = "0.13.0";
21980
+ const cliVersion = "0.14.0";
21897
21981
  //#endregion
21898
21982
  //#region src/harness/hook-protocol.ts
21899
21983
  /**
@@ -27203,10 +27287,9 @@ const gitTokenOf = Effect.fnUntraced(function* (binding) {
27203
27287
  const keychain = yield* Keychain;
27204
27288
  const kept = yield* keychain.get(gitTokenAccount(binding));
27205
27289
  if (Option.isSome(kept)) return kept;
27206
- const sessionToken = yield* keychain.get(sessionAccount(binding));
27207
- if (Option.isNone(sessionToken)) return Option.none();
27208
- const config = yield* Ref0Config;
27209
- const client = yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(sessionToken.value)));
27290
+ const session = readKeptSession(yield* keychain.get(sessionAccount(binding)));
27291
+ if (Option.isNone(session)) return Option.none();
27292
+ const client = yield* keptClient(session.value);
27210
27293
  const me = yield* client.me.get();
27211
27294
  if (me.session === void 0) return Option.none();
27212
27295
  const { secret, hash } = yield* freshTokenSecret;
@@ -27344,12 +27427,11 @@ const trimShallow = Effect.fnUntraced(function* (gitDir) {
27344
27427
  * attempts the merge: the checkout's own for `ref0 sync`, or the local check's scratch one.
27345
27428
  */
27346
27429
  const loadSyncHistory = Effect.fnUntraced(function* (gitDir, repo) {
27347
- const config = yield* Ref0Config;
27348
- const token = yield* (yield* SessionToken).current.pipe(Effect.catchTag("NoSession", () => Effect.succeedNone));
27349
- if (Option.isNone(token)) return yield* new HistoryNotLoaded({ why: "this terminal has no session to load it as" });
27430
+ const kept = yield* (yield* SessionToken).current.pipe(Effect.catchTag("NoSession", () => Effect.succeedNone));
27431
+ if (Option.isNone(kept)) return yield* new HistoryNotLoaded({ why: "this terminal has no session to load it as" });
27350
27432
  const fs = yield* FileSystem.FileSystem;
27351
27433
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
27352
- const client = yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(token.value)));
27434
+ const client = yield* keptClient(kept.value);
27353
27435
  const params = yield* decodePlace(repoParts(repo));
27354
27436
  const manifest = yield* client.repos.manifest({ params });
27355
27437
  const temp = yield* fs.makeTempDirectoryScoped();
@@ -30578,7 +30660,7 @@ const watchCommands = [Command.make("watch", {
30578
30660
  owner: ownerName,
30579
30661
  agent: flags.agent
30580
30662
  });
30581
- const client = yield* grantClient(grant.value);
30663
+ const client = yield* keptClient(grant.value);
30582
30664
  const watching = {
30583
30665
  project: flags.project,
30584
30666
  parent: Option.getOrUndefined(flags.parent),
@@ -32452,7 +32534,7 @@ var BaseChecks = class BaseChecks extends Context.Service()("ref0/cli/BaseChecks
32452
32534
  ], { stdin: "ignore" })).pipe(Effect.map((code) => Number(code) === 0), Effect.orElseSucceed(() => false));
32453
32535
  return BaseChecks.of({
32454
32536
  settingsAt,
32455
- check: (top, base, delivered, within, token) => checkBase(top, base, delivered, within).pipe(Effect.provide(context), Effect.provideService(SessionToken, SessionToken.of({ current: Effect.succeedSome(token) }))),
32537
+ check: (top, base, delivered, within, session) => checkBase(top, base, delivered, within).pipe(Effect.provide(context), Effect.provideService(SessionToken, SessionToken.of({ current: Effect.succeedSome(session) }))),
32456
32538
  holds
32457
32539
  });
32458
32540
  }));
@@ -32505,10 +32587,10 @@ const baseWatch = Effect.fnUntraced(function* (edges) {
32505
32587
  return;
32506
32588
  }
32507
32589
  if (Option.isNone(repo)) return;
32508
- const read = yield* (yield* clientOf(bound.token)).sessions.base({ params: {
32590
+ const read = yield* clientOf(bound.kept).pipe(Effect.flatMap((client) => client.sessions.base({ params: {
32509
32591
  session: bound.session,
32510
32592
  repo: repo.value
32511
- } }).pipe(Effect.option);
32593
+ } })), Effect.option);
32512
32594
  if (Option.isNone(read)) return;
32513
32595
  const base = read.value;
32514
32596
  const mark = {
@@ -32534,7 +32616,7 @@ const baseWatch = Effect.fnUntraced(function* (edges) {
32534
32616
  commit: base.commit,
32535
32617
  url,
32536
32618
  ref: base.ref
32537
- }, delivered, clone.within, bound.token).pipe(Effect.option);
32619
+ }, delivered, clone.within, bound.kept).pipe(Effect.option);
32538
32620
  if (Option.isNone(checked)) return;
32539
32621
  const notice = checked.value.kind === "compared" ? checked.value.notice : Option.none();
32540
32622
  yield* Ref.update(watched, (was) => was.top !== clone.top ? was : {
@@ -34157,28 +34239,31 @@ function readHook(args) {
34157
34239
  * `ref0 clone` writes), or, with none, on a new thread under a plain title, such as "Claude Code
34158
34240
  * in shop, 9 Oct 14:02", which the agent then names in its own words (decided, 9 Oct 2026, 3).
34159
34241
  * The prompt's words never become the title. A session `ref0 watch` started says so, with the
34160
- * event that started its thread, as its trigger. The session's token is kept in the keychain
34161
- * under its binding's account, and nowhere else, and its thread beside it, so the session that
34162
- * follows one that ended goes on the same thread.
34242
+ * event that started its thread, as its trigger. The session's credential is kept in the
34243
+ * keychain under its binding's account, and nowhere else, as one record with the key it's bound
34244
+ * to and the grant that started it, and its thread beside it, so the session that follows one
34245
+ * that ended goes on the same thread.
34163
34246
  */
34164
34247
  /** The keychain account a binding's thread is kept under, beside its session token's. */
34165
34248
  const threadAccount = (binding) => `${sessionAccount(binding)}/thread`;
34166
34249
  /** The keychain account a start not yet landed is kept under, beside its session token's. */
34167
34250
  const pendingAccount = (binding) => `${sessionAccount(binding)}/pending`;
34168
34251
  const decodeRequest = Schema.decodeUnknownEffect(StartSessionRequest);
34169
- const decodeRequestId = Schema.decodeUnknownOption(RequestId);
34170
34252
  /**
34171
- * A pending start's request ID and secret, as kept: `<request ID> <secret>`. One whose secret
34172
- * isn't a token's, kept before tokens took only those, could never authorize, so it's dropped.
34253
+ * A start not yet landed, as kept: its request ID and secret, and the grant that authorized it
34254
+ * and the key it was signed by, so it's sent again only by that grant, with that key. One kept
34255
+ * in any other form is dropped, and the next start is a new one.
34173
34256
  */
34174
- const decodePending = (kept) => {
34175
- const [requestId = "", secret = ""] = kept.split(" ");
34176
- if (!isTokenSecret(secret)) return Option.none();
34177
- return Option.map(decodeRequestId(requestId), (id) => ({
34178
- requestId: id,
34179
- secret
34180
- }));
34181
- };
34257
+ const PendingStart = Schema.Struct({
34258
+ requestId: RequestId,
34259
+ secret: Schema.String.check(Schema.makeFilter((secret) => isTokenSecret(secret))),
34260
+ hostGrant: CredentialRef,
34261
+ cnf: Schema.optional(KeyBinding)
34262
+ });
34263
+ const decodePending = Schema.decodeUnknownOption(Schema.fromJsonString(PendingStart));
34264
+ const encodePending = Schema.encodeSync(Schema.fromJsonString(PendingStart));
34265
+ /** Whether a pending start was authorized as a start now would be: by this grant, with this key. */
34266
+ const sameAuthority = (pending, hostGrant, cnf) => pending.hostGrant.person === hostGrant.person && pending.hostGrant.id === hostGrant.id && pending.cnf?.jkt === cnf?.jkt;
34182
34267
  /** A secret's SHA-256, as hex: all ref0 is told of it. */
34183
34268
  const hashOf = (secret) => Effect.map(Effect.promise(async () => await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret))), (digest) => Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""));
34184
34269
  /** How the harness is named in a plain title. */
@@ -34203,15 +34288,23 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
34203
34288
  const folder = yield* (yield* ConfigFolders).folder(harness);
34204
34289
  const grant = yield* hostGrant.get(folder);
34205
34290
  if (Option.isNone(grant) || grant.value.harness !== harness) return Option.none();
34206
- const client = yield* grantClient(grant.value);
34291
+ const authorizing = grantRef(grant.value);
34292
+ if (Option.isNone(authorizing)) return Option.none();
34293
+ const cnf = grant.value.cnf;
34294
+ const client = yield* keptClient(grant.value);
34207
34295
  const pending = pendingAccount(input.binding);
34208
- const held = Option.flatMap(yield* keychain.get(sessionAccount(input.binding)), (kept) => parseToken(Redacted.value(kept)));
34209
- const resent = Option.filter(Option.flatMap(yield* keychain.get(pending), (kept) => decodePending(Redacted.value(kept))), (one) => !Option.exists(held, (parts) => parts.secret === one.secret));
34296
+ const held = Option.flatMap(readKeptSession(yield* keychain.get(sessionAccount(input.binding))), (kept) => parseToken(Redacted.value(kept.token)));
34297
+ const resent = Option.filter(Option.flatMap(yield* keychain.get(pending), (kept) => decodePending(Redacted.value(kept))), (one) => sameAuthority(one, authorizing.value, cnf) && !Option.exists(held, (parts) => parts.secret === one.secret));
34210
34298
  const { requestId, secret } = Option.isSome(resent) ? resent.value : {
34211
34299
  requestId: yield* newRequestId,
34212
34300
  secret: encodeSecret(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))
34213
34301
  };
34214
- if (Option.isNone(resent)) yield* keychain.set(pending, Redacted.make(`${requestId} ${secret}`));
34302
+ if (Option.isNone(resent)) yield* keychain.set(pending, Redacted.make(encodePending({
34303
+ requestId,
34304
+ secret,
34305
+ hostGrant: authorizing.value,
34306
+ cnf
34307
+ })));
34215
34308
  const hash = yield* hashOf(secret);
34216
34309
  const where = input.on.kind === "thread" ? threadParts(input.on.written) : {
34217
34310
  owner: grant.value.owner,
@@ -34246,11 +34339,20 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
34246
34339
  kind: "project",
34247
34340
  name: newThreadProject
34248
34341
  }, started.threadNumber);
34342
+ const kept = {
34343
+ token: Redacted.make(formatToken(started.credential.person, started.credential.id, secret)),
34344
+ session: started.session,
34345
+ hostGrant: authorizing.value,
34346
+ cnf
34347
+ };
34249
34348
  yield* keychain.remove(gitTokenAccount(input.binding)).pipe(Effect.ignore);
34250
- yield* keychain.set(sessionAccount(input.binding), Redacted.make(formatToken(started.credential.person, started.credential.id, secret)));
34349
+ yield* keychain.set(sessionAccount(input.binding), keptSessionValue(kept));
34251
34350
  yield* keychain.set(threadAccount(input.binding), Redacted.make(thread)).pipe(Effect.ignore);
34252
34351
  yield* keychain.remove(pending).pipe(Effect.ignore);
34253
- return Option.some(started);
34352
+ return Option.some({
34353
+ started,
34354
+ kept
34355
+ });
34254
34356
  });
34255
34357
  //#endregion
34256
34358
  //#region src/harness/watched-start.ts
@@ -34333,14 +34435,13 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
34333
34435
  const owned = yield* Ref.make(Option.none());
34334
34436
  const onTheWay = yield* Ref.make(Option.none());
34335
34437
  const listen = Effect.fnUntraced(function* (bound) {
34336
- const client = yield* clientOf(bound.token);
34337
34438
  let pause = 1;
34338
34439
  let opened = false;
34339
34440
  for (;;) {
34340
- const ticket = yield* client.live.ticket({ payload: { track: {
34441
+ const ticket = yield* clientOf(bound.kept).pipe(Effect.flatMap((client) => client.live.ticket({ payload: { track: {
34341
34442
  kind: "session",
34342
34443
  session: bound.session
34343
- } } }).pipe(Effect.option);
34444
+ } } })), Effect.option);
34344
34445
  let closed;
34345
34446
  if (Option.isSome(ticket)) {
34346
34447
  if (opened) yield* edges.moved;
@@ -34366,12 +34467,12 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
34366
34467
  }
34367
34468
  });
34368
34469
  const renew = Effect.fnUntraced(function* (bound) {
34369
- const client = yield* clientOf(bound.token);
34370
34470
  for (;;) {
34371
- const renewed = yield* client.sessions.renew({
34471
+ const key = yield* newRequestId;
34472
+ const renewed = yield* clientOf(bound.kept).pipe(Effect.flatMap((client) => client.sessions.renew({
34372
34473
  params: { session: bound.session },
34373
- headers: { "idempotency-key": yield* newRequestId }
34374
- }).pipe(Effect.as("renewed"), Effect.catchTag("SessionHasEnded", () => Effect.succeed("ended")), Effect.orElseSucceed(() => "failed"));
34474
+ headers: { "idempotency-key": key }
34475
+ }))).pipe(Effect.as("renewed"), Effect.catchTag("SessionHasEnded", () => Effect.succeed("ended")), Effect.orElseSucceed(() => "failed"));
34375
34476
  if (renewed === "ended") {
34376
34477
  yield* oneAtATime.withPermits(1)(Ref.set(state, {
34377
34478
  kind: "ended",
@@ -34383,9 +34484,9 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
34383
34484
  }
34384
34485
  });
34385
34486
  const keptThread = (binding) => keychain.get(threadAccount(binding)).pipe(Effect.map(Option.flatMap((kept) => decodeWrittenThread(Redacted.value(kept)))), Effect.orElseSucceed(() => Option.none()));
34386
- const hasEnded = (token) => clientOf(token).pipe(Effect.flatMap((client) => client.me.get()), Effect.as(false), Effect.catchTag("SessionHasEnded", () => Effect.succeed(true)), Effect.orElseSucceed(() => false));
34487
+ const hasEnded = (kept) => clientOf(kept).pipe(Effect.flatMap((client) => client.me.get()), Effect.as(false), Effect.catchTag("SessionHasEnded", () => Effect.succeed(true)), Effect.orElseSucceed(() => false));
34387
34488
  const heldFor = Effect.fnUntraced(function* (binding, over) {
34388
- const kept = yield* keychain.get(sessionAccount(binding)).pipe(Effect.orElseSucceed(() => Option.none()));
34489
+ const kept = readKeptSession(yield* keychain.get(sessionAccount(binding)).pipe(Effect.orElseSucceed(() => Option.none())));
34389
34490
  if (!over || Option.isNone(kept) || !(yield* hasEnded(kept.value))) return kept;
34390
34491
  return Option.none();
34391
34492
  });
@@ -34424,8 +34525,7 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
34424
34525
  "ProjectNotFound",
34425
34526
  "NotPermitted"
34426
34527
  ], (refused) => Option.isSome(before) ? Effect.andThen(keychain.remove(threadAccount(binding)).pipe(Effect.ignore), startOn(start.thread)) : Effect.fail(refused)), Effect.orElseSucceed(() => Option.none()));
34427
- if (Option.isNone(started)) return Option.none();
34428
- return yield* keychain.get(sessionAccount(binding)).pipe(Effect.orElseSucceed(() => Option.none()));
34528
+ return Option.map(started, (one) => one.kept);
34429
34529
  });
34430
34530
  const startedFor = Effect.fnUntraced(function* (binding, hook, starting) {
34431
34531
  const sent = yield* Ref.get(onTheWay);
@@ -34451,28 +34551,19 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
34451
34551
  if ((over && !starting || waiting) && !sent) return Option.none();
34452
34552
  const binding = yield* bindingForServer(hook.harnessSession);
34453
34553
  const held = Option.isNone(binding) ? Option.none() : yield* heldFor(binding.value, over);
34454
- const token = Option.isSome(held) || Option.isNone(binding) ? held : yield* startedFor(binding.value, hook, starting);
34455
- if (Option.isNone(binding) || Option.isNone(token)) {
34554
+ const kept = Option.isSome(held) || Option.isNone(binding) ? held : yield* startedFor(binding.value, hook, starting);
34555
+ if (Option.isNone(binding) || Option.isNone(kept)) {
34456
34556
  if (!over) yield* Ref.set(state, {
34457
34557
  kind: "unbound",
34458
34558
  until: now + unboundMillis
34459
34559
  });
34460
34560
  return Option.none();
34461
34561
  }
34462
- const client = yield* clientOf(token.value);
34463
- const me = yield* client.me.get();
34464
- if (me.session === void 0) {
34465
- yield* Ref.set(state, {
34466
- kind: "unbound",
34467
- until: now + unboundMillis
34468
- });
34469
- return Option.none();
34470
- }
34471
- const registered = yield* client.sessions.process({ params: { session: me.session.session } });
34562
+ const registered = yield* (yield* clientOf(kept.value)).sessions.process({ params: { session: kept.value.session } });
34472
34563
  const bound = {
34473
34564
  binding: binding.value,
34474
- token: token.value,
34475
- session: me.session.session,
34565
+ kept: kept.value,
34566
+ session: kept.value.session,
34476
34567
  generation: registered.generation,
34477
34568
  counter: 0,
34478
34569
  unreported: []
@@ -34857,7 +34948,6 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
34857
34948
  */
34858
34949
  static layerFor(agent) {
34859
34950
  return Layer.effect(HarnessSession, Effect.gen(function* () {
34860
- const config = yield* Ref0Config;
34861
34951
  const context = yield* Effect.context();
34862
34952
  const state = yield* Ref.make({
34863
34953
  kind: "unbound",
@@ -34877,13 +34967,13 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
34877
34967
  within: Effect.suspend(() => binding.within),
34878
34968
  scope
34879
34969
  });
34880
- const clientOf = (token) => ref0Client(config.api).pipe(Effect.provide(bearerCredential(token)));
34970
+ const clientOf = (kept) => keptClient(kept).pipe(Effect.provide(context));
34881
34971
  const watch = yield* baseWatch({
34882
34972
  scope,
34883
34973
  clientOf
34884
34974
  });
34885
34975
  const settle = Effect.fnUntraced(function* (bound) {
34886
- const client = yield* clientOf(bound.token);
34976
+ const client = yield* clientOf(bound.kept);
34887
34977
  const refused = () => Effect.succeed("refused");
34888
34978
  yield* attribution.settle(ownerOf(bound), (repo, part) => client.sessions.commit({
34889
34979
  params: {
@@ -34929,7 +35019,7 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
34929
35019
  if (hook.event === "UserPromptSubmit") yield* Ref.set(prompted, Option.some(hook));
34930
35020
  const bound = yield* binding.bind(hook);
34931
35021
  if (Option.isNone(bound)) return Option.none();
34932
- const client = yield* clientOf(bound.value.token);
35022
+ const client = yield* clientOf(bound.value.kept);
34933
35023
  const prompt = Option.filter(yield* Ref.get(prompted), (one) => one.harnessSession === hook.harnessSession && one.harnessTurn === hook.harnessTurn);
34934
35024
  if (Option.isSome(prompt)) yield* Ref.set(prompted, Option.none());
34935
35025
  if (Option.isSome(prompt) && prompt.value !== hook) yield* turns.trackLate(client, bound.value, prompt.value, deadline);
@@ -35158,11 +35248,6 @@ const lastRead = Effect.fnUntraced(function* (client, session, harness, transcri
35158
35248
  }
35159
35249
  });
35160
35250
  });
35161
- /** The typed `/v1` as a session, with its own token: not the one this process's environment names. */
35162
- const asSession = (token) => Effect.gen(function* () {
35163
- const config = yield* Ref0Config;
35164
- return yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(token)));
35165
- });
35166
35251
  /** What the hook does, from the hook's input on stdin. */
35167
35252
  const endHarnessSession = Effect.gen(function* () {
35168
35253
  const stdio = yield* Stdio.Stdio;
@@ -35174,13 +35259,13 @@ const endHarnessSession = Effect.gen(function* () {
35174
35259
  const binding = yield* bindingHere(hook.value.session_id);
35175
35260
  if (Option.isNone(binding)) return;
35176
35261
  const account = sessionAccount(binding.value);
35177
- const token = yield* keychain.get(account);
35178
- if (Option.isNone(token)) return;
35262
+ const held = yield* keychain.get(account);
35263
+ if (Option.isNone(held)) return;
35179
35264
  yield* Effect.gen(function* () {
35180
- const client = yield* asSession(token.value);
35181
- const me = yield* client.me.get();
35182
- if (me.session === void 0) return;
35183
- const session = me.session.session;
35265
+ const kept = readKeptSession(held);
35266
+ if (Option.isNone(kept)) return;
35267
+ const client = yield* keptClient(kept.value);
35268
+ const session = kept.value.session;
35184
35269
  const harness = binding.value.harness === "codex" ? "codex" : "claude-code";
35185
35270
  yield* lastRead(client, session, harness, hook.value.transcript_path).pipe(Effect.timeout(lastReadWithin), Effect.ignore);
35186
35271
  yield* client.sessions.end({
@@ -35377,23 +35462,19 @@ const setCommitEnvironment = Effect.fnUntraced(function* (session) {
35377
35462
  */
35378
35463
  const decodeHook = Schema.decodeUnknownOption(Schema.fromJsonString(SessionStartHookInput));
35379
35464
  /**
35380
- * What blocks the session's thread, in the words its agent reads, read with the session's own
35381
- * token; none if nothing does. A session already started is found by its token.
35465
+ * What blocks the session's thread, in the words its agent reads, read as the session, as it's
35466
+ * kept; none if nothing does.
35382
35467
  */
35383
- const blockedBy = Effect.fnUntraced(function* (token, known) {
35384
- const config = yield* Ref0Config;
35385
- const client = yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(token)));
35386
- const session = Option.isSome(known) ? known.value : (yield* client.me.get()).session?.session;
35387
- if (session === void 0) return Option.none();
35388
- const read = yield* client.sessions.blockers({
35389
- params: { session },
35468
+ const blockedBy = Effect.fnUntraced(function* (kept) {
35469
+ const read = yield* (yield* keptClient(kept)).sessions.blockers({
35470
+ params: { session: kept.session },
35390
35471
  query: {}
35391
35472
  });
35392
35473
  if (read.blockers.length === 0) return Option.none();
35393
35474
  return Option.some(renderBlockedBy(read.thread, read.blockers));
35394
35475
  });
35395
35476
  /** Says what blocks the session's thread, if anything does; says nothing if ref0 can't tell. */
35396
- const sayBlockers = (token, known) => blockedBy(token, known).pipe(Effect.flatMap(Option.match({
35477
+ const sayBlockers = (kept) => blockedBy(kept).pipe(Effect.flatMap(Option.match({
35397
35478
  onNone: () => Effect.void,
35398
35479
  onSome: Console.log
35399
35480
  })), Effect.ignore);
@@ -35434,9 +35515,8 @@ const startOnHook = (protocol) => Effect.gen(function* () {
35434
35515
  const why = kept.readable ? `no agent is connected for this ${name} config folder, ${folder}` : `the host grant kept for this ${name} config folder, ${folder}, can't be read`;
35435
35516
  return yield* Console.log(`ref0: ${why}, so no session starts. In your own terminal, run: ${repair}`);
35436
35517
  }
35437
- const account = sessionAccount(binding.value);
35438
- const held = yield* keychain.get(account);
35439
- if (Option.isSome(held)) return yield* sayBlockers(held.value, Option.none());
35518
+ const held = readKeptSession(yield* keychain.get(sessionAccount(binding.value)));
35519
+ if (Option.isSome(held)) return yield* sayBlockers(held.value);
35440
35520
  const place = startPlace(yield* checkout.threadAt(hook.value.cwd ?? "."), yield* watchedHere);
35441
35521
  if (Option.isNone(place.thread)) return;
35442
35522
  const thread = place.thread.value;
@@ -35454,11 +35534,10 @@ const startOnHook = (protocol) => Effect.gen(function* () {
35454
35534
  version
35455
35535
  }).pipe(Effect.tapError((error) => place.trigger.kind === "event" ? Console.log(refusedWords(thread, "nextStep" in error ? error.nextStep : void 0)) : Effect.void));
35456
35536
  if (Option.isNone(started)) return;
35457
- yield* Console.log(`ref0: this session is on ${thread}, as its session ${started.value.sessionNumber}.`);
35537
+ yield* Console.log(`ref0: this session is on ${thread}, as its session ${started.value.started.sessionNumber}.`);
35458
35538
  const watched = watchedWords(place, thread);
35459
35539
  if (watched !== void 0) yield* Console.log(`ref0: ${watched}`);
35460
- const token = yield* keychain.get(account);
35461
- if (Option.isSome(token)) yield* sayBlockers(token.value, Option.some(started.value.session));
35540
+ yield* sayBlockers(started.value.kept);
35462
35541
  }).pipe(Effect.timeout("10 seconds"), Effect.ignore);
35463
35542
  const sessionStart = Command.make(harnessCommandNames.sessionStart, { protocol: protocolFlag }, (flags) => startOnHook(flags.protocol)).pipe(Command.withDescription("Starts the ref0 session of a harness session that’s opening"), Command.withShortDescription("Start a ref0 session for the harness"));
35464
35543
  /** The commands a harness runs: `ref0 mcp`, and its command hooks. */
@@ -35466,7 +35545,7 @@ const harnessCommands = [mcp, Command.make(harnessCommandNames.hook).pipe(Comman
35466
35545
  //#endregion
35467
35546
  //#region src/harness/command-token.ts
35468
35547
  /**
35469
- * The session an agent's command acts as: its harness session's, by the token kept under its
35548
+ * The session an agent's command acts as: its harness session's, by the record kept under its
35470
35549
  * binding. Outside a harness there's none, and a command is its person's, asked by approval; so
35471
35550
  * too in a harness whose config folder has no agent connected. Where one is connected, a harness
35472
35551
  * session with no token has lost its start, and the command says why, as far as ref0 can tell,
@@ -35514,11 +35593,12 @@ const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
35514
35593
  const current = Effect.gen(function* () {
35515
35594
  const binding = yield* bindingHere().pipe(Effect.provideService(HarnessProcess, harness), Effect.orElseSucceed(() => Option.none()));
35516
35595
  if (Option.isNone(binding)) return Option.none();
35517
- const kept = yield* keychain.get(sessionAccount(binding.value));
35596
+ const held = yield* keychain.get(sessionAccount(binding.value));
35597
+ const kept = readKeptSession(held);
35518
35598
  if (Option.isSome(kept)) return kept;
35519
35599
  const name = binding.value.harness === "codex" ? "codex" : "claude-code";
35520
35600
  const grant = yield* hostGrant.get(yield* folders.folder(name)).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone));
35521
- if (Option.isNone(grant) || grant.value.harness !== name) return Option.none();
35601
+ if ((Option.isNone(grant) || grant.value.harness !== name) && Option.isNone(held)) return Option.none();
35522
35602
  const pending = yield* keychain.get(pendingAccount(binding.value));
35523
35603
  const serverRuns = yield* servers.runsUnder(binding.value.process);
35524
35604
  return yield* new NoSession({ nextStep: noSessionStep(name, `plugin:${plugin}:${plugin}`, {
@@ -35714,12 +35794,10 @@ const whoIs = (client) => client.me.get().pipe(Effect.timeout("10 seconds"), Eff
35714
35794
  InvalidDpopProof: refused,
35715
35795
  KeyStoreUnavailable: unsigned
35716
35796
  }), Effect.orElseSucceed(() => ({ kind: "unanswered" })));
35717
- /** A session's token, as a bearer token: sessions aren't bound to a key yet. */
35718
- const sessionClient = (token) => Effect.flatMap(Ref0Config, (config) => ref0Client(config.api).pipe(Effect.provide(bearerCredential(token))));
35719
35797
  const unansweredWords = (api) => `ref0 didn’t answer at ${api}.`;
35720
35798
  /** Check the grant kept for this folder, without borrowing another folder's authority. */
35721
35799
  const grantChecks = Effect.fnUntraced(function* (grant, configFolder) {
35722
- const asked = yield* grantClient(grant).pipe(Effect.flatMap(whoIs), Effect.catchTags({
35800
+ const asked = yield* keptClient(grant).pipe(Effect.flatMap(whoIs), Effect.catchTags({
35723
35801
  InvalidDpopProof: refused,
35724
35802
  KeychainError: unsigned
35725
35803
  }));
@@ -35819,8 +35897,12 @@ const sessionCheck = Effect.fnUntraced(function* (harness, configFolder) {
35819
35897
  if (Option.isNone(binding)) return checkOf("session", "unknown", (yield* harnessEnvironment).CODEX_SANDBOX === void 0 ? "no current session to check" : "Codex’s sandbox hides its session", launch);
35820
35898
  const token = yield* Effect.result((yield* Keychain).get(sessionAccount(binding.value)));
35821
35899
  if (Result.isFailure(token)) return checkOf("session", "unknown", `The keychain didn’t say whether ref0 follows this ${configFolders[harness].name} session.`, token.failure.nextStep);
35822
- if (Option.isNone(token.success)) return checkOf("session", "no", "start a new session in this folder", launch);
35823
- const asked = yield* Effect.flatMap(sessionClient(token.success.value), whoIs);
35900
+ const kept = readKeptSession(token.success);
35901
+ if (Option.isNone(kept)) return checkOf("session", "no", "start a new session in this folder", launch);
35902
+ const asked = yield* keptClient(kept.value).pipe(Effect.flatMap(whoIs), Effect.catchTags({
35903
+ InvalidDpopProof: refused,
35904
+ KeychainError: unsigned
35905
+ }));
35824
35906
  switch (asked.kind) {
35825
35907
  case "accepted": return asked.me.session === void 0 ? checkOf("session", "no", "start a new session in this folder", launch) : checkOf("session", "yes", `ref0 follows this session as ${asked.me.session.session}`);
35826
35908
  case "refused": return checkOf("session", "no", "ref0 no longer follows this session", launch);
@@ -35918,6 +36000,7 @@ const ref0 = Command.make("ref0").pipe(Command.withDescription("ref0 from the te
35918
36000
  ]));
35919
36001
  const keychain = Keychain.layer.pipe(Layer.provide([NodeServices.layer, Ref0Config.layer]));
35920
36002
  const harnessProcess = HarnessProcess.layer.pipe(Layer.provide(NodeServices.layer));
36003
+ const machineKey = MachineKey.layer.pipe(Layer.provide(keychain));
35921
36004
  const sessionToken = harnessSessionToken.pipe(Layer.provide([
35922
36005
  keychain,
35923
36006
  harnessProcess,
@@ -35928,8 +36011,15 @@ const sessionToken = harnessSessionToken.pipe(Layer.provide([
35928
36011
  const services = Layer.mergeAll(cliOutput, Ref0.layer.pipe(Layer.provide([
35929
36012
  Ref0Config.layer,
35930
36013
  sessionToken,
35931
- FetchHttpClient.layer
35932
- ])), sessionToken, harnessSessionThread.pipe(Layer.provide([keychain, harnessProcess])), FetchHttpClient.layer, keychain, MachineKey.layer.pipe(Layer.provide(keychain)), ProofClock.layer, HostGrant.layer.pipe(Layer.provide(keychain)), OldHostGrants.layer, ConnectLock.layer.pipe(Layer.provide(Processes.layer)), Machine.layer, Tty.layer, harnessProcess, Checkout.layer.pipe(Layer.provide(NodeServices.layer)), BaseChecks.layer.pipe(Layer.provide([Ref0Config.layer, FetchHttpClient.layer])), Attribution.layer.pipe(Layer.provide([Commits.layer, Checkout.layer])), HarnessTasks.layer, Transcripts.layer, Waker.layer, LiveHints.layer).pipe(Layer.provideMerge(ConfigFolders.layer), Layer.provideMerge(Ref0Config.layer), Layer.provideMerge(NodeServices.layer));
36014
+ FetchHttpClient.layer,
36015
+ machineKey,
36016
+ ProofClock.layer
36017
+ ])), sessionToken, harnessSessionThread.pipe(Layer.provide([keychain, harnessProcess])), FetchHttpClient.layer, keychain, machineKey, ProofClock.layer, HostGrant.layer.pipe(Layer.provide(keychain)), OldHostGrants.layer, ConnectLock.layer.pipe(Layer.provide(Processes.layer)), Machine.layer, Tty.layer, harnessProcess, Checkout.layer.pipe(Layer.provide(NodeServices.layer)), BaseChecks.layer.pipe(Layer.provide([
36018
+ Ref0Config.layer,
36019
+ FetchHttpClient.layer,
36020
+ machineKey,
36021
+ ProofClock.layer
36022
+ ])), Attribution.layer.pipe(Layer.provide([Commits.layer, Checkout.layer])), HarnessTasks.layer, Transcripts.layer, Waker.layer, LiveHints.layer).pipe(Layer.provideMerge(ConfigFolders.layer), Layer.provideMerge(Ref0Config.layer), Layer.provideMerge(NodeServices.layer));
35933
36023
  //#endregion
35934
36024
  //#region src/root.ts
35935
36025
  /**