agent-tempo 1.7.0-beta.2 → 1.7.0-beta.4
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/CLAUDE.md +3 -1
- package/README.md +1 -1
- package/dashboard/package.json +1 -1
- package/dist/activities/outbox.js +7 -0
- package/dist/cli/commands.js +8 -1
- package/dist/client/core.js +16 -0
- package/dist/client/interface.d.ts +19 -1
- package/dist/daemon.js +22 -0
- package/dist/http/coat-check.d.ts +36 -0
- package/dist/http/coat-check.js +110 -0
- package/dist/http/gate-audit.js +1 -1
- package/dist/http/gate-registry.d.ts +66 -6
- package/dist/http/gate-registry.js +64 -3
- package/dist/http/inner-loop-routes.d.ts +12 -0
- package/dist/http/inner-loop-routes.js +14 -2
- package/dist/http/server.d.ts +11 -0
- package/dist/http/server.js +28 -1
- package/dist/pi/mission-control/actions.d.ts +22 -0
- package/dist/pi/mission-control/actions.js +37 -0
- package/dist/pi/mission-control/extension.d.ts +13 -1
- package/dist/pi/mission-control/extension.js +46 -4
- package/dist/tui/index.js +2 -0
- package/dist/types.d.ts +22 -10
- package/package.json +4 -4
package/CLAUDE.md
CHANGED
|
@@ -212,13 +212,15 @@ daemon worker notes, `npx ts-node` dev runner).
|
|
|
212
212
|
- **Claude Code headless adapter** (`agent: 'claude-code-headless'`, #520): Headless adapter that drives sessions via the official `claude` CLI as a per-turn `claude -p --output-format stream-json` subprocess. The whole point: turns bill against the host's existing Claude Code subscription extra-usage credits (Pro / Max plans) rather than a Console workspace API key — the only ToS-clean way for a third-party tool to tap that pool. Requires the `claude` binary on PATH AND a logged-in Claude Code session (`claude auth login`); recruit pre-flight rejects with an actionable error otherwise. Tool surface is the union of full Claude Code built-ins (Bash / Read / Write / Edit / Glob / Grep / WebSearch / WebFetch) and the agent-tempo MCP surface — registered via inline `--mcp-config` so `claude` spawns `dist/server.js` as its own MCP child (no in-process bridge). Recruit knobs: `permissionMode` (default `'acceptEdits'`) or `dangerouslySkipPermissions: true` (mutually exclusive). Sessions resume across restart via the existing `sessionId` metadata field — the same UUID is shared with the interactive `claude-code` adapter (per-cwd JSONL is per-cwd, not per-adapter). See `src/adapters/claude-code-headless/` and `examples/ensembles/tempo-headless-jam.yaml`.
|
|
213
213
|
- **Pi adapter** (`agent: 'pi'`, #632 / #666): Two modes. **(1) Interactive conductor** (#666): `agent-tempo up --agent pi --ensemble <name>` launches `pi` in a real terminal with the agent-tempo extension auto-loaded (`pi -e dist/pi/extension.js`); the Pi session self-bootstraps its Temporal workflow and attaches as a conductor/player — no separate recruiter step. From the TUI, `/recruit-conductor` relaunches the active ensemble's conductor — set `conductor.agent: pi` in that ensemble's lineup to make it a Pi conductor. Requires `@earendil-works/pi-coding-agent` on Node ≥ 22.19. Recommended: `ANTHROPIC_API_KEY` (without it the session falls back to Pi's own auth/default model). The `AGENT_TEMPO_*` env is auto-wired by `up`; power users can invoke the extension directly with `pi -e dist/pi/extension.js`. `--model provider/model` selector (e.g. `'github-copilot/gpt-4o'`) is a fast-follow. **(2) Headless player** (Phase 3a): `recruit` with `agent: 'pi'` — no terminal, no BaseAttachment; runs `createAgentSession` with an in-memory `SessionManager`; the module-scope singleton owns claim/heartbeat/tools/cue pump (MD-D). MD-C tool-access policy: `toolAccess: 'restricted'` (default — Bash/shell/exec HARD-BLOCKED) | `'standard'` (scoped Bash) | `'full'` (unsandboxed; requires `force: true`). `noExtensions: true` closes the S2 exec-tool-bypass gap. See `src/adapters/pi/` and `src/pi/headless.ts`.
|
|
214
214
|
- **Mission-control Pi extension** (3f): An observer-only Pi extension that turns one interactive Pi TUI into a live ensemble board + operator controller. HTTP-drives the daemon — coarse ensemble view via `/v1/events/:ensemble` SSE + fine per-player tail via `/inner` (T3); operator controls (cue/pause/play/restart/destroy + gate arm/disarm/decide) POST to the daemon write surface using `AGENT_TEMPO_HTTP_ADMIN_TOKEN`. **Never claims attachment or registers as a player** — invisible to the ensemble. ~200ms render throttle from an in-memory `BoardModel`. Requires an interactive Pi session with `ctx.hasUI`. See `src/pi/mission-control/`.
|
|
215
|
+
- **Command-center planner** (#700 P2): An inbox-less interactive Pi session (the operator's planning seat) that routes questions to players via correlated `cue` tags (`[Q <questionId>]`). Players answer with the `respond` MCP tool, which parks the answer on the per-ensemble maestro Q&A mailbox (TTL 1h, 20-slot cap). The planner is woken by an `answer` SSE event when the answer lands (`docs/SSE-PROTOCOL.md` §6). `/handoff` cues hand active work to a conductor (a registered player with a Temporal inbox). See `docs/concepts.md` for the Q&A mechanics.
|
|
216
|
+
- **Guardrail policy** (`guardrailPolicy` recruit arg, #700 P2): Per-player posture for the operator gate on headless Pi players. Four values — `'autonomous'` (default; no gate), `'monitored'` (gate engaged; fail-open: auto-allow after 45 s), `'supervised'` (gate engaged; fail-closed: auto-deny after 300 s; **client-cooperative in P2, not tamper-proof**; daemon-side enforcement is P2.1 #44), `'observe-only'` (non-`low-risk` tools hard-blocked outright). Persisted on `SessionMetadata`; re-sourced at each `(re)attach` from `getMetadata`. See `docs/concepts.md` for operator gate mechanics.
|
|
215
217
|
- **Mock adapter** (`agent: 'mock'`, dev mode only): Four modes: `echo` (echoes input), `scripted` (replays YAML scenario rules), `silent` (drains messages without replying — heartbeat-stale validation), `chaos` (probabilistic fail/crash injection via seeded PRNG). Only registered when `isDevMode()` is true; stripped from the npm tarball by `prepack`. See `src/adapters/mock/`.
|
|
216
218
|
- **Saveable state** (#334, ADR 0011): Per-player curated state slots — the player itself decides what context survives a restart. Three MCP tools: `save_state` (owner-only write, max 4 slots × 32 KiB), `fetch_state` (read self or peer; audit identity recorded on each entry's `savedBy`), `clear_state` (owner-only). `restart` accepts `loadFromState: true | 'someKey'` to seed the new session from a saved-state slot instead of (or, with `transcript: 'replay'`, alongside) transcript replay. Saved-state delivery uses `from: 'self-restart'` as a stable system identity. Empty-slot fallback: graceful — falls through to transcript replay with a log line. See [docs/design/334-player-saveable-state.md](docs/design/334-player-saveable-state.md).
|
|
217
219
|
- **Coat-check** (#318, ADR 0008): Per-ensemble transient content store on Maestro state. Solves the 100 KB cue body cap — stash a large artifact with `coat_check_put` (returns a ticket id) and attach the ticket to a `cue` via `attachmentTicket`; the recipient calls `coat_check_get` to pull the full body. Four MCP tools: `coat_check_put` (any player; max 32 KiB per entry, 20 slots per ensemble, TTL 7d default), `coat_check_get` (any player; bumps fetch-audit counters), `coat_check_list` (read-only survey; headers only, content omitted), `coat_check_evict` (owner or conductor). Saturation rejects with `CoatCheckSlotsFull` (no LRU eviction). See `src/tools/coat-check-*.ts` and [docs/adr/0008-coat-check-pattern.md](docs/adr/0008-coat-check-pattern.md).
|
|
218
220
|
- **Lineup examples**: Six pre-built ensemble YAML files in `examples/ensembles/` — `tempo-big-band`, `tempo-dev-team`, `tempo-review-squad`, `tempo-jam-session`, `tempo-mock-jam` (dev-mode all-mock ensemble), `tempo-headless-jam` (#520 — all-`claude-code-headless` subscription-billed ensemble). Load with `agent-tempo up --lineup <name>` or the `load_lineup` tool.
|
|
219
221
|
- **GitHub App identity** (`agent-tempo[bot]`): When a player writes to GitHub — issue comments, PR creation/merge, commits, labels, check runs — **use `./scripts/ensemble-gh`** instead of `gh`. The wrapper mints a short-lived installation token so the action is attributed to `agent-tempo[bot]`, not to the human maintainer, making the AI authorship visible. Plain `gh` is still correct for read-only local dev (`gh pr view`, `gh repo clone`, `gh auth status`). Every bot-authored comment/PR body must include the AI attribution footer documented in [docs/github-app.md](docs/github-app.md).
|
|
220
222
|
|
|
221
|
-
See [docs/concepts.md](docs/concepts.md) for the full glossary (Adapter, Attachment phases, Restart, Detach/Destroy, Migrate, Broadcast, Recall, Schedule, Lineup, Quality Gate, Worktree, Stage, Hold/Release, Pause/Resume, Maestro, TempoClient, and more).
|
|
223
|
+
See [docs/concepts.md](docs/concepts.md) for the full glossary (Adapter, Attachment phases, Restart, Detach/Destroy, Migrate, Broadcast, Recall, Schedule, Lineup, Quality Gate, Worktree, Stage, Hold/Release, Pause/Resume, Maestro, TempoClient, Guardrail policy, Command-center planner, and more).
|
|
222
224
|
|
|
223
225
|
## Commit Convention
|
|
224
226
|
|
package/README.md
CHANGED
|
@@ -277,7 +277,7 @@ From the TUI, `/recruit-conductor` relaunches the active ensemble's conductor
|
|
|
277
277
|
|
|
278
278
|
**Prerequisites:** `@earendil-works/pi-coding-agent` on Node ≥ 22.19. Recommended: `ANTHROPIC_API_KEY` (without it the session falls back to Pi's own auth/default model).
|
|
279
279
|
|
|
280
|
-
**Headless Pi players** — recruit as a background agent slot using `agent: 'pi'` (
|
|
280
|
+
**Headless Pi players** — recruit as a background agent slot using `agent: 'pi'`. Pass `guardrailPolicy: 'monitored' | 'supervised' | 'observe-only'` to control the operator gate (default `'autonomous'` — no gate). See [docs/concepts.md](docs/concepts.md#command-center-and-player-supervision) for the gate mechanics and the supervised caveat.
|
|
281
281
|
|
|
282
282
|
📖 [Pi integration reference → docs/design/pi-hardening-h1-h2-h3.md](docs/design/pi-hardening-h1-h2-h3.md)
|
|
283
283
|
|
package/dashboard/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo-dashboard",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.7.0-beta.
|
|
4
|
+
"version": "1.7.0-beta.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
|
|
7
7
|
"scripts": {
|
|
@@ -504,6 +504,13 @@ function createOutboxActivities(client, config, ingestTokens, gate) {
|
|
|
504
504
|
// REPLACES) means a restart re-mints and naturally revokes the stale
|
|
505
505
|
// token. Injected into the subprocess env as AGENT_TEMPO_INGEST_TOKEN.
|
|
506
506
|
const ingestToken = ingestTokens?.mint((0, config_1.sessionWorkflowId)(ensemble, targetName));
|
|
507
|
+
// #712 — record the DURABLE guardrail policy on the daemon gate at spawn
|
|
508
|
+
// (beside the ingest-token mint, same daemon process + lifecycle) so the
|
|
509
|
+
// gate's failMode cross-check is daemon-authoritative from the FIRST
|
|
510
|
+
// engagement — no lazy metadata query on the common path. Absent ⇒
|
|
511
|
+
// autonomous (the extension default). Mint REPLACES on restart; setPolicy
|
|
512
|
+
// likewise re-stamps the current durable posture (no silent downgrade).
|
|
513
|
+
gate?.setPolicy((0, config_1.sessionWorkflowId)(ensemble, targetName), guardrailPolicy ?? 'autonomous');
|
|
507
514
|
const { pid } = (0, spawn_1.spawnPiHeadless)({
|
|
508
515
|
name: targetName,
|
|
509
516
|
ensemble,
|
package/dist/cli/commands.js
CHANGED
|
@@ -1030,10 +1030,17 @@ async function up(opts) {
|
|
|
1030
1030
|
else if (p.detail)
|
|
1031
1031
|
out.dim(` Agent types already installed (${p.detail})`);
|
|
1032
1032
|
}
|
|
1033
|
+
else if (p.step === 'search-attributes') {
|
|
1034
|
+
// #46 — ensureInfra registers SAs with `quiet: true` (the per-attribute
|
|
1035
|
+
// success lines are suppressed for the extension TUI path). The CLI
|
|
1036
|
+
// restores visibility with a one-line summary here. `detail` is
|
|
1037
|
+
// "N failed" only when registration failed (errors/permission warnings
|
|
1038
|
+
// still print from registerSearchAttributes regardless of `quiet`).
|
|
1039
|
+
out.check('Search attributes registered', !p.detail, p.detail);
|
|
1040
|
+
}
|
|
1033
1041
|
else if (p.step === 'daemon') {
|
|
1034
1042
|
out.check(p.status === 'ok' ? 'Worker daemon running' : 'Worker daemon started', true, p.detail);
|
|
1035
1043
|
}
|
|
1036
|
-
// 'search-attributes' logs per-attribute internally via registerSearchAttributes.
|
|
1037
1044
|
},
|
|
1038
1045
|
});
|
|
1039
1046
|
}
|
package/dist/client/core.js
CHANGED
|
@@ -1109,6 +1109,22 @@ function createTempoClientCore(client, opts = {}) {
|
|
|
1109
1109
|
return null;
|
|
1110
1110
|
}
|
|
1111
1111
|
},
|
|
1112
|
+
async coatCheckPut(ensemble, input) {
|
|
1113
|
+
// #713 — thin wrapper over the maestro `coatCheckPut` Update so the daemon
|
|
1114
|
+
// HTTP route can stash on behalf of the inbox-less command-center planner.
|
|
1115
|
+
// Errors PROPAGATE (unlike getAnswer's tolerant catch): the workflow's
|
|
1116
|
+
// structured `CoatCheckSlotsFull` / `CoatCheckEntryTooLarge` failures must
|
|
1117
|
+
// reach the HTTP layer so it can map them to a 4xx instead of swallowing.
|
|
1118
|
+
const h = handle((0, config_1.maestroWorkflowId)(ensemble));
|
|
1119
|
+
return await h.executeUpdate(maestro_signals_1.coatCheckPutUpdate, { args: [input] });
|
|
1120
|
+
},
|
|
1121
|
+
async coatCheckGet(ensemble, input) {
|
|
1122
|
+
// #713 — redeem a ticket over the maestro `coatCheckGet` Update. `null` is
|
|
1123
|
+
// the workflow's normal "missing / expired / evicted" return (not an
|
|
1124
|
+
// error); genuine failures propagate to the HTTP layer.
|
|
1125
|
+
const h = handle((0, config_1.maestroWorkflowId)(ensemble));
|
|
1126
|
+
return await h.executeUpdate(maestro_signals_1.coatCheckGetUpdate, { args: [input] });
|
|
1127
|
+
},
|
|
1112
1128
|
async isAnySessionHeld(ensemble) {
|
|
1113
1129
|
// Scan the ensemble's sessions and check the per-session
|
|
1114
1130
|
// `outboxLocked` query. The maestro session is skipped — it's the
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
|
|
5
5
|
* external integrations) can depend on the interface without pulling in Ink/React.
|
|
6
6
|
*/
|
|
7
|
-
import type { AgentType, MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo, HostInfo, AnswerEntry } from '../types';
|
|
7
|
+
import type { AgentType, MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo, HostInfo, AnswerEntry, CoatCheckEntry } from '../types';
|
|
8
|
+
import type { CoatCheckPutInput, CoatCheckPutResult, CoatCheckGetInput } from '../workflows/maestro-signals';
|
|
8
9
|
import type { RestoreOrphansSummary } from '../reconcile/orphans';
|
|
9
10
|
import type { SubscribeOptions, TempoEvent } from '../http/event-types';
|
|
10
11
|
/**
|
|
@@ -427,6 +428,23 @@ export interface TempoClientCore {
|
|
|
427
428
|
* aggregate's outstanding-ask poll (which emits the `answer` SSE event).
|
|
428
429
|
*/
|
|
429
430
|
getAnswer(ensemble: string, questionId: string): Promise<AnswerEntry | null>;
|
|
431
|
+
/**
|
|
432
|
+
* #713 — stash a large content body on the per-ensemble coat-check (#318) over
|
|
433
|
+
* a maestro Update. Thin client wrapper around `coatCheckPutUpdate` so the
|
|
434
|
+
* daemon HTTP route (`POST /v1/ensembles/:e/coat-check`) can let the inbox-less
|
|
435
|
+
* command-center planner park a plan and hand off a ticket. `putBy` is the
|
|
436
|
+
* audit identity (set by the caller — the HTTP layer stamps the operator).
|
|
437
|
+
* Throws the workflow's structured ApplicationFailure on saturation /
|
|
438
|
+
* oversize (`CoatCheckSlotsFull` / `CoatCheckEntryTooLarge`).
|
|
439
|
+
*/
|
|
440
|
+
coatCheckPut(ensemble: string, input: CoatCheckPutInput): Promise<CoatCheckPutResult>;
|
|
441
|
+
/**
|
|
442
|
+
* #713 — redeem a coat-check ticket (#318) over a maestro Update. Returns the
|
|
443
|
+
* full entry (incl. content body) or `null` when the ticket is missing /
|
|
444
|
+
* expired / evicted. A successful redemption bumps the entry's fetch-audit
|
|
445
|
+
* counters (`fetchedBy` is the audit identity), so this is NOT a pure read.
|
|
446
|
+
*/
|
|
447
|
+
coatCheckGet(ensemble: string, input: CoatCheckGetInput): Promise<CoatCheckEntry | null>;
|
|
430
448
|
/** Disband an ensemble: terminate all sessions, scheduler, and maestro workflows. */
|
|
431
449
|
disbandEnsemble(ensemble: string): Promise<{
|
|
432
450
|
terminated: number;
|
package/dist/daemon.js
CHANGED
|
@@ -74,6 +74,8 @@ const grpc_shutdown_guard_1 = require("./utils/grpc-shutdown-guard");
|
|
|
74
74
|
const daemon_1 = require("./cli/daemon");
|
|
75
75
|
const client_3 = require("./client");
|
|
76
76
|
const orphans_1 = require("./reconcile/orphans");
|
|
77
|
+
const query_timeout_1 = require("./utils/query-timeout");
|
|
78
|
+
const signals_1 = require("./workflows/signals");
|
|
77
79
|
const agent_types_1 = require("./ensemble/agent-types");
|
|
78
80
|
const pre_flight_1 = require("./adapters/claude-code-headless/pre-flight");
|
|
79
81
|
const daemon_adapter_versions_1 = require("./daemon-adapter-versions");
|
|
@@ -970,6 +972,24 @@ async function main() {
|
|
|
970
972
|
const bootEpoch = Date.now();
|
|
971
973
|
aggregateRunner = new AggregateRunner({ client: httpClient, bootEpoch });
|
|
972
974
|
aggregateRunner.start();
|
|
975
|
+
// #712 — BOUNDED durable-policy resolver for the gate failMode cross-check.
|
|
976
|
+
// The gate is normally policy-populated at spawn (sync, no query); this is
|
|
977
|
+
// the rare-cache-miss fallback the ingest gate_pending path awaits. It MUST
|
|
978
|
+
// be bounded (utils/query-timeout) so it can never hang gate engagement. A
|
|
979
|
+
// successful query with an absent metadata field ⇒ 'autonomous' (a real,
|
|
980
|
+
// open posture). Timeout / error / workflow-gone ⇒ undefined ⇒ the route
|
|
981
|
+
// leaves the policy unresolved ⇒ open() enforces 'closed' (NO-FAIL-OPEN).
|
|
982
|
+
const reconcileClientForPolicy = reconcileClient;
|
|
983
|
+
const resolveGuardrailPolicy = async (workflowId) => {
|
|
984
|
+
try {
|
|
985
|
+
const handle = reconcileClientForPolicy.workflow.getHandle(workflowId);
|
|
986
|
+
const md = await (0, query_timeout_1.queryHandleWithTimeout)(handle, signals_1.getMetadataQuery);
|
|
987
|
+
return md?.guardrailPolicy ?? 'autonomous';
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
return undefined; // timeout / error / gone → indeterminate → open() enforces closed
|
|
991
|
+
}
|
|
992
|
+
};
|
|
973
993
|
httpServerHandle = await startHttpServer({
|
|
974
994
|
client: httpClient,
|
|
975
995
|
namespace: config.temporalNamespace,
|
|
@@ -984,6 +1004,8 @@ async function main() {
|
|
|
984
1004
|
// 3d MD-G — the same gate registry the worker's outbox auto-disarms on
|
|
985
1005
|
// detach/destroy; the HTTP server serves arm/disarm/decide + resolution.
|
|
986
1006
|
gate,
|
|
1007
|
+
// #712 — bounded durable-policy resolver for the failMode cross-check.
|
|
1008
|
+
resolveGuardrailPolicy,
|
|
987
1009
|
});
|
|
988
1010
|
log(`HTTP listening on http://${httpServerHandle.bindAddr}:${httpServerHandle.port}`);
|
|
989
1011
|
log(`Aggregate poll loop running (bootEpoch=${bootEpoch})`);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon coat-check routes (#713 / #42) — HTTP surface over the per-ensemble
|
|
3
|
+
* coat-check store (#318, ADR 0008).
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/ensembles/:e/coat-check { summary, content, contentType?, ttlMs? } → 200 { ticket, … }
|
|
6
|
+
* GET /v1/ensembles/:e/coat-check/:ticket → 200 { found, entry }
|
|
7
|
+
*
|
|
8
|
+
* **Why**: `coat_check_put` was MCP/Temporal-only, so the command-center planner
|
|
9
|
+
* (a mission-control Pi extension with NO Temporal inbox — it drives the daemon
|
|
10
|
+
* over HTTP) could only hand off plans INLINE on a cue. This route lets the
|
|
11
|
+
* planner park a plan and hand off a ticket instead, keeping the cue body lean.
|
|
12
|
+
*
|
|
13
|
+
* **Audit identity** is stamped by this layer as the operator (`maestro`) — the
|
|
14
|
+
* HTTP caller cannot supply `putBy` / `fetchedBy` (same anti-spoof posture as the
|
|
15
|
+
* MCP tools, where audit identity comes from `getPlayerId()`, never a caller arg).
|
|
16
|
+
*
|
|
17
|
+
* **Auth** (gated by the caller in server.ts):
|
|
18
|
+
* - `put` is a WRITE → Tier 2 (admin), consistent with `/ask` and the write surface.
|
|
19
|
+
* - `get` REDEEMS via a maestro Update that bumps fetch-audit counters — it is NOT
|
|
20
|
+
* a pure read, so it is also gated at Tier 2.
|
|
21
|
+
*/
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
23
|
+
import type { TempoClient } from '../client/interface';
|
|
24
|
+
/**
|
|
25
|
+
* Operator audit identity for HTTP-sourced coat-check ops. The command-center
|
|
26
|
+
* planner has no player id; like the `/cue` + `/ask` routes (which write as the
|
|
27
|
+
* maestro), stashes/redeems are attributed to the maestro. NOT caller-supplied —
|
|
28
|
+
* a fixed constant so the HTTP surface can't spoof a peer's audit identity.
|
|
29
|
+
*/
|
|
30
|
+
export declare const HTTP_COAT_CHECK_IDENTITY = "maestro";
|
|
31
|
+
/** A ticket is non-empty, ≤ cap, and URL/path-safe (it rides a GET path segment). */
|
|
32
|
+
export declare function isValidTicket(t: string | undefined): t is string;
|
|
33
|
+
/** POST /v1/ensembles/:e/coat-check — stash a content body, return a ticket. */
|
|
34
|
+
export declare function handleCoatCheckPut(req: IncomingMessage, res: ServerResponse, client: TempoClient, ensemble: string): Promise<void>;
|
|
35
|
+
/** GET /v1/ensembles/:e/coat-check/:ticket — redeem a ticket + pull the content. */
|
|
36
|
+
export declare function handleCoatCheckGet(res: ServerResponse, client: TempoClient, ensemble: string, ticket: string): Promise<void>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HTTP_COAT_CHECK_IDENTITY = void 0;
|
|
4
|
+
exports.isValidTicket = isValidTicket;
|
|
5
|
+
exports.handleCoatCheckPut = handleCoatCheckPut;
|
|
6
|
+
exports.handleCoatCheckGet = handleCoatCheckGet;
|
|
7
|
+
const responses_1 = require("./responses");
|
|
8
|
+
const body_1 = require("./body");
|
|
9
|
+
const validation_1 = require("../utils/validation");
|
|
10
|
+
const validation_2 = require("../utils/validation");
|
|
11
|
+
/**
|
|
12
|
+
* Operator audit identity for HTTP-sourced coat-check ops. The command-center
|
|
13
|
+
* planner has no player id; like the `/cue` + `/ask` routes (which write as the
|
|
14
|
+
* maestro), stashes/redeems are attributed to the maestro. NOT caller-supplied —
|
|
15
|
+
* a fixed constant so the HTTP surface can't spoof a peer's audit identity.
|
|
16
|
+
*/
|
|
17
|
+
exports.HTTP_COAT_CHECK_IDENTITY = 'maestro';
|
|
18
|
+
/** A ticket is non-empty, ≤ cap, and URL/path-safe (it rides a GET path segment). */
|
|
19
|
+
function isValidTicket(t) {
|
|
20
|
+
return typeof t === 'string' && t.length > 0 && t.length <= validation_2.COAT_CHECK_TICKET_MAX && validation_2.COAT_CHECK_TICKET_REGEX.test(t);
|
|
21
|
+
}
|
|
22
|
+
/** POST /v1/ensembles/:e/coat-check — stash a content body, return a ticket. */
|
|
23
|
+
async function handleCoatCheckPut(req, res, client, ensemble) {
|
|
24
|
+
if ((0, validation_1.validateEnsembleName)(ensemble) !== null) {
|
|
25
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-ensemble-name', ensemble });
|
|
26
|
+
}
|
|
27
|
+
const body = await (0, body_1.readJsonBody)(req);
|
|
28
|
+
if (body === body_1.BODY_TOO_LARGE)
|
|
29
|
+
return (0, responses_1.errorResponse)(res, 413, { error: 'body-too-large', limit: body_1.WRITE_BODY_MAX });
|
|
30
|
+
if (body === body_1.BODY_INVALID_JSON)
|
|
31
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-json' });
|
|
32
|
+
const summary = (0, body_1.stringField)(body, 'summary');
|
|
33
|
+
const content = (0, body_1.stringField)(body, 'content');
|
|
34
|
+
if (!summary)
|
|
35
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'missing-field', field: 'summary' });
|
|
36
|
+
if (!content)
|
|
37
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'missing-field', field: 'content' });
|
|
38
|
+
if (summary.length > validation_2.COAT_CHECK_SUMMARY_MAX) {
|
|
39
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'summary-too-long', limit: validation_2.COAT_CHECK_SUMMARY_MAX });
|
|
40
|
+
}
|
|
41
|
+
// Byte length, not char length — the workflow validator caps on UTF-8 bytes.
|
|
42
|
+
if (Buffer.byteLength(content, 'utf8') > validation_2.COAT_CHECK_CONTENT_MAX) {
|
|
43
|
+
return (0, responses_1.errorResponse)(res, 413, { error: 'content-too-large', limit: validation_2.COAT_CHECK_CONTENT_MAX });
|
|
44
|
+
}
|
|
45
|
+
const contentType = (0, body_1.stringField)(body, 'contentType');
|
|
46
|
+
if (contentType !== undefined && contentType.length > validation_2.COAT_CHECK_CONTENT_TYPE_MAX) {
|
|
47
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'content-type-too-long', limit: validation_2.COAT_CHECK_CONTENT_TYPE_MAX });
|
|
48
|
+
}
|
|
49
|
+
let ttlMs;
|
|
50
|
+
if (body.ttlMs !== undefined) {
|
|
51
|
+
if (typeof body.ttlMs !== 'number' ||
|
|
52
|
+
!Number.isInteger(body.ttlMs) ||
|
|
53
|
+
body.ttlMs < validation_2.COAT_CHECK_TTL_MIN_MS ||
|
|
54
|
+
body.ttlMs > validation_2.COAT_CHECK_TTL_MAX_MS) {
|
|
55
|
+
return (0, responses_1.errorResponse)(res, 400, {
|
|
56
|
+
error: 'invalid-field', field: 'ttlMs', min: validation_2.COAT_CHECK_TTL_MIN_MS, max: validation_2.COAT_CHECK_TTL_MAX_MS,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
ttlMs = body.ttlMs;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = await client.coatCheckPut(ensemble, {
|
|
63
|
+
summary,
|
|
64
|
+
content,
|
|
65
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
66
|
+
...(ttlMs !== undefined ? { ttlMs } : {}),
|
|
67
|
+
putBy: exports.HTTP_COAT_CHECK_IDENTITY,
|
|
68
|
+
});
|
|
69
|
+
(0, responses_1.jsonResponse)(res, 200, { ok: true, ensemble, ...result });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return mapCoatCheckError(res, ensemble, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** GET /v1/ensembles/:e/coat-check/:ticket — redeem a ticket + pull the content. */
|
|
76
|
+
async function handleCoatCheckGet(res, client, ensemble, ticket) {
|
|
77
|
+
if ((0, validation_1.validateEnsembleName)(ensemble) !== null) {
|
|
78
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-ensemble-name', ensemble });
|
|
79
|
+
}
|
|
80
|
+
if (!isValidTicket(ticket)) {
|
|
81
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-ticket', ticket });
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const entry = await client.coatCheckGet(ensemble, { ticket, fetchedBy: exports.HTTP_COAT_CHECK_IDENTITY });
|
|
85
|
+
// Mirror the sibling `/answer` route: 200 + `found:false` for the common
|
|
86
|
+
// "ticket already gone" case (missing / expired / evicted), rather than 404.
|
|
87
|
+
(0, responses_1.jsonResponse)(res, 200, { ok: true, ensemble, ticket, found: entry !== null, entry });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return mapCoatCheckError(res, ensemble, err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Map a thrown coat-check error to an HTTP response. Recognises the maestro
|
|
95
|
+
* hub-not-running errors (→ 404) and the workflow's structured saturation /
|
|
96
|
+
* oversize ApplicationFailures (→ 409 / 413); anything else → 500.
|
|
97
|
+
*/
|
|
98
|
+
function mapCoatCheckError(res, ensemble, err) {
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
if (/no session found|no maestro|workflow not found/i.test(message)) {
|
|
101
|
+
return (0, responses_1.errorResponse)(res, 404, { error: 'session-not-found', ensemble, detail: message });
|
|
102
|
+
}
|
|
103
|
+
if (/CoatCheckSlotsFull/i.test(message)) {
|
|
104
|
+
return (0, responses_1.errorResponse)(res, 409, { error: 'coat-check-slots-full', ensemble, detail: message });
|
|
105
|
+
}
|
|
106
|
+
if (/CoatCheckEntryTooLarge/i.test(message)) {
|
|
107
|
+
return (0, responses_1.errorResponse)(res, 413, { error: 'coat-check-entry-too-large', ensemble, detail: message });
|
|
108
|
+
}
|
|
109
|
+
return (0, responses_1.errorResponse)(res, 500, { error: 'coat-check-failed', ensemble, detail: message });
|
|
110
|
+
}
|
package/dist/http/gate-audit.js
CHANGED
|
@@ -42,7 +42,7 @@ exports.createGateAuditSink = createGateAuditSink;
|
|
|
42
42
|
*
|
|
43
43
|
* <AGENT_TEMPO_HOME>/gate-audit/<ensemble>/<workflowId>.jsonl
|
|
44
44
|
*
|
|
45
|
-
* Each {@link GateAuditRecord} (arm | disarm | decision) is one JSON line,
|
|
45
|
+
* Each {@link GateAuditRecord} (arm | disarm | decision | failmode-override) is one JSON line,
|
|
46
46
|
* appended SYNCHRONOUSLY at the decision/posture-change point so the durable
|
|
47
47
|
* record lands before the daemon hands back control (no buffering window where a
|
|
48
48
|
* crash loses an allow/deny). The `ensemble` sidecar (not part of the locked
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* coordination bus). Nothing here is imported by `src/workflows/`.
|
|
48
48
|
*/
|
|
49
49
|
import type { InnerFrame } from '../pi/inner-loop-publisher';
|
|
50
|
+
import type { GuardrailPolicy } from '../types';
|
|
50
51
|
/**
|
|
51
52
|
* MONITORED (fail-OPEN) timeout: a pending request auto-ALLOWS after this long
|
|
52
53
|
* (R3, locked). The cheap-if-wrong fuse for an otherwise-autonomous agent the
|
|
@@ -75,6 +76,26 @@ export declare const GATE_CLOSED_DENY_MS = 300000;
|
|
|
75
76
|
* (auto-ALLOW); `'closed'` = supervised (auto-DENY).
|
|
76
77
|
*/
|
|
77
78
|
export type GateFailMode = 'open' | 'closed';
|
|
79
|
+
/**
|
|
80
|
+
* #712 — the daemon-AUTHORITATIVE fail posture for a player, derived from its
|
|
81
|
+
* DURABLE `guardrailPolicy` (the source of truth on SessionMetadata). The
|
|
82
|
+
* `gate_pending` frame's `failMode` claim is ADVISORY only: the daemon enforces
|
|
83
|
+
* THIS regardless, so an engaging agent can't self-downgrade a supervised player
|
|
84
|
+
* out of fail-closed by stamping `'open'`.
|
|
85
|
+
*
|
|
86
|
+
* Posture — **fail-CLOSED except an EXPLICITLY `monitored`/`autonomous` player**:
|
|
87
|
+
* - `monitored` → `'open'` (operator-armed observability gate; fail-OPEN is its point)
|
|
88
|
+
* - `autonomous` → `'open'` (never engages the gate on its own; if an operator
|
|
89
|
+
* arms it, it behaves as monitored → fail-open)
|
|
90
|
+
* - `supervised` → `'closed'`
|
|
91
|
+
* - `observe-only` → `'closed'` (most-restrictive no-act posture; if the client
|
|
92
|
+
* no-act block is ever bypassed and it reaches the
|
|
93
|
+
* gate, auto-ALLOW would be backwards — defense-in-depth)
|
|
94
|
+
* - `undefined` (policy not yet resolved — e.g. post-daemon-restart pre-reconcile,
|
|
95
|
+
* or a bounded metadata query that timed out/errored) → `'closed'`
|
|
96
|
+
* (NO-FAIL-OPEN: uncertainty resolves to the safe posture).
|
|
97
|
+
*/
|
|
98
|
+
export declare function enforcedFailMode(policy: GuardrailPolicy | undefined): GateFailMode;
|
|
78
99
|
/**
|
|
79
100
|
* Terminal decision on a gated tool call. `auto-allow` = the open (monitored)
|
|
80
101
|
* timeout fired; `auto-deny` = the closed (supervised) timeout fired. The two
|
|
@@ -100,10 +121,12 @@ export interface GateRequestMeta {
|
|
|
100
121
|
*/
|
|
101
122
|
ensemble?: string;
|
|
102
123
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
124
|
+
* The engaging agent's ADVISORY failMode claim from the `gate_pending` frame
|
|
125
|
+
* (#700). **#712: advisory only** — {@link GateRegistry.open} ENFORCES the
|
|
126
|
+
* failMode from the player's durable policy ({@link enforcedFailMode}), not
|
|
127
|
+
* from this field. It is retained solely to DETECT a self-downgrade (frame
|
|
128
|
+
* claimed the weaker `open` while the policy enforces `closed` → a
|
|
129
|
+
* `failmode-override` audit record). Omitted ⇒ treated as the claim `'open'`.
|
|
107
130
|
*/
|
|
108
131
|
failMode?: GateFailMode;
|
|
109
132
|
}
|
|
@@ -133,6 +156,26 @@ export type GateAuditRecord = {
|
|
|
133
156
|
workflowId: string;
|
|
134
157
|
source: 'operator';
|
|
135
158
|
operatorTokenHint?: string;
|
|
159
|
+
} | {
|
|
160
|
+
/**
|
|
161
|
+
* #712 — the daemon enforced a STRICTER failMode than the gate_pending
|
|
162
|
+
* frame claimed (frame `open`, policy-enforced `closed`). An
|
|
163
|
+
* override-detected OBSERVABILITY signal — NEUTRAL-FACTUAL: it could be a
|
|
164
|
+
* client bug, a stale client, or an injection self-downgrade attempt; the
|
|
165
|
+
* operator/security interprets. This is exactly the self-downgrade vector
|
|
166
|
+
* the #712 cross-check closes.
|
|
167
|
+
*/
|
|
168
|
+
kind: 'failmode-override';
|
|
169
|
+
ts: string;
|
|
170
|
+
workflowId: string;
|
|
171
|
+
sessionId?: string;
|
|
172
|
+
requestId: string;
|
|
173
|
+
tool: string;
|
|
174
|
+
argsSummary: string;
|
|
175
|
+
claimedFailMode: GateFailMode;
|
|
176
|
+
enforcedFailMode: GateFailMode;
|
|
177
|
+
/** The durable policy that drove enforcement; `'unknown'` when unresolved. */
|
|
178
|
+
policy: GuardrailPolicy | 'unknown';
|
|
136
179
|
};
|
|
137
180
|
/**
|
|
138
181
|
* Append-only audit sink (daemon wires the JSONL writer; tests inject a spy).
|
|
@@ -180,11 +223,28 @@ export declare class GateRegistry {
|
|
|
180
223
|
disarm(workflowId: string, operatorTokenHint?: string): void;
|
|
181
224
|
/** Whether the gate is currently armed for a player (the engagement predicate reads this). */
|
|
182
225
|
isArmed(workflowId: string): boolean;
|
|
226
|
+
/**
|
|
227
|
+
* #712 — record the player's DURABLE `guardrailPolicy` so {@link open} can
|
|
228
|
+
* enforce its failMode authoritatively (frame claim ignored). Populated at
|
|
229
|
+
* spawn (beside the ingest-token mint), reconstructed at reconcile-on-boot, and
|
|
230
|
+
* lazily back-filled by the ingest route on a cache-miss. Cleared by
|
|
231
|
+
* {@link clearPlayer} (detach/destroy) like all per-player state.
|
|
232
|
+
*/
|
|
233
|
+
setPolicy(workflowId: string, policy: GuardrailPolicy): void;
|
|
234
|
+
/** #712 — the recorded durable policy, or `undefined` if not yet resolved. */
|
|
235
|
+
getPolicy(workflowId: string): GuardrailPolicy | undefined;
|
|
183
236
|
/**
|
|
184
237
|
* Open (or return the existing) pending request for a gated tool call.
|
|
185
238
|
* Idempotent on `requestId` — a retried open returns the existing entry so the
|
|
186
|
-
* source can safely re-register before polling.
|
|
187
|
-
*
|
|
239
|
+
* source can safely re-register before polling.
|
|
240
|
+
*
|
|
241
|
+
* #712 — DAEMON-AUTHORITATIVE failMode: the stored failMode is computed from the
|
|
242
|
+
* player's DURABLE policy ({@link enforcedFailMode}), NOT from `meta.failMode`
|
|
243
|
+
* (which is now an ADVISORY claim from the engaging agent). The route resolves +
|
|
244
|
+
* stores the policy (spawn / reconcile / lazy miss-resolve) before this call; an
|
|
245
|
+
* unresolved policy enforces `closed` (no-fail-open). When the frame claimed the
|
|
246
|
+
* weaker `open` but the policy enforces `closed`, a NEUTRAL-FACTUAL
|
|
247
|
+
* `failmode-override` audit record is emitted (the self-downgrade #712 closes).
|
|
188
248
|
*/
|
|
189
249
|
open(workflowId: string, requestId: string, meta: GateRequestMeta): void;
|
|
190
250
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GateRegistry = exports.GATE_CLOSED_DENY_MS = exports.GATE_AUTO_ALLOW_MS = void 0;
|
|
4
|
+
exports.enforcedFailMode = enforcedFailMode;
|
|
4
5
|
/**
|
|
5
6
|
* MONITORED (fail-OPEN) timeout: a pending request auto-ALLOWS after this long
|
|
6
7
|
* (R3, locked). The cheap-if-wrong fuse for an otherwise-autonomous agent the
|
|
@@ -21,6 +22,28 @@ exports.GATE_AUTO_ALLOW_MS = 45_000;
|
|
|
21
22
|
* constant for closed.
|
|
22
23
|
*/
|
|
23
24
|
exports.GATE_CLOSED_DENY_MS = 300_000;
|
|
25
|
+
/**
|
|
26
|
+
* #712 — the daemon-AUTHORITATIVE fail posture for a player, derived from its
|
|
27
|
+
* DURABLE `guardrailPolicy` (the source of truth on SessionMetadata). The
|
|
28
|
+
* `gate_pending` frame's `failMode` claim is ADVISORY only: the daemon enforces
|
|
29
|
+
* THIS regardless, so an engaging agent can't self-downgrade a supervised player
|
|
30
|
+
* out of fail-closed by stamping `'open'`.
|
|
31
|
+
*
|
|
32
|
+
* Posture — **fail-CLOSED except an EXPLICITLY `monitored`/`autonomous` player**:
|
|
33
|
+
* - `monitored` → `'open'` (operator-armed observability gate; fail-OPEN is its point)
|
|
34
|
+
* - `autonomous` → `'open'` (never engages the gate on its own; if an operator
|
|
35
|
+
* arms it, it behaves as monitored → fail-open)
|
|
36
|
+
* - `supervised` → `'closed'`
|
|
37
|
+
* - `observe-only` → `'closed'` (most-restrictive no-act posture; if the client
|
|
38
|
+
* no-act block is ever bypassed and it reaches the
|
|
39
|
+
* gate, auto-ALLOW would be backwards — defense-in-depth)
|
|
40
|
+
* - `undefined` (policy not yet resolved — e.g. post-daemon-restart pre-reconcile,
|
|
41
|
+
* or a bounded metadata query that timed out/errored) → `'closed'`
|
|
42
|
+
* (NO-FAIL-OPEN: uncertainty resolves to the safe posture).
|
|
43
|
+
*/
|
|
44
|
+
function enforcedFailMode(policy) {
|
|
45
|
+
return policy === 'monitored' || policy === 'autonomous' ? 'open' : 'closed';
|
|
46
|
+
}
|
|
24
47
|
/**
|
|
25
48
|
* Per-daemon operator-gate registry, keyed by the player's fixed session
|
|
26
49
|
* `workflowId`. One instance is constructed in the daemon and shared between the
|
|
@@ -77,12 +100,34 @@ class GateRegistry {
|
|
|
77
100
|
isArmed(workflowId) {
|
|
78
101
|
return this.gates.get(workflowId)?.armed ?? false;
|
|
79
102
|
}
|
|
103
|
+
// ── #712 durable-policy source of truth (daemon-authoritative failMode) ──────
|
|
104
|
+
/**
|
|
105
|
+
* #712 — record the player's DURABLE `guardrailPolicy` so {@link open} can
|
|
106
|
+
* enforce its failMode authoritatively (frame claim ignored). Populated at
|
|
107
|
+
* spawn (beside the ingest-token mint), reconstructed at reconcile-on-boot, and
|
|
108
|
+
* lazily back-filled by the ingest route on a cache-miss. Cleared by
|
|
109
|
+
* {@link clearPlayer} (detach/destroy) like all per-player state.
|
|
110
|
+
*/
|
|
111
|
+
setPolicy(workflowId, policy) {
|
|
112
|
+
this.gate(workflowId).policy = policy;
|
|
113
|
+
}
|
|
114
|
+
/** #712 — the recorded durable policy, or `undefined` if not yet resolved. */
|
|
115
|
+
getPolicy(workflowId) {
|
|
116
|
+
return this.gates.get(workflowId)?.policy;
|
|
117
|
+
}
|
|
80
118
|
// ── Pending requests (source opens; operator decides; source polls) ─────────
|
|
81
119
|
/**
|
|
82
120
|
* Open (or return the existing) pending request for a gated tool call.
|
|
83
121
|
* Idempotent on `requestId` — a retried open returns the existing entry so the
|
|
84
|
-
* source can safely re-register before polling.
|
|
85
|
-
*
|
|
122
|
+
* source can safely re-register before polling.
|
|
123
|
+
*
|
|
124
|
+
* #712 — DAEMON-AUTHORITATIVE failMode: the stored failMode is computed from the
|
|
125
|
+
* player's DURABLE policy ({@link enforcedFailMode}), NOT from `meta.failMode`
|
|
126
|
+
* (which is now an ADVISORY claim from the engaging agent). The route resolves +
|
|
127
|
+
* stores the policy (spawn / reconcile / lazy miss-resolve) before this call; an
|
|
128
|
+
* unresolved policy enforces `closed` (no-fail-open). When the frame claimed the
|
|
129
|
+
* weaker `open` but the policy enforces `closed`, a NEUTRAL-FACTUAL
|
|
130
|
+
* `failmode-override` audit record is emitted (the self-downgrade #712 closes).
|
|
86
131
|
*/
|
|
87
132
|
open(workflowId, requestId, meta) {
|
|
88
133
|
const g = this.gate(workflowId);
|
|
@@ -90,12 +135,28 @@ class GateRegistry {
|
|
|
90
135
|
g.ensemble = meta.ensemble;
|
|
91
136
|
if (g.pending.has(requestId))
|
|
92
137
|
return;
|
|
138
|
+
const enforced = enforcedFailMode(g.policy);
|
|
139
|
+
const claimed = meta.failMode ?? 'open';
|
|
140
|
+
if (enforced === 'closed' && claimed === 'open') {
|
|
141
|
+
this.audit({
|
|
142
|
+
kind: 'failmode-override',
|
|
143
|
+
ts: this.nowIso(),
|
|
144
|
+
workflowId,
|
|
145
|
+
...(meta.sessionId ? { sessionId: meta.sessionId } : {}),
|
|
146
|
+
requestId,
|
|
147
|
+
tool: meta.tool,
|
|
148
|
+
argsSummary: meta.argsSummary,
|
|
149
|
+
claimedFailMode: claimed,
|
|
150
|
+
enforcedFailMode: enforced,
|
|
151
|
+
policy: g.policy ?? 'unknown',
|
|
152
|
+
}, g.ensemble);
|
|
153
|
+
}
|
|
93
154
|
g.pending.set(requestId, {
|
|
94
155
|
tool: meta.tool,
|
|
95
156
|
argsSummary: meta.argsSummary,
|
|
96
157
|
sessionId: meta.sessionId,
|
|
97
158
|
createdAt: this.now(),
|
|
98
|
-
failMode:
|
|
159
|
+
failMode: enforced,
|
|
99
160
|
decision: null,
|
|
100
161
|
source: null,
|
|
101
162
|
});
|
|
@@ -24,6 +24,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
|
|
|
24
24
|
import type { InnerLoopRegistry } from './inner-loop';
|
|
25
25
|
import type { IngestTokenRegistry } from './ingest-registry';
|
|
26
26
|
import type { GateRegistry } from './gate-registry';
|
|
27
|
+
import type { GuardrailPolicy } from '../types';
|
|
27
28
|
/** Header carrying the per-player ingest token (the source-plane credential). */
|
|
28
29
|
export declare const INGEST_TOKEN_HEADER = "x-ingest-token";
|
|
29
30
|
export interface InnerLoopDeps {
|
|
@@ -40,6 +41,17 @@ export interface InnerLoopDeps {
|
|
|
40
41
|
* `gate_resolved` emission flows back via its injected publishToInner.
|
|
41
42
|
*/
|
|
42
43
|
gate?: GateRegistry;
|
|
44
|
+
/**
|
|
45
|
+
* #712 — BOUNDED resolver for a player's DURABLE `guardrailPolicy` (daemon
|
|
46
|
+
* wires it to a `getMetadataQuery` behind `utils/query-timeout`). Used by the
|
|
47
|
+
* ingest gate_pending path ONLY on a {@link GateRegistry.getPolicy} cache-miss
|
|
48
|
+
* (the common path is spawn/reconcile-populated → sync, no query). Returns the
|
|
49
|
+
* resolved policy (absent metadata field ⇒ `'autonomous'`), or `undefined` when
|
|
50
|
+
* the query TIMES OUT / ERRORS / the workflow is gone — the caller then leaves
|
|
51
|
+
* the gate policy unresolved so `open()` enforces `closed` (NO-FAIL-OPEN). The
|
|
52
|
+
* resolver MUST be bounded so it can never hang gate engagement.
|
|
53
|
+
*/
|
|
54
|
+
resolveGuardrailPolicy?: (workflowId: string) => Promise<GuardrailPolicy | undefined>;
|
|
43
55
|
}
|
|
44
56
|
/** True when the request originates from the same host (loopback). */
|
|
45
57
|
export declare function isLoopbackRemote(req: IncomingMessage): boolean;
|
|
@@ -86,12 +86,24 @@ async function handleInnerIngest(req, res, deps, ensemble, playerId) {
|
|
|
86
86
|
if (type === 'inner.gate_pending' && deps.gate) {
|
|
87
87
|
const f = body;
|
|
88
88
|
if (typeof f.requestId === 'string' && typeof f.tool === 'string') {
|
|
89
|
+
// #712 — ensure the gate knows the player's DURABLE guardrailPolicy BEFORE
|
|
90
|
+
// registering, so open() enforces failMode from policy (daemon-authoritative),
|
|
91
|
+
// not the frame's advisory claim. Fast path: already populated at spawn /
|
|
92
|
+
// reconcile (sync, no query). Miss (post-restart pre-reconcile): a BOUNDED
|
|
93
|
+
// getMetadataQuery; on success populate the gate. On timeout/error/gone the
|
|
94
|
+
// resolver returns undefined → we leave it unresolved → open() enforces
|
|
95
|
+
// 'closed' (NO-FAIL-OPEN).
|
|
96
|
+
if (deps.resolveGuardrailPolicy && deps.gate.getPolicy(workflowId) === undefined) {
|
|
97
|
+
const resolved = await deps.resolveGuardrailPolicy(workflowId);
|
|
98
|
+
if (resolved !== undefined)
|
|
99
|
+
deps.gate.setPolicy(workflowId, resolved);
|
|
100
|
+
}
|
|
89
101
|
deps.gate.open(workflowId, f.requestId, {
|
|
90
102
|
tool: f.tool,
|
|
91
103
|
argsSummary: typeof f.argsSummary === 'string' ? f.argsSummary : '',
|
|
92
104
|
ensemble,
|
|
93
|
-
// #
|
|
94
|
-
//
|
|
105
|
+
// #712 — ADVISORY claim only (open() enforces from policy); retained so a
|
|
106
|
+
// self-downgrade (frame 'open' vs policy-enforced 'closed') is auditable.
|
|
95
107
|
failMode: f.failMode === 'closed' ? 'closed' : 'open',
|
|
96
108
|
});
|
|
97
109
|
}
|
package/dist/http/server.d.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as http from 'http';
|
|
19
19
|
import type { TempoClient } from '../client/interface';
|
|
20
|
+
import type { GuardrailPolicy } from '../types';
|
|
20
21
|
import type { AggregateRunner } from './aggregate';
|
|
21
22
|
import type { InnerLoopRegistry } from './inner-loop';
|
|
22
23
|
import type { IngestTokenRegistry } from './ingest-registry';
|
|
@@ -106,6 +107,14 @@ export interface HttpServerOptions {
|
|
|
106
107
|
* detach/destroy) — same singleton pattern as the inner-loop registries.
|
|
107
108
|
*/
|
|
108
109
|
gate?: GateRegistry;
|
|
110
|
+
/**
|
|
111
|
+
* #712 — bounded resolver for a player's durable `guardrailPolicy`, used by the
|
|
112
|
+
* ingest gate_pending path on a {@link GateRegistry.getPolicy} cache-miss to
|
|
113
|
+
* keep the failMode cross-check daemon-authoritative. The daemon wires it to a
|
|
114
|
+
* `getMetadataQuery` behind `utils/query-timeout`; absent → the route skips the
|
|
115
|
+
* miss-resolve (an unresolved policy then enforces `closed`, no-fail-open).
|
|
116
|
+
*/
|
|
117
|
+
resolveGuardrailPolicy?: (workflowId: string) => Promise<GuardrailPolicy | undefined>;
|
|
109
118
|
}
|
|
110
119
|
export interface HttpServerHandle {
|
|
111
120
|
/** The actual port the server is listening on (after `.listen()` resolves). */
|
|
@@ -148,6 +157,8 @@ interface HandleContext {
|
|
|
148
157
|
ingestTokens: IngestTokenRegistry | null;
|
|
149
158
|
/** 3d MD-G operator-gate registry — null when unwired. */
|
|
150
159
|
gate: GateRegistry | null;
|
|
160
|
+
/** #712 — bounded durable-policy resolver for the failMode cross-check — null when unwired. */
|
|
161
|
+
resolveGuardrailPolicy: ((workflowId: string) => Promise<GuardrailPolicy | undefined>) | null;
|
|
151
162
|
}
|
|
152
163
|
/**
|
|
153
164
|
* Top-level request dispatcher — exported for unit tests that want to
|
package/dist/http/server.js
CHANGED
|
@@ -65,6 +65,7 @@ const fixtures_1 = require("./fixtures");
|
|
|
65
65
|
const writes_1 = require("./writes");
|
|
66
66
|
const catalog_1 = require("./catalog");
|
|
67
67
|
const qa_1 = require("./qa");
|
|
68
|
+
const coat_check_1 = require("./coat-check");
|
|
68
69
|
const port_file_1 = require("./port-file");
|
|
69
70
|
const responses_1 = require("./responses");
|
|
70
71
|
const snapshot_1 = require("./snapshot");
|
|
@@ -170,6 +171,7 @@ async function startHttpServer(opts) {
|
|
|
170
171
|
innerLoop: opts.innerLoop ?? null,
|
|
171
172
|
ingestTokens: opts.ingestTokens ?? null,
|
|
172
173
|
gate: opts.gate ?? null,
|
|
174
|
+
resolveGuardrailPolicy: opts.resolveGuardrailPolicy ?? null,
|
|
173
175
|
}).catch((err) => {
|
|
174
176
|
log('unhandled handler error:', err instanceof Error ? err.message : err);
|
|
175
177
|
if (!res.headersSent) {
|
|
@@ -282,7 +284,7 @@ async function handle(req, res, ctx) {
|
|
|
282
284
|
// reaches them regardless of the daemon's bind address. Only live when the
|
|
283
285
|
// daemon wired the registries; else they fall through to the 404/405 path.
|
|
284
286
|
if (ctx.innerLoop && ctx.ingestTokens) {
|
|
285
|
-
const innerDeps = { innerLoop: ctx.innerLoop, ingestTokens: ctx.ingestTokens, ...(ctx.gate ? { gate: ctx.gate } : {}) };
|
|
287
|
+
const innerDeps = { innerLoop: ctx.innerLoop, ingestTokens: ctx.ingestTokens, ...(ctx.gate ? { gate: ctx.gate } : {}), ...(ctx.resolveGuardrailPolicy ? { resolveGuardrailPolicy: ctx.resolveGuardrailPolicy } : {}) };
|
|
286
288
|
const ingestMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/inner\/ingest$/);
|
|
287
289
|
if (ingestMatch) {
|
|
288
290
|
if (method !== 'POST') {
|
|
@@ -405,6 +407,31 @@ async function handle(req, res, ctx) {
|
|
|
405
407
|
}
|
|
406
408
|
return (0, qa_1.handleAnswer)(res, ctx.client, ensemble, questionId);
|
|
407
409
|
}
|
|
410
|
+
// #713 — coat-check HTTP routes. The POST (2-segment) MUST be matched BEFORE
|
|
411
|
+
// the generic writeMatch below, which would otherwise 404 `coat-check` as an
|
|
412
|
+
// unknown write action. Both are Tier 2 (admin): `put` is a write; `get`
|
|
413
|
+
// REDEEMS via a maestro Update that bumps fetch-audit counters (not a pure read).
|
|
414
|
+
const coatCheckPutMatch = pathname.match(/^\/v1\/ensembles\/([^/]+)\/coat-check$/);
|
|
415
|
+
if (coatCheckPutMatch) {
|
|
416
|
+
const ensemble = decodeURIComponent(coatCheckPutMatch[1]);
|
|
417
|
+
if (!gateTier(2))
|
|
418
|
+
return; // write
|
|
419
|
+
if (method !== 'POST') {
|
|
420
|
+
return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'POST, OPTIONS' });
|
|
421
|
+
}
|
|
422
|
+
return (0, coat_check_1.handleCoatCheckPut)(req, res, ctx.client, ensemble);
|
|
423
|
+
}
|
|
424
|
+
const coatCheckGetMatch = pathname.match(/^\/v1\/ensembles\/([^/]+)\/coat-check\/([^/]+)$/);
|
|
425
|
+
if (coatCheckGetMatch) {
|
|
426
|
+
const ensemble = decodeURIComponent(coatCheckGetMatch[1]);
|
|
427
|
+
const ticket = decodeURIComponent(coatCheckGetMatch[2]);
|
|
428
|
+
if (!gateTier(2))
|
|
429
|
+
return; // redeem mutates fetch-audit counters → Tier 2
|
|
430
|
+
if (method !== 'GET') {
|
|
431
|
+
return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'GET, OPTIONS' });
|
|
432
|
+
}
|
|
433
|
+
return (0, coat_check_1.handleCoatCheckGet)(res, ctx.client, ensemble, ticket);
|
|
434
|
+
}
|
|
408
435
|
// Write surface (PR-7a of #340) — POST `/v1/ensembles/:ensemble/<action>`
|
|
409
436
|
// Match BEFORE the GET-only method gate; everything else (POST to a
|
|
410
437
|
// read endpoint, GET to a write endpoint) flows into the 405 fallback
|
|
@@ -36,6 +36,9 @@ export declare class MissionControlActions {
|
|
|
36
36
|
get ready(): boolean;
|
|
37
37
|
private baseUrl;
|
|
38
38
|
private post;
|
|
39
|
+
/** POST and parse a JSON response body (bearer-authed). Used when the caller
|
|
40
|
+
* needs the response payload, not just success — e.g. the coat-check ticket. */
|
|
41
|
+
private postJson;
|
|
39
42
|
/** GET a JSON body from the daemon (bearer-authed). Used by the read surface (#700 readAnswer). */
|
|
40
43
|
private getJson;
|
|
41
44
|
private ens;
|
|
@@ -90,6 +93,25 @@ export declare class MissionControlActions {
|
|
|
90
93
|
* transport error) — the caller polls or waits for the SSE `answer` wake.
|
|
91
94
|
*/
|
|
92
95
|
readAnswer(questionId: string): Promise<AnswerEntry | null>;
|
|
96
|
+
/**
|
|
97
|
+
* Stash a content body on the ensemble coat-check (`POST /v1/ensembles/:e/coat-check`)
|
|
98
|
+
* and return the ticket. Lets the inbox-less planner park a large plan and hand
|
|
99
|
+
* off a ticket instead of inlining it on a cue. NOTE (#713): the coat-check entry
|
|
100
|
+
* cap (32 KiB) is BELOW the 100 KB cue cap, so this keeps cues lean — it does NOT
|
|
101
|
+
* raise the handoff ceiling.
|
|
102
|
+
*/
|
|
103
|
+
coatCheckPut(opts: {
|
|
104
|
+
summary: string;
|
|
105
|
+
content: string;
|
|
106
|
+
contentType?: string;
|
|
107
|
+
ttlMs?: number;
|
|
108
|
+
}): Promise<{
|
|
109
|
+
ok: true;
|
|
110
|
+
ticket: string;
|
|
111
|
+
} | {
|
|
112
|
+
ok: false;
|
|
113
|
+
error: string;
|
|
114
|
+
}>;
|
|
93
115
|
gateArm(playerId: string): Promise<ActionResult>;
|
|
94
116
|
gateDisarm(playerId: string): Promise<ActionResult>;
|
|
95
117
|
gateDecide(playerId: string, requestId: string, decision: 'allow' | 'deny'): Promise<ActionResult>;
|
|
@@ -62,6 +62,31 @@ class MissionControlActions {
|
|
|
62
62
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/** POST and parse a JSON response body (bearer-authed). Used when the caller
|
|
66
|
+
* needs the response payload, not just success — e.g. the coat-check ticket. */
|
|
67
|
+
async postJson(pathSuffix, body) {
|
|
68
|
+
if (!this.adminToken)
|
|
69
|
+
return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
|
|
70
|
+
if (!this.fetchFn)
|
|
71
|
+
return { ok: false, error: 'no fetch transport available' };
|
|
72
|
+
const base = this.baseUrl();
|
|
73
|
+
if (base === null)
|
|
74
|
+
return { ok: false, error: 'daemon HTTP not reachable (no port)' };
|
|
75
|
+
try {
|
|
76
|
+
const res = await this.fetchFn(`${base}${pathSuffix}`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { Authorization: `Bearer ${this.adminToken}`, 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify(body ?? {}),
|
|
80
|
+
});
|
|
81
|
+
const text = await res.text().catch(() => '');
|
|
82
|
+
if (res.status < 200 || res.status >= 300)
|
|
83
|
+
return { ok: false, error: `HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` };
|
|
84
|
+
return { ok: true, data: JSON.parse(text) };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
65
90
|
/** GET a JSON body from the daemon (bearer-authed). Used by the read surface (#700 readAnswer). */
|
|
66
91
|
async getJson(pathSuffix) {
|
|
67
92
|
if (!this.adminToken)
|
|
@@ -151,6 +176,18 @@ class MissionControlActions {
|
|
|
151
176
|
const res = await this.getJson(`/v1/ensembles/${this.ens()}/answer/${encodeURIComponent(questionId)}`);
|
|
152
177
|
return res.ok ? (res.data.answer ?? null) : null;
|
|
153
178
|
}
|
|
179
|
+
// ── Coat-check surface (#713) ──
|
|
180
|
+
/**
|
|
181
|
+
* Stash a content body on the ensemble coat-check (`POST /v1/ensembles/:e/coat-check`)
|
|
182
|
+
* and return the ticket. Lets the inbox-less planner park a large plan and hand
|
|
183
|
+
* off a ticket instead of inlining it on a cue. NOTE (#713): the coat-check entry
|
|
184
|
+
* cap (32 KiB) is BELOW the 100 KB cue cap, so this keeps cues lean — it does NOT
|
|
185
|
+
* raise the handoff ceiling.
|
|
186
|
+
*/
|
|
187
|
+
async coatCheckPut(opts) {
|
|
188
|
+
const res = await this.postJson(`/v1/ensembles/${this.ens()}/coat-check`, opts);
|
|
189
|
+
return res.ok ? { ok: true, ticket: res.data.ticket } : res;
|
|
190
|
+
}
|
|
154
191
|
// ── Operator gate plane (T3) ──
|
|
155
192
|
gateArm(playerId) {
|
|
156
193
|
return this.post(`/v1/players/${this.player(playerId)}/gate-arm`, {});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type BoardModel } from './board';
|
|
2
|
-
import { MissionControlActions } from './actions';
|
|
2
|
+
import { MissionControlActions, type ActionResult } from './actions';
|
|
3
3
|
import { type InfraProgress } from '../../cli/ensure-infra';
|
|
4
4
|
import type { McExtensionAPI, McExtensionContext, McOutboundMessage, McMessageOptions } from './pi-ui';
|
|
5
5
|
/**
|
|
@@ -93,6 +93,18 @@ export declare class Controller {
|
|
|
93
93
|
/** Bounded poll of the answer mailbox (human `/ask` path). Resolves null on timeout. */
|
|
94
94
|
private pollAnswer;
|
|
95
95
|
cmdHandoff(args: string, ctx: McExtensionContext): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* #713 — hand a plan to `target`. Plans in the stash band `(8 KiB, 32 KiB]`
|
|
98
|
+
* are parked on the coat-check (`coatCheckPut`) and the cue carries only a
|
|
99
|
+
* short summary + the redeem instruction, keeping the cue body lean. Smaller
|
|
100
|
+
* plans cue inline; larger ones (> 32 KiB) ALSO cue inline up to the 100 KB cue
|
|
101
|
+
* cap — the coat-check entry cap is below the cue cap, so it can't hold them.
|
|
102
|
+
*
|
|
103
|
+
* Public so both the `/handoff` slash command and the planner `handoff` tool
|
|
104
|
+
* share one path. If a stash fails (saturation / transport), falls back to an
|
|
105
|
+
* inline cue so the handoff still lands (a ≤ 32 KiB plan always fits a cue).
|
|
106
|
+
*/
|
|
107
|
+
handoffPlan(target: string, plan: string): Promise<ActionResult>;
|
|
96
108
|
}
|
|
97
109
|
/**
|
|
98
110
|
* #700 P2 — register the planner LLM tools (the "think with tools" half) on an
|
|
@@ -66,8 +66,22 @@ const actions_1 = require("./actions");
|
|
|
66
66
|
const inner_tail_1 = require("./inner-tail");
|
|
67
67
|
const ensure_infra_1 = require("../../cli/ensure-infra");
|
|
68
68
|
const zod_to_typebox_1 = require("../zod-to-typebox");
|
|
69
|
+
const validation_1 = require("../../utils/validation");
|
|
69
70
|
/** The durable conductor a `/handoff` targets by default (matches catalog's conductorName default). */
|
|
70
71
|
const DEFAULT_CONDUCTOR = 'conductor';
|
|
72
|
+
/**
|
|
73
|
+
* #713 — handoff stash band (UTF-8 bytes). A plan in `(MIN, MAX]` stashes to the
|
|
74
|
+
* coat-check so the cue body stays lean; below MIN an inline cue is fine; above
|
|
75
|
+
* MAX (the coat-check entry cap) we stay inline up to the 100 KB cue cap.
|
|
76
|
+
*
|
|
77
|
+
* HONEST FRAMING (#713): MAX === the coat-check entry cap (32 KiB), which is
|
|
78
|
+
* BELOW the 100 KB cue cap. Coat-check stashing keeps cues lean and lets the
|
|
79
|
+
* inbox-less planner park artifacts — it does NOT raise the handoff ceiling. A
|
|
80
|
+
* plan over 100 KB still cannot be handed off; that would need a coat-check cap
|
|
81
|
+
* increase (a separate decision with Temporal continue-as-new state implications).
|
|
82
|
+
*/
|
|
83
|
+
const HANDOFF_STASH_MIN_BYTES = 8 * 1024;
|
|
84
|
+
const HANDOFF_STASH_MAX_BYTES = validation_1.COAT_CHECK_CONTENT_MAX;
|
|
71
85
|
/** Bounded human-`/ask` poll: total wait + interval. The LLM `ask` tool yields instead (SSE wake). */
|
|
72
86
|
const ASK_POLL_TIMEOUT_MS = 30_000;
|
|
73
87
|
const ASK_POLL_INTERVAL_MS = 1_000;
|
|
@@ -370,9 +384,35 @@ class Controller {
|
|
|
370
384
|
this.notify(ctx, 'Usage: /handoff <plan> — pushes the plan to the durable conductor.');
|
|
371
385
|
return;
|
|
372
386
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
387
|
+
this.report(ctx, `handoff → ${DEFAULT_CONDUCTOR}`, await this.handoffPlan(DEFAULT_CONDUCTOR, plan));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* #713 — hand a plan to `target`. Plans in the stash band `(8 KiB, 32 KiB]`
|
|
391
|
+
* are parked on the coat-check (`coatCheckPut`) and the cue carries only a
|
|
392
|
+
* short summary + the redeem instruction, keeping the cue body lean. Smaller
|
|
393
|
+
* plans cue inline; larger ones (> 32 KiB) ALSO cue inline up to the 100 KB cue
|
|
394
|
+
* cap — the coat-check entry cap is below the cue cap, so it can't hold them.
|
|
395
|
+
*
|
|
396
|
+
* Public so both the `/handoff` slash command and the planner `handoff` tool
|
|
397
|
+
* share one path. If a stash fails (saturation / transport), falls back to an
|
|
398
|
+
* inline cue so the handoff still lands (a ≤ 32 KiB plan always fits a cue).
|
|
399
|
+
*/
|
|
400
|
+
async handoffPlan(target, plan) {
|
|
401
|
+
const bytes = Buffer.byteLength(plan, 'utf8');
|
|
402
|
+
if (bytes > HANDOFF_STASH_MIN_BYTES && bytes <= HANDOFF_STASH_MAX_BYTES) {
|
|
403
|
+
const preview = plan.replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
404
|
+
const stash = await this.actions.coatCheckPut({
|
|
405
|
+
summary: `[PLAN HANDOFF] ${preview}`,
|
|
406
|
+
content: plan,
|
|
407
|
+
contentType: 'text/markdown',
|
|
408
|
+
});
|
|
409
|
+
if (stash.ok) {
|
|
410
|
+
return this.actions.cue(target, `[PLAN HANDOFF] Full plan stashed on the coat-check (${bytes} bytes) — redeem with the ` +
|
|
411
|
+
`\`coat_check_get\` tool:\ncoat_check_get({ ticket: "${stash.ticket}" })`);
|
|
412
|
+
}
|
|
413
|
+
// Stash failed — fall through to inline (the plan fits the 100 KB cue cap).
|
|
414
|
+
}
|
|
415
|
+
return this.actions.cue(target, `[PLAN HANDOFF]\n${plan}`);
|
|
376
416
|
}
|
|
377
417
|
}
|
|
378
418
|
exports.Controller = Controller;
|
|
@@ -413,7 +453,9 @@ function registerPlannerTools(pi, ctrl) {
|
|
|
413
453
|
execute: async (_id, params) => {
|
|
414
454
|
const { plan, to } = params;
|
|
415
455
|
const target = to ?? DEFAULT_CONDUCTOR;
|
|
416
|
-
|
|
456
|
+
// #713 — large plans stash to the coat-check (cue carries the ticket);
|
|
457
|
+
// small plans inline. Shared with the `/handoff` slash command.
|
|
458
|
+
const r = await ctrl.handoffPlan(target, plan);
|
|
417
459
|
if (!r.ok)
|
|
418
460
|
throw new Error(`handoff failed: ${r.error}`);
|
|
419
461
|
return ok(`Plan handed off to ${target}.`);
|
package/dist/tui/index.js
CHANGED
|
@@ -141,6 +141,8 @@ function createDummyClient() {
|
|
|
141
141
|
isMaestroPaused: async () => false,
|
|
142
142
|
isAnySessionHeld: async () => false,
|
|
143
143
|
getAnswer: async () => null,
|
|
144
|
+
coatCheckPut: fail,
|
|
145
|
+
coatCheckGet: async () => null,
|
|
144
146
|
getGates: async () => [],
|
|
145
147
|
getStages: async () => [],
|
|
146
148
|
getWorktrees: async () => [],
|
package/dist/types.d.ts
CHANGED
|
@@ -238,16 +238,28 @@ export interface SessionMetadata {
|
|
|
238
238
|
* the real posture on EVERY attach (across restart / migrate / re-attach), so
|
|
239
239
|
* a previously-`supervised` agent stays supervised. (tempo-architect ruling.)
|
|
240
240
|
*
|
|
241
|
-
* **★
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
241
|
+
* **★ Enforcement scope (#715).** `supervised` is the daemon-enforced approval
|
|
242
|
+
* boundary for the realistic threat: a prompt-injected agent. A manipulated LLM
|
|
243
|
+
* can only *emit* tool-call requests — Pi routes every one to agent-tempo's
|
|
244
|
+
* `tool_call` handler, which engages the gate (non-`low-risk`) or hard-blocks
|
|
245
|
+
* (exec tools at `toolAccess: 'restricted'`). The agent **cannot** skip the gate
|
|
246
|
+
* or run a dangerous tool directly — it doesn't control the hook. The daemon also
|
|
247
|
+
* derives `failMode` from this durable policy (populated at spawn, falling
|
|
248
|
+
* `closed` on any lookup failure — no-fail-open), so an engaging agent can't
|
|
249
|
+
* self-downgrade a supervised player out of fail-closed.
|
|
250
|
+
*
|
|
251
|
+
* The **residual** is *process compromise*: code execution **inside** the Pi
|
|
252
|
+
* process (host RCE bypassing the handler entirely). No client-side gate defends
|
|
253
|
+
* that — it requires OS-level process sandboxing, tracked as a separate future
|
|
254
|
+
* `'sandboxed'` posture (#724). That is **not a gap in `supervised`'s scope**:
|
|
255
|
+
* supervised targets prompt-injection, and against that threat it **is** a real
|
|
256
|
+
* enforcement boundary.
|
|
257
|
+
*
|
|
258
|
+
* **Post-restart window:** on daemon restart the in-memory ingest tokens are
|
|
259
|
+
* invalidated, so existing players' gate engagements are rejected (403) until a
|
|
260
|
+
* re-spawn re-mints. In that window a `supervised` player's gate-client
|
|
261
|
+
* fail-closes on its own derived deadline (client-side safety holds, not
|
|
262
|
+
* daemon-mediated) — same process-compromise residual, not a distinct gap.
|
|
251
263
|
*/
|
|
252
264
|
guardrailPolicy?: GuardrailPolicy;
|
|
253
265
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo",
|
|
3
|
-
"version": "1.7.0-beta.
|
|
3
|
+
"version": "1.7.0-beta.4",
|
|
4
4
|
"description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -72,12 +72,12 @@
|
|
|
72
72
|
"copilot-bridge": "ts-node src/adapters/copilot/adapter.ts",
|
|
73
73
|
"clean:test": "node -e \"require('fs').rmSync('dist-test',{recursive:true,force:true})\"",
|
|
74
74
|
"build:test": "npm run clean:test && tsc -p test/tsconfig.json",
|
|
75
|
-
"pretest": "npm run build:test",
|
|
75
|
+
"pretest": "node scripts/check-bundle-present.js && npm run build:test",
|
|
76
76
|
"test:tui": "vitest run",
|
|
77
77
|
"test:conformance": "npm run build:test && mocha --config .mocharc.conformance.yml",
|
|
78
|
-
"pretest:shard-1": "npm run build:test && npm run build:scripts",
|
|
78
|
+
"pretest:shard-1": "node scripts/check-bundle-present.js && npm run build:test && npm run build:scripts",
|
|
79
79
|
"test:shard-1": "node dist/scripts/run-shard.js 1",
|
|
80
|
-
"pretest:shard-2": "npm run build:test && npm run build:scripts",
|
|
80
|
+
"pretest:shard-2": "node scripts/check-bundle-present.js && npm run build:test && npm run build:scripts",
|
|
81
81
|
"test:shard-2": "node dist/scripts/run-shard.js 2",
|
|
82
82
|
"test": "mocha && vitest run",
|
|
83
83
|
"lint:surface-drift": "node scripts/check-surface-drift.js",
|