@ai-ecoverse/slicc-spectrum 2.0.3 → 2.2.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/README.md CHANGED
@@ -307,6 +307,7 @@ When a live agent's model, or `Settings.model`, is no longer offered and its pro
307
307
  The chat re-runs this on `follow` and `readOnly` too, and `<slicc-app>` does it as well, when it binds, on those events and on `follow` and `readOnly`, so a new cone that starts on a provider that isn't set up moves at once.
308
308
  The transcript pads its end, and its `scroll-padding-block-end`, by the composer's measured height plus the height its frosted layer fades out over (`--composer-fade`, 72 px, 0 under `prefers-reduced-transparency` and `forced-colors` and without a composer), so at the bottom the last line sits a line above the glass, and a focused element scrolls clear of it.
309
309
  The log follows new content only while it sits within one line (its `line-height`) of the bottom. Scrolling up by wheel, touch, keys or the scrollbar stops that, and the log then keeps its place, whatever re-renders or ticks; content that grows above the view, such as a tool card opening, doesn't move what's in view either. New output while the reader is scrolled up shows **Jump to latest** (`data-action="jump-latest"`), floating over the log's bottom edge above the composer, with the number of new items, "Jump to latest (2 new)", once there are any; it, or scrolling back to the bottom, resumes following, and so does the reader's own send. While it follows, a log that gets shorter (a notice above it, a taller composer, a shorter panel) or a latest card that grows (a lick opened, a sprinkle loading) doesn't scroll that card's top out of view: a card that fits shows whole, and a taller one keeps its top at the top of the log, with the rest under the composer, instead of its end above the composer's padding; a new item, a streaming reply and Jump to latest still go to the very bottom.
310
+ With the agent port's optional `hasOlder(agentId)`, the log pages back through the conversation: while it is less than a screen from its top (which includes a log that doesn't fill the view) and `hasOlder` is true, it calls `older(agentId, id)` with the id of the first message in `messages()`, one load at a time per agent, and renders the earlier messages from `messages()` by id once the port emits `messages`, never from what `older` resolves with. The first turn in view keeps its place: its offset is measured before the render and put back before the next paint (scroll anchoring does nothing at the very top), and the log's follow bookkeeping moves with it, so the jump never reads as the reader scrolling. It loads until the log is more than a screen above the view or the start is reached; an empty answer, or one that leaves the first message as it was, is the start, and the chat doesn't ask again for that agent until its first message changes or it is shown again. While there is more, the top of the log reads "Loading earlier messages…" (`[data-older=loading]`, `aria-busy` while a load runs), and at the start it shows nothing; a rejected `older` shows "Couldn’t load earlier messages." with **Retry** (`[data-older=failed]`, `role="alert"`, `data-action="retry-older"`) instead of trying again. A load that ends after another agent is shown touches only its own conversation. Without `hasOlder` (an older agent), it never calls `older`.
310
311
  While a reply streams, its status icon follows what it is doing: a pencil for an edit, a page for a read, a magnifier for a search, an eye for the browser and an hourglass for any other tool; thinking and text keep the AI logo.
311
312
  Between the header and the transcript, a `<slicc-notices>` shows the model's notices.
312
313
  On a follower without Control, or while the agent port's `readOnly()` returns a line, a note (`p[data-follow="watch"]`, that line first, else the follow-based one) takes the composer's place, the header shows the model's name instead of the pickers and New conversation, and there's no suggested follow-up.
