Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ Compatibility is documented in release notes, not encoded in the version string.

### Added

- A tokenless `queue_start` now files a **start request** the operator confirms
in the BLUESKY queue panel, instead of dead-ending in a refusal. Deployed
web terminals never hold the scan launch token by design; the agent stages
and queues, the request appears in the queue panel beside the queue it would
drain, and the human's *Confirm start* click — the panel's own token-gated
start — is what arms it. Dismissing the request is always available and
starts nothing. Skill and error-message guidance across the scan stack now
explains this posture so agents hand the start to the human instead of
chasing the token in configuration.

- An archiver read that comes back empty now says why: the response carries a
coverage verdict — the window predates or postdates the archive, the channel
was never recorded, or the window holds a genuine gap — with the archive's
Expand Down Expand Up @@ -156,6 +166,13 @@ Compatibility is documented in release notes, not encoded in the version string.

### Changed

- Deployed agents can no longer reconfigure their own harness: the Claude Code
CLI's bundled harness-configuration skills (`update-config`,
`keybindings-help`, `fewer-permission-prompts`) are switched off in every
rendered project, and the `setup-mode` skill (which can patch config.yml)
left the operator preset's default roster — it stays in the artifact catalog
for admin profiles to opt into. Rebuilt control-assistant projects will
report preset staleness once; that is the intended signal.
- **Log out** moved into the web terminal's display menu, alongside
**Settings** — the two now sit side by side under a line naming the signed-in
user. The separate user chip in the header is gone, leaving search and the
Expand Down
32 changes: 25 additions & 7 deletions docs/source/how-to/bluesky/queue.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ One queue, three ways to drive it

.. code-block:: text

POST /queue/items add the current draft revision
POST /queue/start start draining (needs the launch token)
POST /queue/stop stop after the running item
POST /queue/abort abort the running plan — never gated
GET /queue what is queued and running
GET /runs recent runs; /runs/<id>/data for the numbers
POST /queue/items add the current draft revision
POST /queue/start start draining (needs the launch token)
POST /queue/start-request ask a token holder to start — arms nothing
POST /queue/stop stop after the running item
POST /queue/abort abort the running plan — never gated
GET /queue what is queued and running
GET /runs recent runs; /runs/<id>/data for the numbers

Every refusal comes back with a ``detail`` object of the form
``{"code": ..., "detail": ...}`` — a stable code for software to
Expand Down Expand Up @@ -115,6 +116,9 @@ quirks worth knowing:
- The launch token — this hands work straight to a moving machine.
* - Start the queue
- The launch token.
* - Ask for a start (file a start request)
- Nothing — the request arms nothing. Confirming it *is* the
token-gated start, done from the queue panel.
* - Stop the queue / abort the running plan
- Nothing. Ever. Anywhere.
* - Withdraw a pending stop
Expand All @@ -126,6 +130,14 @@ quirks worth knowing:
anything, even on an idle queue. Its halts and its read tools are never
taken away.

In a deployed control room the agent's environment never holds the launch
token at all — the token lives with the operator panels. The agent's
``queue_start`` then files a **start request**: it appears in the BLUESKY
queue panel beside the queue it would drain, with *Confirm start* and
*Dismiss* controls. Confirming fires the panel's own token-carrying start;
dismissing starts nothing. Either way, the human's click is the arming
decision.

.. dropdown:: When something is refused
:color: info
:icon: alert
Expand All @@ -138,7 +150,13 @@ quirks worth knowing:
for a repeat.

``launch_token_required``
The operation was armed and the deployment is not. Nothing was started.
The operation was armed and the caller held no valid token. Nothing was
started. (An agent asking for a plain start never hits this — it files a
start request for the panel instead.)

``queue_empty``
A start was requested with nothing queued, so there was nothing a
confirmation could run. Stage and add a plan first.

``browse_only_connector``
This deployment cannot execute scans at all — it is pointed at the
Expand Down
6 changes: 4 additions & 2 deletions docs/source/how-to/bluesky/run-first-scan.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ token, no switch can disable them:
process with its own copy of the devices. That is why the queue survives
restarts of everything around it.
- **Start queue** is checked against a **launch token** the deployment
holds. For the agent, starting is additionally switched off entirely
whenever the project's control-system writes are disabled.
holds. In deployed control rooms the agent never holds it — asking the
agent to start a scan gets you a **start request** to confirm in the
queue panel. For the agent, starting is additionally switched off
entirely whenever the project's control-system writes are disabled.

.. dropdown:: First-run hiccups
:color: info
Expand Down
4 changes: 3 additions & 1 deletion src/osprey/cli/build_injectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,9 @@ def _inject_bluesky(bluesky: BlueskyConfig, project_path: Path) -> None:
logger.info(" ✓ Injected Bluesky scan bridge (port %d)", bluesky.port)
logger.info(
" Token: `osprey deploy up` writes BLUESKY_LAUNCH_TOKEN to .env; "
"the `bluesky` MCP server's queue tools read it automatically."
"a host-run agent's queue tools read it automatically. Deployed web "
"terminals never receive it — their agents file a start request the "
"operator confirms in the BLUESKY queue panel."
)
logger.info(
" Images: `osprey deploy up` builds the bluesky-bridge image locally "
Expand Down
14 changes: 10 additions & 4 deletions src/osprey/deployment/web_terminals/env_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,16 @@ def _build_env_production_subset(
:mod:`osprey.deployment.container_lifecycle`, minted per deploy under
those fixed names). Neither kind is anything a web terminal presents to
anyone: the containers that need a service token read the deploy ``.env``
the main compose file hands them, and nothing in a web terminal reads one
at all. This is the security spec for this function: a var absent from the
enumerated list above can never appear in the returned dict, regardless of
what the input ``.env`` contains.
the main compose file hands them. The one web-terminal consumer that
WOULD read a service token if present — the bluesky MCP server's
``${BLUESKY_LAUNCH_TOKEN:-}`` — is tokenless here on purpose: an agent
container must never hold a write-arming bearer credential (any Bash or
Python it runs could read it and arm the queue with no approval), so its
``queue_start`` files a panel start request and the operator's panels
sidecar, which does receive the token, answers it. This is the security
spec for this function: a var absent from the enumerated list above can
never appear in the returned dict, regardless of what the input ``.env``
contains.

:param config: Raw deploy config (facility fields merged in — see
``modules.web_terminals.image_source`` in :func:`ensure_env_production`).
Expand Down
16 changes: 16 additions & 0 deletions src/osprey/interfaces/bluesky_panels/panels/bluesky/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ <h2>Queue</h2>
<button type="button" id="start-btn" class="btn" disabled>Start queue</button>
</div>

<!-- A pending start request: the agent (which never holds the launch
token in a deployed terminal) asked for a start, and THIS panel is
where a human answers. Confirm fires the same token-carrying
POST /queue/start as the Start button above; Dismiss withdraws the
request and starts nothing. Hidden unless the status summary
carries a `start_request` record (queue-view.js renders it). -->
<div id="start-request-card" class="start-request" hidden>
<p id="start-request-text" class="start-request-text"></p>
<div class="start-request-actions">
<button type="button" id="confirm-start-btn" class="btn confirm" disabled>
Confirm start &mdash; queue drains toward hardware
</button>
<button type="button" id="dismiss-request-btn" class="btn">Dismiss request</button>
</div>
</div>

<div id="running-card" class="running" hidden>
<div class="running-head">
<span class="running-kicker">running</span>
Expand Down
21 changes: 21 additions & 0 deletions src/osprey/interfaces/bluesky_panels/panels/bluesky/panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,27 @@ code {
margin-bottom: 12px;
background: var(--accent-tint-06);
}

/* A pending start request: the one card on this panel that asks the operator
for a decision, so it wears the caution tint (same vocabulary as
.btn.confirm — armed-and-consequential), never the accent or success one. */
.start-request {
border: 1px solid var(--color-accent-secondary);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 12px;
background: var(--accent-secondary-tint-08);
}
.start-request-text {
margin: 0 0 8px;
font-size: 12.5px;
color: var(--text-primary);
}
.start-request-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.running.selected { border-color: var(--color-accent-light); }

.running-head {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
* items_in_queue, items_in_history, running_item_uid, plan_queue_uid,
* plan_history_uid, queue_stop_pending, queue_autostart_enabled, ...}`, or
* `{available: false, reason}` when the manager could not be read at all.
* Either shape may carry `start_request` — the bridge-local record of a
* tokenless caller (the agent) asking a token holder (this panel) to start
* the queue; see `startRequest` / `describeStartRequest`.
* - `items` are the manager's own item documents (`item_uid`, `name`,
* `kwargs`, `meta.osprey_run_id`); the running item may carry `progress`.
*
Expand Down Expand Up @@ -238,6 +241,65 @@ export function queueControls(state) {
return { start, stop };
}

// The confirm control on a pending start request. This button IS the arming
// action — the sidecar attaches the launch token the agent never holds — so
// its label says what confirming does, not who asked.
export const CONFIRM_START_LABEL = 'Confirm start — queue drains toward hardware';
export const DISMISS_START_REQUEST_LABEL = 'Dismiss request';

/**
* The pending start request riding the status summary, or null.
*
* The record is bridge-local state (`queue.py`'s `_start_request`): an agent
* without the launch token filed it, and the ONLY thing that honours it is a
* human clicking the token-holding confirm — this accessor never decides
* anything, it only says whether there is something to render.
*
* @param {QueueState} state
* @returns {Record<string, any>|null}
*/
export function startRequest(state) {
const record = state.status && state.status.start_request;
return record && typeof record === 'object' ? /** @type {Record<string, any>} */ (record) : null;
}

/**
* The pending start request's operator-facing sentence.
*
* Names who asked, how much would run, and when — the three things an
* operator weighs before confirming. The item count is the count AT FILING
* time; the queue list right next to this card is the live truth, which is
* why the sentence points at it rather than restating it.
*
* @param {Record<string, any>} record
* @returns {string}
*/
export function describeStartRequest(record) {
const by = typeof record.requested_by === 'string' && record.requested_by ? record.requested_by : 'agent';
const count = Number.isInteger(record.items_in_queue)
? `${record.items_in_queue} item${record.items_in_queue === 1 ? '' : 's'} at the time`
: 'the queued items';
const at = formatRequestedAt(record.requested_at);
return (
`The ${by} asks to start the queue (${count}${at ? `, requested ${at}` : ''}). ` +
'Confirming runs the queue exactly as listed below.'
);
}

/**
* `requested_at` as a short local time, or null when unparseable — the
* sentence simply omits what it cannot state truthfully.
*
* @param {unknown} iso
* @returns {string|null}
*/
function formatRequestedAt(iso) {
if (typeof iso !== 'string' || !iso) return null;
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

/**
* The stop button's label, across the plain stop and the two-step withdrawal.
*
Expand Down
43 changes: 43 additions & 0 deletions src/osprey/interfaces/bluesky_panels/panels/bluesky/queue-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
createQueueStream,
describeProgress,
describeQueueStatus,
describeStartRequest,
historyChanged,
historyEmptyState,
historyRecords,
Expand All @@ -58,6 +59,7 @@ import {
queueEmptyState,
reduceQueueFrame,
refusalTone,
startRequest,
stopButtonClass,
stopButtonLabel,
writeOutcomeTone,
Expand Down Expand Up @@ -104,6 +106,10 @@ export function createQueueView({ root, api, onSelectRun }) {
const queueNote = byId('queue-note');
const queueBanner = byId('queue-banner');
const startBtn = /** @type {HTMLButtonElement} */ (byId('start-btn'));
const startRequestCard = byId('start-request-card');
const startRequestText = byId('start-request-text');
const confirmStartBtn = /** @type {HTMLButtonElement} */ (byId('confirm-start-btn'));
const dismissRequestBtn = /** @type {HTMLButtonElement} */ (byId('dismiss-request-btn'));
const stopBtn = /** @type {HTMLButtonElement} */ (byId('stop-btn'));
const stopNote = byId('stop-note');
const abortBtn = /** @type {HTMLButtonElement} */ (byId('abort-btn'));
Expand Down Expand Up @@ -271,6 +277,18 @@ export function createQueueView({ root, api, onSelectRun }) {
startBtn.disabled = controls.start.disabled;
startBtn.title = controls.start.reason || '';

// The pending start request, when the summary carries one. Confirm is the
// SAME arming action as the Start button — the sidecar attaches the launch
// token the requester never held — so it shares Start's usability gate and
// its reason tooltip. Dismiss is never gated: declining is always safe.
const request = startRequest(queue);
startRequestCard.hidden = request === null;
if (request !== null) {
startRequestText.textContent = describeStartRequest(request);
confirmStartBtn.disabled = controls.start.disabled;
confirmStartBtn.title = controls.start.reason || '';
}

// The stop button is never disabled — see `queueControls`. Whatever this
// panel believes about the manager is a tooltip, not a gate.
stopBtn.textContent = stopButtonLabel(controls.stop, stopConfirmArmed);
Expand Down Expand Up @@ -560,6 +578,31 @@ export function createQueueView({ root, api, onSelectRun }) {
);
});

// Confirming a start request fires the same token-carrying start as the
// Start button — the bridge clears the request record on a successful
// start, and the next SSE frame hides this card. One click, like Start:
// the human reading this card IS the deliberation the flow exists for.
confirmStartBtn.addEventListener('click', () => {
if (confirmStartBtn.disabled) return;
void queueWrite(
'POST',
'/queue/start',
{},
'Start confirmed — the queue is now draining toward hardware.',
writeOutcomeTone(true)
);
});

dismissRequestBtn.addEventListener('click', () => {
void queueWrite(
'DELETE',
'/queue/start-request',
undefined,
'Start request dismissed — nothing was started.',
writeOutcomeTone(false)
);
});

// Two-step ONLY on the withdrawal branch, matching the Plans view's
// Add-to-queue and discard-draft buttons. A plain stop fires on the first
// click: friction in front of a halt is friction in exactly the wrong place.
Expand Down
26 changes: 26 additions & 0 deletions src/osprey/interfaces/bluesky_panels/queue_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
- ``POST /queue/items/{uid}/move`` -> reorder one queued item
- ``DELETE /queue/items/{uid}`` -> drop one queued item
- ``POST /queue/start`` -> start draining the queue
- ``POST /queue/start-request`` -> file the tokenless "please start" record
- ``DELETE /queue/start-request`` -> dismiss a pending start request
- ``POST /queue/stop`` -> stop after the running item (``cancel: true`` withdraws)
- ``POST /queue/abort`` -> abort the plan already in motion
- ``GET /queue/events`` -> the queue's Server-Sent-Events stream
Expand Down Expand Up @@ -247,6 +249,30 @@ async def start_queue(request: Request) -> JSONResponse:
return await _forward_write(request, "POST", "/queue/start")


@router.post("/queue/start-request")
async def request_queue_start(request: Request) -> JSONResponse:
"""Relay the filing of a start request — the tokenless "please start" record.

Exists on the panel surface for symmetry and testing; the usual FILER is
the agent's MCP server talking to the bridge directly, and the usual
CONFIRMER is this sidecar's own ``POST /queue/start``, whose launch token
is the entire point of the flow. The resolved token rides along like every
other queue write (module docstring) and the bridge ignores it — the
request route arms nothing for anyone.
"""
return await _forward_write(request, "POST", "/queue/start-request")


@router.delete("/queue/start-request")
async def dismiss_queue_start_request(request: Request) -> JSONResponse:
"""Relay the dismissal of a pending start request — the panel's Dismiss control.

Ungated at the bridge (declining to start is the safe direction), so the
dismissal always answers, token or no token.
"""
return await _forward_write(request, "DELETE", "/queue/start-request")


@router.post("/queue/stop")
async def stop_queue(request: Request) -> JSONResponse:
"""Relay a queue stop, including the token-gated ``cancel: true`` withdrawal.
Expand Down
Loading
Loading