@@ -334,6 +335,11 @@ Sudo and guest requests show the verdict under the command, in a live region (`.
334
335
  Focus starts on Deny (on Close once `decide` rejects) and goes back, after Deny, Always allow, Allow once, <kbd>Esc</kbd> or Close, to what had it when the dialog opened; inside a terminal, to that `<slicc-terminal>` through its own `focus()`, even when it remounted meanwhile.
335
336
  Guest requests (`guest-message`, `guest-tool`) get their own copy, the guest's text as plain text (`[data-guest-text]`), attachments (`[data-attachments]`), the automatic check's verdict (`[data-verdict]`), and Deny and Allow once, plus **Always allow** (`data-action="always"`) with a hint saying what it covers when the approval's `always` is `true`; the dialog body carries `[data-approval-kind]` and fills the dialog's width at every size.
336
337
  Tray requests (`tray`) get their own title and one sentence (`#asking`), and only **Deny** (`deny`, focused) and **Allow once** (`once`), never Always allow.
338
+ Secret requests (`secret`, `dialog[data-kind=secret]`) read "Enter a value for {name}?" (`mode: 'enter'`) or "Reveal {name} to a program?" (`reveal`). Under the title comes the trusted block, which only bios and the kernel fill: "{name} ran this[ in {where}]:" for an agent or "You ran this[ in {where}] (as {name}):" for a user, then the command (`[data-command]`), which is `process.exe` followed by `argv[1…]`, each word shell-quoted (`argv[0]` is the program's own choice), then "In {cwd}". The command follows sudo's rules: the same scrolling box, the same control-character chips, and the same end-in-view rule. Then the untrusted `prompt`, if any, in its own block (`[data-prompt]`) under "From the agent, not checked by SLICC:", with control characters as chips and cut at 200 characters. With `reveal`, a negative block (`[data-reveal]`) says "{exe} gets the plaintext. It can send it anywhere.", and adds "The agent asked for this." when `requester.kind` is `agent`.
339
+ The body is the vault's own frame (`[data-value-frame]`, a `<slicc-vault-value>` on `vault.valueFrame(id)`). The value is typed and Save or Allow clicked there, so spectrum never sees the value and can't fake the consent. The frame is `inert` until the end of the command has been in view, and "Scroll to the end of the command to answer it." (`[data-allow-hint]`) shows while it isn't. Without `vault.valueFrame`, "The vault isn’t available here, so this request can only be denied." (`[data-id="no-frame"]`) stands in its place.
340
+ The title shows control characters in the name as chips, and so does the notice. The only button is **Deny** (`deny`, focused). The element's `decide()` takes only `deny` for a secret, and only while that request is the one showing: not once it was denied, answered in its frame, closed, or ended (timed out, answered elsewhere, withdrawn). A late `escape` from the frame does nothing either.
341
+ When a listed request is replaced (the same `id` with another `createdAt`, command, mode, name, `reveal`, prompt or requester), or an `id` comes back after it left `list()`, the dialog counts it as a new request: a new frame, the end of its command unseen again, and the half-second guard again. Escape on the dialog denies, and so does the frame's `escape` event, posted when Escape is pressed inside the frame. The frame's `done` for this request closes the dialog and moves on to the next request, with no "answered" dialog. The frame's `gone` shows "Secret request timed out" (`expired`) or "Answered elsewhere", or closes silently (`withdrawn`), except for a request this tab denied or answered. The frame's `error` shows its message under the frame (`[data-frame-error]`). A secret request that leaves `list()` without `ended` before `expiresAt` closes silently, because the frame's `done` and the list change can arrive in either order. Focus then goes back as for sudo, to the `<slicc-terminal>` that had it.
342
+ `approvalKinds` (from `/ui`) is a frozen array of the kinds this build shows (`sudo`, `guest-message`, `guest-tool`, `tray`, `secret`), so an embedder can hold back the kinds an older UI would leave waiting unseen.
337
343
 
338
344
  #### `<slicc-follow>`
339
345
 
@@ -479,7 +485,7 @@ The secrets, sorted by name (`li[data-secret][data-state]`, with `[data-rules]`,
479
485
  Locked, every form (Add secret, Replace value, Import, Export) says "Unlock the vault first" (`p[data-locked]`) next to the vault's own small **Unlock** button (`slicc-vault-button[data-action=unlock]`) and disables its submit; once unlocked, the form stays open with what was typed and its submit enables.
480
486
  Vault errors show their message, EACCES as "Unlock the vault first", an EACCES with `cause: 'policy-paused'` as "Secrets are paused until the missing policy file is back", and ENOTSUP as "This arrives with a later vault version".
481
487
  Each action shows only when the port has its method.
482
- Then **Rules SLICC refused** (`[data-refused]`), where the rules live (`[data-policy]`) and **Activity** (`ol.audit li[data-seq][data-type]`; an entry with `uid` and `tag` both `null` reads "by you", a `refresh` entry "Refreshed automatically" (with the sign-in it used), an `endpoints` entry (vault-originated, no "by …") one part per field it carries: (1.91.0's `EndpointsEntry` had a required `purged` with `url`; no vault wrote it before 1.13.0, so the shape was corrected rather than kept, with `purged` optional and `host` replacing `url`) `purged` "Removed custom model URLs an agent set, automatically: {provider name} at {host}, …. Only you can set a model URL now, in Settings › Accounts" (the provider's name from `settings.accounts()`, else its id), `adopted` "Kept the MCP servers your tokens use, automatically: {server} at {host}, …", `pinned` "{name} pinned to {host} automatically, on first use" with **Unpin** (`data-action=unpin`, calling the vault port's optional `unpin([name])`; disabled while the vault is locked; a failure shows in the row as `[data-error=unpin]`, in the same words as other vault failures; it's hidden once a newer `unpinned` entry for that name is in the log), and `unpinned` "{name} unpinned from {host}; the next use pins it again", an `mcp` entry "Sent to the MCP server SERVER at DOMAIN" with its request and exit, a `session` entry (a value the vault handed the kernel for a verified TLS session, vault 1.15.0) "Sent over {HTTP/1.1, SMTP or Postgres} to {domain}:{port}, {trusted by public roots, or trusted by its pinned anchor}, through {exit}" (no trust part when an older entry has no `verifiedBy` or a newer vault sends one spectrum doesn't know; a protocol it doesn't know reads as its raw name), and a policy entry lists its changes, its warning and, when accepted, how to change the file back, except that an entry with `absent` reads "{file} was removed. Secrets are paused until it’s back." and the accepted entry that ends its pause reads "{file} is back. Secrets are running again." ("Secrets stay paused until the other file is back." while the other file is still missing), neither with the way back; **Only reveals to programs**, `data-action="reveals"`, and **Show older**, `data-action="older"`).
488
+ Then **Rules SLICC refused** (`[data-refused]`), where the rules live (`[data-policy]`) and **Activity** (`ol.audit li[data-seq][data-type]`; an entry with `uid` and `tag` both `null` reads "by you", a `refresh` entry "Refreshed automatically" (with the sign-in it used), an `endpoints` entry (vault-originated, no "by …") one part per field it carries: (1.91.0's `EndpointsEntry` had a required `purged` with `url`; no vault wrote it before 1.13.0, so the shape was corrected rather than kept, with `purged` optional and `host` replacing `url`) `purged` "Removed custom model URLs an agent set, automatically: {provider name} at {host}, …. Only you can set a model URL now, in Settings › Accounts" (the provider's name from `settings.accounts()`, else its id), `adopted` "Kept the MCP servers your tokens use, automatically: {server} at {host}, …", `pinned` "{name} pinned to {host} automatically, on first use" with **Unpin** (`data-action=unpin`, calling the vault port's optional `unpin([name])`; disabled while the vault is locked; a failure shows in the row as `[data-error=unpin]`, in the same words as other vault failures; it's hidden once a newer `unpinned` entry for that name is in the log), and `unpinned` "{name} unpinned from {host}; the next use pins it again", an `mcp` entry "Sent to the MCP server SERVER at DOMAIN" with its request and exit, a `reveal` entry (vault 1.23.0, a secret ask whose plaintext the user allowed) "Revealed to {argv} in {cwd}, allowed in a dialog", (the argv, cwd and "by …" of `materialize` and `reveal` entries, and of a secret's "Revealed to programs…, last by … with {argv}" line, show control characters as chips, as the approval dialog does), which counts as a reveal for **Only reveals to programs** and for the secret's "Revealed to programs…" line, a policy change of `[reveal] agent` "Programs an agent runs may no longer ask for a secret’s plaintext" (or "…may ask for a secret’s plaintext again, in a dialog each time"), a `session` entry (a value the vault handed the kernel for a verified TLS session, vault 1.15.0) "Sent over {HTTP/1.1, SMTP or Postgres} to {domain}:{port}, {trusted by public roots, or trusted by its pinned anchor}, through {exit}" (no trust part when an older entry has no `verifiedBy` or a newer vault sends one spectrum doesn't know; a protocol it doesn't know reads as its raw name), and a policy entry lists its changes, its warning and, when accepted, how to change the file back, except that an entry with `absent` reads "{file} was removed. Secrets are paused until it’s back." and the accepted entry that ends its pause reads "{file} is back. Secrets are running again." ("Secrets stay paused until the other file is back." while the other file is still missing), neither with the way back; **Only reveals to programs**, `data-action="reveals"`, and **Show older**, `data-action="older"`).
483
489
  The section's `data-vault` is `locked`, `unlocked` or `loading`.
484
490
  A popup the browser blocked shows how to allow it (`[data-popup=blocked]`).
485
491
  Widened-rule notices aren't shown.
@@ -489,8 +495,14 @@ An open **Removed** list (`details[data-removed]`) stays open across a layout sw
489
495
 
490
496
  Places `vault.button()`'s element (the vault's own button frame), posts its `config` (`ButtonConfig` plus `theme`) once the button is `ready`, sets the frame's `color-scheme` to `theme` (or leaves it to the page) so the frame stays transparent, takes the reported `size` (with the focus-ring `inset` as negative margin) and `label` (as its title), moves focus to the control before or after it when the frame reports `focus` (`previous` or `next`), and fires `vault-popup` (bubbles, composed) with each `popup` event's `{ intent, state }`. spectrum sends only the `unlock` and `protect` intents.
491
497
 
498
+ #### `<slicc-vault-value>`
499
+
500
+ Places `vault.valueFrame(request)`'s element (the vault's `value.html` frame) and, once the frame is `ready`, posts `{ type: 'slicc-vault', v: 1, op: 'value', request, config }`, where `config` is a `ValueConfig` (`theme`, `dir`, `lang`, `scale`). It posts again when `config` changes, and mounts a new frame when `vault` or `request` changes. It sets the frame's `color-scheme` to `theme`. From `size` it takes the height, at most 480 px (and at most `45dvh`, beyond which the frame scrolls), and makes the frame its own width, with the `inset` (at most 4 px) as a negative margin above and below only, so the dialog never scrolls sideways. A value that isn't a finite number counts as 0. The element clips the frame to its own box (with room above and below for the largest inset it allows, 4 px, for the focus ring), so a frame can't paint over the command above it or the dialog's buttons. On the frame's `focus` event it moves focus to the control after or before the frame (`next` or `previous`), within its `<dialog>` and wrapping around. `focus()` posts `{ op: 'focus' }`, which focuses the frame's first control. It fires `vault-value` (bubbles, composed) with each `done`, `gone`, `error` and `escape` event. The detail holds only `event`, `request`, `state`, `code` and `message` (each when it's a string), so anything else the frame sends goes no further.
501
+
492
502
  #### `<slicc-value-request>`
493
503
 
504
+ For the older `value` requests only, until bios stops sending them: `ask` requests show in `<slicc-approvals>` instead.
505
+
494
506
  The vault's waiting `value` requests (`fnox set` on a terminal) as a modal dialog, one at a time, oldest first, with "N more waiting"; `<slicc-app>` mounts it.
495
507
  `dialog[data-request]`: "Enter a value for NAME" (a Name field when the request has no name), who asks, as on the Settings card ("docs-pass asks for NAME", or "A program running as uid N asks for NAME"), the prompt under "From the agent, not checked by SLICC:", a masked value field with the same rules as Settings › Secrets, "Expires in N min", and **Later** (`data-action="later"`, or Escape: closes, the request stays in Settings › Secrets, whose **Enter value** reopens it), **Cancel request** (`data-action="cancel-request"`, with `cancel`) and **Save** (`data-action="save"`, `put(name, value, request.id)`, with `put`).
496
508
  Locked, it shows the vault's Unlock button and disables Save.
@@ -765,6 +777,7 @@ The optional `toolLabel(agentId, callIds)` resolves with a short label for a run
765
777
  Spectrum asks once a reply's status has two calls and again each time it grows, once per set of ids, and shows the label once the status is complete.
766
778
  The optional `readOnly()` returns a line, or `null`, with a `readOnly` event when it changes: while it's a non-empty string, such as when this follower runs a different major version from its owner, every surface is watch-only just as a follower without Control is (no composer, no cone or conversation actions, no pickers, no Stop, no Accept or Revert in Changes, inline sprinkles view only, no model settling), and the line takes the place of the follow-based watch-only note under the chat.
767
779
  The optional `starting()` returns a line, or `null`, with a `starting` event when it changes: while it's a non-empty string, there's no agent yet but one is on its way (such as while seven installs its first agent, or replaces one), the chat drops its empty state and the composer queues what's sent until an agent is there (see `<slicc-composer>`); without it, or at `null`, nothing changes.
780
+ The optional `hasOlder(agentId)` says whether `older(agentId, before)` can load messages before the first one in `messages()`; `older` resolves once at least one earlier message has arrived in `messages()`, emitting `messages`, or with `[]` at the start, and the chat pages back with them (see `<slicc-chat>`).
768
781
 
769
782
  #### `files`
770
783
 
@@ -965,6 +978,9 @@ It has no `always`: the dialog never offers Always allow for it, even when the i
965
978
  Its dialog reads "Share this seven’s join URL?" with "{name} wants this seven’s join URL. Anyone with it can ask to link to this seven.", "Make a new join URL?" with "{name} wants a new join URL. Linked devices move to it, and the old one stops working.", or "Unlink {device.name}?" with "{name} wants to unlink {device.name} from this seven." ("Unlink a device?" and "…unlink a device from this seven." without `device`), then the countdown, **Deny** and **Allow once**.
966
979
  Ended tray requests read "Answered in another tab" or "Tray request timed out", and a withdrawn one closes silently.
967
980
  Without `decide`, its notice reads "Waiting for tray approval" with the same sentence, then "It waits for the owner's approval. Denied automatically at {time}."
981
+ A secret request (`kind` `secret`, a `SecretApproval`) is a program asking for a secret through `/dev/sealed`. Its fields are `mode` (`enter` when there is no live secret of that name, `reveal` when there is), `name`, an optional `prompt` (the program's own words, ≤ 200 characters, untrusted), `reveal` (the process gets the plaintext back too), `requester` (`kind` `agent` or `user`, `name`, optional `where`), `process` (`exe`, the resolved program file; `argv`; `cwd`, all from the kernel), `createdAt` and `expiresAt`.
982
+ `decide(id, 'deny')` cancels it in the vault, and the UI never sends `once` or `always` for it: the answer is given in the vault's frame. Send `ended` (`answered`, `expired` or `withdrawn`) only for a request that ended outside this tab: not for one this tab denied, and not for one whose frame in this tab posted `done`.
983
+ Without `decide`, its notice (`[data-approval-kind=secret]`) reads "Waiting for a secret" with "{name} ran {exe}, which asks for a value for {secret}." ("You ran…" for a user, "…the plaintext of {secret}." for `reveal`), then "It waits for the owner's answer. Denied automatically at {time}."
968
984
  An approval whose `kind` this build doesn't know shows neither a dialog nor a notice, so it waits until it ends and is never allowed from here.
969
985
 
970
986
  #### `vault` (optional)
@@ -975,10 +991,12 @@ The vault's view for SLICC, typed after `@ai-ecoverse/slicc-vault` (spectrum re-
975
991
  Emit `status` and `list` when they change (bios re-lists after the vault's `changed` events), and `availability` when the optional `availability()` (`connecting`, `waiting`, `locked` or `ready`) changes.
976
992
  `audit(before?, limit?)` resolves with an older `AuditPage` (`before` is a `seq`).
977
993
  The optional page ops, each hiding its control when missing: `put(name, value, request?)` (a value request's `put` must use its `name`), `cancel(request)`, `remove(name, kind?)`, `restore(name, kind?)`, `import(kind, bytes, { password?, pick? })` (a `pick` stage, then `done`), `export(passphrase)` (the bundle's bytes), `lock()`, `dismissBackupReminder()` and `endpoints({ add?, remove? })`, which registers or takes back the URLs a page-sealed credential may be filled on (each an `EndpointSet`, `{ model?: [{ name, url }], mcp?: [{ server, url }] }`, a model URL bound to one record by name), checks the whole change first (`EINVAL` changes nothing) and rejects with a sentence. Settings registers them itself, never through the agent. `settings(name, kind, settings)` replaces a record's tenant settings as a whole without touching its key (the vault's `put` without a value); `put`'s options take the same `settings` on a first save.
978
- They reject with a `VaultFailure`, an `Error` with an `ErrorCode` `code` (`isVaultFailure` from `/ui`) and an optional `ErrorCause` `cause`, matching slicc-vault 1.22.0’s stable refusal causes; `isPolicyPaused` still identifies only `policy-paused`.
994
+ They reject with a `VaultFailure`, an `Error` with an `ErrorCode` `code` (`isVaultFailure` from `/ui`) and an optional `ErrorCause` `cause`, matching slicc-vault 1.22.0’s stable refusal causes (checked against the pinned 1.23.0, whose ask types `compat/vault.ts` also checks: `Requester`, `ValueConfig`, `ValueEvent` and, the other way, `ValueMessage`); `isPolicyPaused` still identifies only `policy-paused`.
979
995
  Values go to the vault only through `put`, and never through another port.
980
996
  `button()` returns a new `VaultButton`, `{ element, post(message), listen(fn) }` returning an unsubscribe: bios wraps the vault's `button.html` frame and its postMessage protocol, and spectrum only sends `ButtonMessage`s and receives `ButtonEvent`s.
981
997
  The passkey lock (unlock and protect) happens in the vault's own popup, which only a click in that frame can open.
998
+ The optional `valueFrame(request)` returns a new `VaultValueFrame` for an `ask` request, `{ element, post(message), listen(fn) }` like `button()`. bios wraps the vault's `value.html` frame. spectrum sends only `ValueMessage`s (`op: 'value'` with the request and a `ValueConfig`, and `op: 'focus'`) and receives only `ValueEvent`s (`ready`, `size`, `focus`, `done { request, state: 'sealed' | 'allowed' }`, `gone { request, state }`, `error { code, message }`, `escape`). The value is typed in that frame, and a reveal is allowed only by a click there, so the page never holds the value or the consent.
999
+ An `ask` request in `status.waiting` also carries `mode`, `reveal` and `requester` (`{ kind, name?, where?, exe, argv, cwd, tty, stdin }`). Settings › Secrets lists it with Cancel request only, and `RequestState` includes `withdrawn`.
982
1000
  `notices`, `acknowledge` and the `notice` event are still typed, and ignored.
983
1001
 
984
1002
  #### `guests` (optional)
@@ -1099,6 +1117,7 @@ Import offers five invented candidates (one replaces a secret, one is a model ke
1099
1117
  Its buttons are plain Spectrum buttons that answer at once: Unlock unlocks, and created buttons are in `model.vault.buttons`.
1100
1118
  `vaultFixtures`, `DummyVault` and `DummyVaultButton` are exported from `/dummy`.
1101
1119
  `createDummyModel({ hold: 'waiting' | 'locked' | 'off' })` (`?hold=` on `/ui/`) makes the agent hold two tool results; a `locked` hold clears when the dummy vault unlocks, and `model.agent.setHold(hold)` changes it.
1120
+ `createDummyModel({ older: 'paged' })` (`?older=paged` on `/ui/`, off by default) holds back all but the last six messages of every conversation, and the agent's `older` brings them back four at a time after a short wait (`model.agent.olderTicks`, 10 ticks); `model.agent.paginate(keep, size, ids?)` holds them back again for those agents (all by default), `failOlder(times)` makes the next loads reject, and `olderCalls` lists the agents each load was for. `hasOlder` is false wherever nothing is held back.
1102
1121
 
1103
1122
  On phones the update status and the tray connection show only their status light (the tray's budget is hidden) and the network indicator a cloud icon, with the text in their labels and tooltips; the agent picker keeps room for the cone's name, cut with an ellipsis when long, and the bottom rail scrolls when needed.
1104
1123
  On tablets the agent picker gives up room before the View menu does, so the header keeps the View menu and its last button in view from 640 to 1199 px, the tray's chips stay on one line each, the budget chip leaves out its window and a tray that isn't live shows only its state.
@@ -148,6 +148,86 @@ function trayFixtures(scenario, now2) {
148
148
  const id = scenario === "tray-expired" ? "tray-url" : scenario;
149
149
  return all.filter((approval) => approval.id === id);
150
150
  }
151
+ function secretFixtures(scenario, now2) {
152
+ const base = { createdAt: now2, expiresAt: now2 + 5 * minutes };
153
+ const enter = {
154
+ ...base,
155
+ id: "ask-harbor",
156
+ kind: "secret",
157
+ mode: "enter",
158
+ name: "HARBOR_DEPLOY_TOKEN",
159
+ prompt: "Paste the harbor deploy token from the release checklist.",
160
+ reveal: false,
161
+ requester: { kind: "user", name: "cone", where: "Terminal 1" },
162
+ process: {
163
+ exe: "/usr/bin/fnox",
164
+ argv: ["fnox", "set", "HARBOR_DEPLOY_TOKEN"],
165
+ cwd: "/home/cone/harbor"
166
+ }
167
+ };
168
+ const reveal = {
169
+ ...base,
170
+ id: "ask-lagoon",
171
+ kind: "secret",
172
+ mode: "reveal",
173
+ name: "LAGOON_API_KEY",
174
+ prompt: "The tide report script signs its uploads with this key.",
175
+ reveal: true,
176
+ requester: { kind: "agent", name: "docs-pass", where: "scoop:docs" },
177
+ process: {
178
+ exe: "/usr/bin/sealed",
179
+ argv: ["sealed", "ask", "LAGOON_API_KEY", "--reveal"],
180
+ cwd: "/home/cone/harbor"
181
+ },
182
+ createdAt: now2 + 1
183
+ };
184
+ if (scenario === "secret-enter") return [enter];
185
+ if (scenario === "secret-reveal") return [reveal];
186
+ if (scenario === "secret-long")
187
+ return [
188
+ {
189
+ ...enter,
190
+ id: "ask-long",
191
+ reveal: true,
192
+ requester: { kind: "agent", name: "quiet-otter" },
193
+ prompt: `Paste it\u202Eevil\r${"and wait. ".repeat(30)}`,
194
+ process: {
195
+ exe: "/usr/bin/sh",
196
+ argv: [
197
+ "sh",
198
+ "-c",
199
+ [longCommand, longCommand, longCommand].join(" && \\\n"),
200
+ "tide\rreport"
201
+ ],
202
+ cwd: "/home/cone/harbor\rferry"
203
+ }
204
+ }
205
+ ];
206
+ return [enter, reveal];
207
+ }
208
+ function pendingAsk(approval) {
209
+ const { kind, name: name2, where } = approval.requester;
210
+ return {
211
+ id: approval.id,
212
+ kind: "ask",
213
+ name: approval.name,
214
+ ...approval.prompt ? { prompt: approval.prompt } : {},
215
+ mode: approval.mode,
216
+ reveal: approval.reveal,
217
+ requester: {
218
+ kind,
219
+ name: name2,
220
+ ...where ? { where } : {},
221
+ ...approval.process,
222
+ tty: kind === "user" ? "/dev/pts/1" : null,
223
+ stdin: kind === "user" ? "tty" : "pipe"
224
+ },
225
+ uid: 1e3,
226
+ tag: kind,
227
+ createdAt: approval.createdAt,
228
+ expiresAt: approval.expiresAt
229
+ };
230
+ }
151
231
  function levelFixture(scenario, now2) {
152
232
  const base = {
153
233
  id: `sudo-${scenario}`,
@@ -296,6 +376,7 @@ function approvalFixtures(scenario, now2 = Date.now()) {
296
376
  if (scenario === "guest" || scenario === "guest-follower") return guestFixtures(now2);
297
377
  if (scenario === "guest-always") return guestAlways(now2);
298
378
  if (scenario.startsWith("tray")) return trayFixtures(scenario, now2);
379
+ if (scenario.startsWith("secret-") || scenario === "secret") return secretFixtures(scenario, now2);
299
380
  if (scenario.startsWith("guest-")) return [guestCall(scenario, now2)];
300
381
  if (scenario === "none") return [];
301
382
  if (scenario === "long")
@@ -355,12 +436,20 @@ var DummyApprovals = class extends Emitter {
355
436
  #approvals;
356
437
  #time;
357
438
  #fail;
439
+ #vault;
440
+ #quiet = /* @__PURE__ */ new Set();
358
441
  decisions = [];
359
442
  decide;
360
- constructor(scenario, time) {
443
+ constructor(scenario, time, vault) {
361
444
  super();
362
445
  this.#approvals = approvalFixtures(scenario);
363
446
  this.#time = time;
447
+ this.#vault = vault;
448
+ if (vault) {
449
+ for (const approval of this.#approvals)
450
+ if (approval.kind === "secret") vault.hold(pendingAsk(approval));
451
+ vault.on("status", (status2) => this.#left(status2.waiting));
452
+ }
364
453
  this.#fail = scenario === "fail";
365
454
  if (scenario !== "follower" && scenario !== "guest-follower")
366
455
  this.decide = (id, decision) => this.#decide(id, decision);
@@ -370,10 +459,24 @@ var DummyApprovals = class extends Emitter {
370
459
  void time.sleep(100).then(() => this.end("tray-url", "expired"));
371
460
  }
372
461
  raise(approval) {
462
+ if (approval.kind === "secret") this.#vault?.hold(pendingAsk(approval));
373
463
  this.#approvals = [...this.#approvals.filter(({ id }) => id !== approval.id), approval];
374
464
  this.emit("approvals", this.list());
375
465
  }
466
+ #left(waiting2) {
467
+ const vault = this.#vault;
468
+ for (const approval of this.#approvals) {
469
+ if (approval.kind !== "secret" || waiting2.some(({ id }) => id === approval.id)) continue;
470
+ if (vault.answered.has(approval.id) || this.#quiet.has(approval.id)) this.#drop(approval.id);
471
+ else this.end(approval.id, "answered");
472
+ }
473
+ }
474
+ #drop(id) {
475
+ this.#approvals = this.#approvals.filter((approval) => approval.id !== id);
476
+ this.emit("approvals", this.list());
477
+ }
376
478
  end(id, reason) {
479
+ if (!this.#approvals.some((approval) => approval.id === id)) return;
377
480
  this.emit("ended", { id, reason });
378
481
  this.#approvals = this.#approvals.filter((approval) => approval.id !== id);
379
482
  this.emit("approvals", this.list());
@@ -385,7 +488,14 @@ var DummyApprovals = class extends Emitter {
385
488
  await this.#time.sleep(10);
386
489
  if (this.#fail) throw new Error("The kernel stopped waiting. Run the command again.");
387
490
  if (id === "guest-late") throw new Error("Someone else already answered this.");
491
+ const approval = this.#approvals.find((item) => item.id === id);
492
+ if (approval?.kind === "secret" && decision !== "deny")
493
+ throw new Error("Save or Allow in the vault\u2019s frame.");
388
494
  this.decisions.push([id, decision]);
495
+ if (approval?.kind === "secret" && this.#vault?.ask(id)) {
496
+ this.#quiet.add(id);
497
+ await this.#vault.cancel(id);
498
+ }
389
499
  this.end(id, "answered");
390
500
  }
391
501
  };
@@ -1916,6 +2026,11 @@ var DummyAgent = class extends Emitter {
1916
2026
  #ice = /* @__PURE__ */ new Map();
1917
2027
  #suggestions = /* @__PURE__ */ new Map();
1918
2028
  #runs = /* @__PURE__ */ new Map();
2029
+ #earlier = /* @__PURE__ */ new Map();
2030
+ #page = 0;
2031
+ #failing = 0;
2032
+ olderTicks = 10;
2033
+ olderCalls = [];
1919
2034
  #active;
1920
2035
  #clock;
1921
2036
  #effects;
@@ -2278,6 +2393,7 @@ var DummyAgent = class extends Emitter {
2278
2393
  const messages = this.#messages.get(agentId);
2279
2394
  if (agent.kind === "cone" && messages.length) this.#shelve(agent, messages);
2280
2395
  this.#messages.set(agentId, []);
2396
+ this.#earlier.delete(agentId);
2281
2397
  this.#queues.set(agentId, []);
2282
2398
  this.#suggestions.delete(agentId);
2283
2399
  this.emit("messages", agentId);
@@ -2309,8 +2425,36 @@ var DummyAgent = class extends Emitter {
2309
2425
  agent.thinking = thinking;
2310
2426
  this.#changed();
2311
2427
  }
2312
- async older() {
2313
- return [];
2428
+ paginate(keep, size, ids = [...this.#messages.keys()]) {
2429
+ this.#page = size;
2430
+ for (const agentId of ids) {
2431
+ const all = [...this.#earlier.get(agentId) ?? [], ...this.#messages.get(agentId) ?? []];
2432
+ const cut = Math.max(0, all.length - keep);
2433
+ this.#earlier.set(agentId, all.slice(0, cut));
2434
+ this.#messages.set(agentId, all.slice(cut));
2435
+ this.emit("messages", agentId);
2436
+ }
2437
+ }
2438
+ failOlder(times = 1) {
2439
+ this.#failing = times;
2440
+ }
2441
+ hasOlder(agentId) {
2442
+ return !!this.#earlier.get(agentId)?.length;
2443
+ }
2444
+ async older(agentId) {
2445
+ this.olderCalls.push(agentId);
2446
+ await this.#clock.sleep(this.olderTicks);
2447
+ if (this.#failing > 0) {
2448
+ this.#failing--;
2449
+ throw new Error("The earlier messages couldn\u2019t be read.");
2450
+ }
2451
+ const earlier = this.#earlier.get(agentId) ?? [];
2452
+ const page2 = earlier.splice(Math.max(0, earlier.length - this.#page));
2453
+ if (page2.length) {
2454
+ this.#messages.get(agentId)?.unshift(...page2);
2455
+ this.emit("messages", agentId);
2456
+ }
2457
+ return page2;
2314
2458
  }
2315
2459
  commands(agentId) {
2316
2460
  if (this.#agent(agentId)?.kind !== "cone") return [];
@@ -2354,6 +2498,7 @@ var DummyAgent = class extends Emitter {
2354
2498
  this.stop(agentId);
2355
2499
  this.#agents = this.#agents.filter((candidate) => candidate !== agent);
2356
2500
  this.#messages.delete(agentId);
2501
+ this.#earlier.delete(agentId);
2357
2502
  this.#queues.delete(agentId);
2358
2503
  this.#suggestions.delete(agentId);
2359
2504
  if (this.#active === agentId) this.select(agent.parentId ?? this.#agents[0].id);
@@ -6316,7 +6461,8 @@ function vaultAudit(now2 = Date.now()) {
6316
6461
  secret: "HARBOR_API_KEY",
6317
6462
  from: ["proxy", "device:link-pier"],
6318
6463
  to: ["proxy", "tailscale", "device:link-pier"]
6319
- }
6464
+ },
6465
+ { kind: "reveal", field: "agent", from: true, to: false }
6320
6466
  ]
6321
6467
  },
6322
6468
  {
@@ -6715,6 +6861,151 @@ var DummyVaultButton = class {
6715
6861
  this.#emit({ event: "popup", intent: resolved, state: "done" });
6716
6862
  }
6717
6863
  };
6864
+ var DummyValueFrame = class {
6865
+ element;
6866
+ request;
6867
+ config = null;
6868
+ messages = [];
6869
+ arming = 500;
6870
+ #listeners = /* @__PURE__ */ new Set();
6871
+ #vault;
6872
+ #box;
6873
+ #view = "";
6874
+ #armed = false;
6875
+ #finished = false;
6876
+ #sizes = new ResizeObserver(() => this.#size());
6877
+ constructor(vault, time, request) {
6878
+ this.#vault = vault;
6879
+ this.request = request;
6880
+ this.element = document.createElement("div");
6881
+ this.element.dataset.vaultValue = "";
6882
+ this.#box = document.createElement("div");
6883
+ this.#box.style.padding = "2px";
6884
+ this.element.append(this.#box);
6885
+ this.element.addEventListener("keydown", (event) => {
6886
+ if (event.key !== "Escape" || event.isComposing) return;
6887
+ event.preventDefault();
6888
+ event.stopPropagation();
6889
+ this.#emit({ event: "escape" });
6890
+ });
6891
+ vault.on("status", () => this.#render());
6892
+ this.#sizes.observe(this.#box);
6893
+ void time.sleep(0).then(() => this.#emit({ event: "ready" }));
6894
+ }
6895
+ post(message) {
6896
+ this.messages.push(message);
6897
+ if (message.op === "focus") {
6898
+ this.#box.querySelector("input, swc-button")?.focus();
6899
+ return;
6900
+ }
6901
+ this.request = message.request;
6902
+ this.config = message.config;
6903
+ this.element.style.colorScheme = message.config.theme ?? "";
6904
+ this.#render();
6905
+ }
6906
+ listen(listener) {
6907
+ this.#listeners.add(listener);
6908
+ return () => this.#listeners.delete(listener);
6909
+ }
6910
+ #emit(event) {
6911
+ const message = { type: "slicc-vault", v: 1, ...event };
6912
+ for (const listener of [...this.#listeners]) listener(message);
6913
+ }
6914
+ #size() {
6915
+ const { width, height } = this.#box.getBoundingClientRect();
6916
+ this.#emit({ event: "size", width: Math.ceil(width), height: Math.ceil(height), inset: 2 });
6917
+ }
6918
+ #render() {
6919
+ if (!this.config || this.#finished) return;
6920
+ const ask2 = this.#vault.ask(this.request);
6921
+ if (!ask2) {
6922
+ this.#finished = true;
6923
+ this.#box.replaceChildren();
6924
+ this.#emit({ event: "gone", request: this.request, state: "cancelled" });
6925
+ return;
6926
+ }
6927
+ const view = this.#vault.status()?.locked ? "locked" : ask2.mode ?? "enter";
6928
+ if (view === this.#view) return;
6929
+ if (view !== "locked" && (this.#view === "" || this.#view === "locked"))
6930
+ setTimeout(() => {
6931
+ this.#armed = true;
6932
+ this.#arm();
6933
+ }, this.arming);
6934
+ this.#view = view;
6935
+ this.#box.replaceChildren(...this.#controls(view, ask2));
6936
+ this.#arm();
6937
+ this.#size();
6938
+ }
6939
+ #arm() {
6940
+ for (const button of this.#box.querySelectorAll("[data-action=save], [data-action=allow]"))
6941
+ button.toggleAttribute("disabled", !this.#armed);
6942
+ }
6943
+ #button(action, label2, variant, click) {
6944
+ const button = document.createElement("swc-button");
6945
+ button.dataset.action = action;
6946
+ button.setAttribute("variant", variant);
6947
+ button.textContent = label2;
6948
+ button.addEventListener("click", click);
6949
+ return button;
6950
+ }
6951
+ #controls(view, ask2) {
6952
+ const text = document.createElement("p");
6953
+ text.style.margin = "0 0 8px";
6954
+ const problem = document.createElement("p");
6955
+ problem.dataset.problem = "";
6956
+ problem.style.margin = "8px 0 0";
6957
+ if (view === "locked") {
6958
+ text.textContent = "Unlock the vault to answer.";
6959
+ return [
6960
+ text,
6961
+ this.#button("unlock", "Unlock", "accent", () => {
6962
+ void this.#vault.answer({ kind: "unlock" });
6963
+ })
6964
+ ];
6965
+ }
6966
+ if (view === "reveal") {
6967
+ text.textContent = `${ask2.requester?.exe} gets the plaintext of ${ask2.name}`;
6968
+ return [
6969
+ text,
6970
+ this.#button("allow", "Allow", "negative", () => void this.#answer(problem, "")),
6971
+ problem
6972
+ ];
6973
+ }
6974
+ const label2 = document.createElement("label");
6975
+ label2.textContent = `Value for ${ask2.name}`;
6976
+ label2.style.display = "grid";
6977
+ const input = document.createElement("input");
6978
+ input.type = "password";
6979
+ input.dataset.field = "value";
6980
+ input.autocomplete = "off";
6981
+ label2.append(input);
6982
+ text.append(label2);
6983
+ return [
6984
+ text,
6985
+ this.#button("save", "Save", "accent", () => {
6986
+ const value = input.value;
6987
+ input.value = "";
6988
+ void this.#answer(problem, value);
6989
+ }),
6990
+ problem
6991
+ ];
6992
+ }
6993
+ async #answer(problem, value) {
6994
+ const reveal = this.#view === "reveal";
6995
+ problem.textContent = "";
6996
+ this.#finished = true;
6997
+ try {
6998
+ if (reveal) await this.#vault.allow(this.request);
6999
+ else await this.#vault.seal(this.request, value);
7000
+ this.#emit({ event: "done", request: this.request, state: reveal ? "allowed" : "sealed" });
7001
+ } catch (error) {
7002
+ this.#finished = false;
7003
+ const { code, message } = error;
7004
+ if (code === "EINVAL") problem.textContent = message;
7005
+ else this.#emit({ event: "error", code, message });
7006
+ }
7007
+ }
7008
+ };
6718
7009
  var DummyVault = class extends Emitter {
6719
7010
  #status;
6720
7011
  #list;
@@ -6724,6 +7015,9 @@ var DummyVault = class extends Emitter {
6724
7015
  #older = vaultOlder();
6725
7016
  #serial = 0;
6726
7017
  buttons = [];
7018
+ frames = [];
7019
+ answered = /* @__PURE__ */ new Set();
7020
+ revealed = [];
6727
7021
  puts = [];
6728
7022
  endpointChanges = [];
6729
7023
  settingsCalls = [];
@@ -6998,6 +7292,55 @@ var DummyVault = class extends Emitter {
6998
7292
  this.buttons.push(button);
6999
7293
  return button;
7000
7294
  }
7295
+ valueFrame(request) {
7296
+ const frame = new DummyValueFrame(this, this.#time, request);
7297
+ this.frames.push(frame);
7298
+ return frame;
7299
+ }
7300
+ hold(request) {
7301
+ this.#status = {
7302
+ ...this.#status,
7303
+ waiting: [...this.#status.waiting.filter(({ id }) => id !== request.id), request]
7304
+ };
7305
+ this.emit("status", this.#status);
7306
+ }
7307
+ ask(request) {
7308
+ return this.#status.waiting.find(({ id, kind }) => id === request && kind === "ask");
7309
+ }
7310
+ #asked(request) {
7311
+ this.#unlocked();
7312
+ const found = this.ask(request);
7313
+ if (!found) throw vaultFailure("ENOENT", "That request is no longer waiting.");
7314
+ return found;
7315
+ }
7316
+ async seal(request, value) {
7317
+ await this.#time.sleep(5);
7318
+ const found = this.#asked(request);
7319
+ const problem = valueProblem(value);
7320
+ if (problem) throw vaultFailure("EINVAL", problem);
7321
+ this.answered.add(request);
7322
+ this.#take(request);
7323
+ return this.#seal(found.name, "settings");
7324
+ }
7325
+ async allow(request) {
7326
+ await this.#time.sleep(5);
7327
+ const found = this.#asked(request);
7328
+ this.answered.add(request);
7329
+ this.revealed.push(request);
7330
+ this.#take(request);
7331
+ const { exe, argv, cwd, kind } = found.requester;
7332
+ this.#log(found.name, {
7333
+ type: "reveal",
7334
+ uid: found.uid,
7335
+ tag: found.tag,
7336
+ exe,
7337
+ argv: argv.join(" "),
7338
+ cwd,
7339
+ requester: kind,
7340
+ request
7341
+ });
7342
+ this.emit("list", this.#list);
7343
+ }
7001
7344
  autoLabel() {
7002
7345
  return this.#status.locked ? "Vault locked" : "Vault unlocked";
7003
7346
  }
@@ -7111,7 +7454,8 @@ function createDummyModel({
7111
7454
  monitor = "dummy",
7112
7455
  import: imports = "off",
7113
7456
  login,
7114
- budget = "plain"
7457
+ budget = "plain",
7458
+ older = "off"
7115
7459
  } = {}) {
7116
7460
  const time = clock(delay);
7117
7461
  const mount = mounts === "off" ? null : { scenario: mounts, point: mountPoint, files: mounted };
@@ -7136,8 +7480,10 @@ function createDummyModel({
7136
7480
  browser.release(agentId);
7137
7481
  };
7138
7482
  importPort(imports, agent, time);
7483
+ if (older === "paged") agent.paginate(6, 4);
7139
7484
  if (hold !== "off") agent.setHold({ reason: hold, held: 2 });
7140
7485
  const noticePort = noticesPort(agent, time, notices, readonly, arrival);
7486
+ const vaultPort = vault === "off" ? void 0 : new DummyVault(vault, time, vaultRecords(accounts2, mcp));
7141
7487
  if (scenario === "driving") void browser.run(drive);
7142
7488
  const base = {
7143
7489
  agent,
@@ -7180,11 +7526,9 @@ function createDummyModel({
7180
7526
  ...changes === "files" ? {} : { changes: new DummyChanges(files3, changes === "git") },
7181
7527
  ...noticePort ? { notices: noticePort } : {},
7182
7528
  ...tabs2 === "off" ? {} : { tabs: new DummyTabs(tabs2, time) },
7183
- ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time) },
7529
+ ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time, vaultPort) },
7184
7530
  ...follow === "off" ? {} : { follow: new DummyFollow(follow, time) },
7185
- ...vault === "off" ? {} : {
7186
- vault: new DummyVault(vault, time, vaultRecords(accounts2, mcp))
7187
- },
7531
+ ...vaultPort ? { vault: vaultPort } : {},
7188
7532
  ...guestsPort(guests, time)
7189
7533
  };
7190
7534
  base.vault?.on("status", (status2) => {
@@ -7207,6 +7551,7 @@ export {
7207
7551
  DummyNotices,
7208
7552
  DummyTabs,
7209
7553
  DummyUpdates,
7554
+ DummyValueFrame,
7210
7555
  DummyVault,
7211
7556
  DummyVaultButton,
7212
7557
  actionLimits,