@zgeoff/atc 2.15.0 → 2.15.2
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/package.json +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +2 -1
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +64 -77
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +206 -288
- package/src/daemon/daemon-context.ts +291 -0
- package/src/daemon/daemon.ts +250 -94
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +1 -7
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +175 -123
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/daemon/tap-registry.ts +43 -5
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/store/fleet-entry.ts +2 -2
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +49 -13
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.2",
|
|
4
4
|
"description": "Terminal control tower for coding-agent sessions",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/atc#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/atc/issues",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"build:atc-bridge": "bun scripts/build-atc-bridge.ts && oxfmt src/agents/atc-bridge-files.ts && format-codemod --quiet src/agents/atc-bridge-files.ts",
|
|
25
25
|
"build:binaries": "bash scripts/build-binaries.sh",
|
|
26
26
|
"audit": "bun audit",
|
|
27
|
+
"check:imports": "bun scripts/check-imports.ts",
|
|
27
28
|
"deadcode": "knip",
|
|
28
29
|
"lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
|
|
29
30
|
"lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
|
|
@@ -1,24 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
2
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
3
|
+
import type { AgentID } from '../shared/agent-id';
|
|
2
4
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
5
|
import type { SessionID } from '../shared/session-id';
|
|
4
6
|
|
|
5
|
-
/**
|
|
6
|
-
* Which agent a session runs under: the key the adapter registry is looked
|
|
7
|
-
* up by. Every agent CLI supplies one, and so does every configured backend
|
|
8
|
-
* that drives a CLI it does not own, so two ids can share one kind.
|
|
9
|
-
*/
|
|
10
|
-
export type AgentID = string;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Missing and empty values become Claude so a fleet written before the agent
|
|
14
|
-
* column still restores as Claude. Any other string is returned as it stands,
|
|
15
|
-
* registered or not: an id whose adapter is gone must reach the caller intact
|
|
16
|
-
* so the session can be shown and refused, never quietly run as Claude.
|
|
17
|
-
*/
|
|
18
|
-
export function toAgentID(raw: unknown): AgentID {
|
|
19
|
-
return typeof raw === 'string' && raw !== '' ? raw : 'claude';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
7
|
export interface SpawnOptions {
|
|
23
8
|
readonly prompt: string;
|
|
24
9
|
|
|
@@ -64,26 +49,6 @@ export interface GuestSpawnPlan extends SpawnPlan {
|
|
|
64
49
|
readonly files: Readonly<Record<string, string>>;
|
|
65
50
|
}
|
|
66
51
|
|
|
67
|
-
export interface AdapterEvent {
|
|
68
|
-
kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
|
|
69
|
-
agentSessionID?: AgentSessionID;
|
|
70
|
-
message?: string;
|
|
71
|
-
|
|
72
|
-
// Fuller activity text than message: what the agent last said or was
|
|
73
|
-
// asked, for briefing. Bounded by the adapter.
|
|
74
|
-
detail?: string;
|
|
75
|
-
|
|
76
|
-
// Opaque handle the adapter can later pull a session name from.
|
|
77
|
-
nameSource?: string;
|
|
78
|
-
|
|
79
|
-
// Claude resume-existence path. Distinct from nameSource: a naming
|
|
80
|
-
// handle is not a resume gate.
|
|
81
|
-
transcriptSource?: string;
|
|
82
|
-
|
|
83
|
-
// The agent's whole final message for a finished turn; detail holds a bounded preview of it.
|
|
84
|
-
result?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
52
|
export interface TranscriptToolUse {
|
|
88
53
|
readonly name: string;
|
|
89
54
|
readonly input: string;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type {
|
|
3
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
4
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
4
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
5
6
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
6
7
|
import type { Config } from '../shared/config';
|
|
7
8
|
import { isRecord } from '../shared/report';
|
|
8
9
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
9
10
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
11
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
10
12
|
import type {
|
|
11
|
-
AdapterEvent,
|
|
12
13
|
AgentAdapter,
|
|
13
14
|
AgentProfile,
|
|
14
15
|
GuestPaths,
|
|
@@ -31,7 +32,6 @@ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
|
31
32
|
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
32
33
|
import { planTypedLineInput } from './plan-typed-line-input';
|
|
33
34
|
import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
|
|
34
|
-
import { truncateDetail } from './truncate-detail';
|
|
35
35
|
import { writeATCBridge } from './write-atc-bridge';
|
|
36
36
|
import { writeHookSettings } from './write-hook-settings';
|
|
37
37
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import type {
|
|
4
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
5
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
5
6
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
7
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
7
8
|
import type { Config } from '../shared/config';
|
|
8
9
|
import { isRecord } from '../shared/report';
|
|
9
10
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
10
11
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
12
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
11
13
|
import type {
|
|
12
|
-
AdapterEvent,
|
|
13
14
|
AgentAdapter,
|
|
14
15
|
AgentProfile,
|
|
15
16
|
NameUpdate,
|
|
@@ -22,7 +23,6 @@ import { buildArgsWithoutFlags } from './build-args-without-flags';
|
|
|
22
23
|
import { findFlagValue } from './find-flag-value';
|
|
23
24
|
import { planPastedLineInput } from './plan-pasted-line-input';
|
|
24
25
|
import { resolveAgentHome } from './resolve-agent-home';
|
|
25
|
-
import { truncateDetail } from './truncate-detail';
|
|
26
26
|
|
|
27
27
|
// Codex's hook payload keys, snake_case. An absent or wrong-typed field
|
|
28
28
|
// parses to undefined rather than failing the payload, so a broken reporter
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
2
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
3
|
+
import type { AgentID } from '../shared/agent-id';
|
|
2
4
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
5
|
import type { GatewayConfig } from '../shared/collect-gateways';
|
|
4
6
|
import type { Config } from '../shared/config';
|
|
5
7
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
6
8
|
import type {
|
|
7
|
-
AdapterEvent,
|
|
8
9
|
AgentAdapter,
|
|
9
|
-
AgentID,
|
|
10
10
|
AgentProfile,
|
|
11
11
|
HeadlessRunner,
|
|
12
12
|
NameUpdate,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import type {
|
|
4
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
5
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
5
6
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
7
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
7
8
|
import type { Config } from '../shared/config';
|
|
@@ -9,8 +10,8 @@ import { isRecord } from '../shared/report';
|
|
|
9
10
|
import type { SessionID } from '../shared/session-id';
|
|
10
11
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
11
12
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
13
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
12
14
|
import type {
|
|
13
|
-
AdapterEvent,
|
|
14
15
|
AgentAdapter,
|
|
15
16
|
AgentProfile,
|
|
16
17
|
NameUpdate,
|
|
@@ -22,7 +23,6 @@ import type {
|
|
|
22
23
|
import { normalizeHookEventName } from './normalize-hook-event';
|
|
23
24
|
import { planPastedLineInput } from './plan-pasted-line-input';
|
|
24
25
|
import { resolveAgentHome } from './resolve-agent-home';
|
|
25
|
-
import { truncateDetail } from './truncate-detail';
|
|
26
26
|
|
|
27
27
|
interface GrokSessionHookState {
|
|
28
28
|
latestPromptID?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { truncateSummary } from '../daemon/truncate-summary';
|
|
2
1
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
3
2
|
import { isRecord } from '../shared/report';
|
|
3
|
+
import { truncateSummary } from '../shared/truncate-summary';
|
|
4
4
|
import { buildClaudeQueryOptions } from './build-claude-query-options';
|
|
5
5
|
import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
|
|
6
6
|
|
|
@@ -2,17 +2,17 @@ import { spawn as spawnChild } from 'node:child_process';
|
|
|
2
2
|
import type { ChildProcess } from 'node:child_process';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { toAgentID } from '../agents/agent-adapter';
|
|
6
|
-
import type { AgentID } from '../agents/agent-adapter';
|
|
7
5
|
import { DaemonError } from '../protocol/daemon-error';
|
|
8
6
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
9
7
|
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
10
8
|
import { PROTOCOL_V } from '../protocol/protocol';
|
|
9
|
+
import type { AgentID } from '../shared/agent-id';
|
|
11
10
|
import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
|
|
12
11
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
13
12
|
import { getBuild } from '../shared/get-build';
|
|
14
13
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
15
14
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
15
|
+
import { toAgentID } from '../shared/to-agent-id';
|
|
16
16
|
import { DaemonClient } from './daemon-client';
|
|
17
17
|
import { formatProtocolMismatch } from './format-protocol-mismatch';
|
|
18
18
|
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DaemonChannel } from '../protocol/daemon-channel';
|
|
1
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
3
|
import { LineDecoder } from '../protocol/line-decoder';
|
|
3
4
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
@@ -14,7 +15,7 @@ interface Pending {
|
|
|
14
15
|
* event callback. `open` connects the socket; `sendHello` completes the
|
|
15
16
|
* handshake and must come first.
|
|
16
17
|
*/
|
|
17
|
-
export class DaemonClient {
|
|
18
|
+
export class DaemonClient implements DaemonChannel {
|
|
18
19
|
onEvent: (event: EventMsg) => void = () => {};
|
|
19
20
|
|
|
20
21
|
onClose: () => void = () => {};
|
package/src/client/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { match } from 'ts-pattern';
|
|
2
2
|
import { createActor } from 'xstate';
|
|
3
|
-
import
|
|
4
|
-
import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
|
|
3
|
+
import { countSessionStates } from '../protocol/count-session-states';
|
|
5
4
|
import { DaemonError } from '../protocol/daemon-error';
|
|
6
5
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
|
+
import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
|
|
7
|
+
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
8
|
+
import type { AgentID } from '../shared/agent-id';
|
|
7
9
|
import { loadConfig } from '../shared/config';
|
|
8
10
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
9
11
|
import { bootDaemonClient } from './boot-daemon';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { SessionState } from '../
|
|
2
|
-
import { sortSessionViews } from '../
|
|
1
|
+
import type { SessionState } from '../protocol/session-state';
|
|
2
|
+
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
3
3
|
|
|
4
4
|
interface TabCandidate {
|
|
5
5
|
readonly id: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import type { AgentID } from '../agents/agent-adapter';
|
|
3
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { AgentID } from '../shared/agent-id';
|
|
4
4
|
import { loadConfig } from '../shared/config';
|
|
5
5
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
6
6
|
import type { AgentPick } from './collect-agent-picks';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
import type { AgentID } from '../
|
|
4
|
-
import
|
|
2
|
+
import type { SessionState } from '../protocol/session-state';
|
|
3
|
+
import type { AgentID } from '../shared/agent-id';
|
|
4
|
+
import { toAgentID } from '../shared/to-agent-id';
|
|
5
5
|
|
|
6
6
|
export interface MirrorSession {
|
|
7
7
|
id: string;
|
package/src/client/ui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
1
|
+
import { PINNED_GROUP_KEY } from '../protocol/pinned-group-key';
|
|
2
|
+
import type { SessionState } from '../protocol/session-state';
|
|
3
|
+
import type { AgentID } from '../shared/agent-id';
|
|
4
4
|
import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
|
|
5
5
|
import { formatDir } from './dirs';
|
|
6
6
|
import { formatOverlayAgentMark } from './format-overlay-agent-mark';
|
|
@@ -18,18 +18,24 @@ export interface FleetEvent {
|
|
|
18
18
|
readonly label?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Builds the events from the sessions that may name them. Rows written
|
|
23
|
+
* before atc session ids stayed stable across restores carry an earlier atc
|
|
24
|
+
* id, so a session of `aliases` that holds a row's agent session id names
|
|
25
|
+
* it when no session holds the atc id; every session that may name a row
|
|
26
|
+
* may alias it unless the caller gives fewer.
|
|
27
|
+
*/
|
|
21
28
|
export function buildFleetEvents(
|
|
22
29
|
rows: readonly StoredEvent[],
|
|
23
30
|
sessions: readonly SessionDescriptor[],
|
|
31
|
+
aliases: readonly SessionDescriptor[] = sessions,
|
|
24
32
|
): FleetEvent[] {
|
|
25
33
|
return rows.map((row) => {
|
|
26
|
-
// Rows written before atc session ids stayed stable across restores
|
|
27
|
-
// carry an earlier atc id, so the agent session id links them.
|
|
28
34
|
const live =
|
|
35
|
+
sessions.find((s) => s.id === row.atcID) ??
|
|
29
36
|
(row.agentSessionID === null
|
|
30
37
|
? undefined
|
|
31
|
-
:
|
|
32
|
-
sessions.find((s) => s.id === row.atcID);
|
|
38
|
+
: aliases.find((s) => s.agentSessionID === row.agentSessionID));
|
|
33
39
|
|
|
34
40
|
return {
|
|
35
41
|
cursor: encodeCursor({ kind: 'events', id: row.id }),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FleetEntry } from '../store/fleet-entry';
|
|
2
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
3
|
+
import type { TargetGrant } from './target-access';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The target a fleet row's session is bound to. A row that stores no
|
|
7
|
+
* target ran on the implicit `local` target, under its identity.
|
|
8
|
+
*/
|
|
9
|
+
export function buildGrantFromFleetEntry(entry: FleetEntry): TargetGrant {
|
|
10
|
+
return {
|
|
11
|
+
target: entry.target ?? 'local',
|
|
12
|
+
targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { truncateDetail } from '../agents/truncate-detail';
|
|
2
1
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
4
4
|
import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
5
5
|
import type { TrailEntry } from '../store/trail-entry';
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { truncateDetail } from '../agents/truncate-detail';
|
|
2
1
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
4
4
|
import type { TrailEntry } from '../store/trail-entry';
|
|
5
5
|
import type { NoteReport } from './parse-report';
|
|
6
6
|
|
|
@@ -17,17 +17,23 @@ export interface ReportView {
|
|
|
17
17
|
readonly complete: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Builds the view from the sessions that may name it. A row written before
|
|
22
|
+
* atc session ids stayed stable across restores carries an earlier atc id,
|
|
23
|
+
* so a session of `aliases` that holds its agent session id names it when
|
|
24
|
+
* no session holds the atc id. The alias only names the report: whoever
|
|
25
|
+
* checks who may read it checks the row's own atc id.
|
|
26
|
+
*/
|
|
20
27
|
export function buildReportView(
|
|
21
28
|
stored: StoredReport,
|
|
22
29
|
sessions: readonly SessionDescriptor[],
|
|
30
|
+
aliases: readonly SessionDescriptor[],
|
|
23
31
|
): ReportView {
|
|
24
|
-
// A row written before atc session ids stayed stable across restores
|
|
25
|
-
// carries an earlier atc id, so the agent session id links it.
|
|
26
32
|
const live =
|
|
33
|
+
sessions.find((s) => s.id === stored.atcID) ??
|
|
27
34
|
(stored.agentSessionID === null
|
|
28
35
|
? undefined
|
|
29
|
-
:
|
|
30
|
-
sessions.find((s) => s.id === stored.atcID);
|
|
36
|
+
: aliases.find((s) => s.agentSessionID === stored.agentSessionID));
|
|
31
37
|
|
|
32
38
|
return {
|
|
33
39
|
report: encodeCursor({ kind: 'events', id: stored.id }),
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
3
|
import { toSessionID } from '../shared/to-session-id';
|
|
4
|
+
import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
|
|
4
5
|
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
5
|
-
import {
|
|
6
|
-
import type { DaemonContext } from './daemon-connection';
|
|
6
|
+
import type { DaemonContext } from './daemon-context';
|
|
7
7
|
import type { KeyedRequest } from './idempotency-ledger';
|
|
8
8
|
import type { TargetAccess, TargetGrant } from './target-access';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The daemon as a principal with the given access sees it. Its keyed
|
|
12
12
|
* spawns and messages hold their keys in the given namespace, apart from
|
|
13
|
-
* every other namespace's. A session
|
|
14
|
-
*
|
|
15
|
-
* session
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* every other namespace's. A session exists here only when the access
|
|
14
|
+
* reaches every session in its tree: its top-level session and each
|
|
15
|
+
* sub-session of that one. Every lookup of any other session answers as a
|
|
16
|
+
* lookup of an unknown session does, so the request's own handling refuses
|
|
17
|
+
* it with the words and data it gives a session that never existed. Lists
|
|
18
|
+
* leave such sessions out, the directory list leaves out directories
|
|
19
|
+
* spawned only on targets outside the access, and a spawn may use only a
|
|
20
|
+
* target the access holds, a spawn's replayed answer included. A kill, a
|
|
21
|
+
* forget, or a change checks the tree in the same synchronous step that
|
|
22
|
+
* starts it, so a sub-session added after the check is never part of it.
|
|
21
23
|
*/
|
|
22
24
|
export function buildScopedContext(
|
|
23
25
|
ctx: DaemonContext,
|
|
@@ -28,11 +30,11 @@ export function buildScopedContext(
|
|
|
28
30
|
const buildPrincipalKey = (keyed: KeyedRequest | null): KeyedRequest | null =>
|
|
29
31
|
keyed === null ? null : { ...keyed, principal: keyNamespace };
|
|
30
32
|
|
|
31
|
-
const canSee = (id: SessionID): boolean =>
|
|
32
|
-
const grant = ctx.findSessionGrant(id);
|
|
33
|
+
const canSee = (id: SessionID): boolean => ctx.canSeeSession(id, access);
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// A request's own access narrowed to this one.
|
|
36
|
+
const mergeAccess = (outer: TargetAccess | null): TargetAccess =>
|
|
37
|
+
outer === null ? access : outer.merge(access);
|
|
36
38
|
|
|
37
39
|
const canUseTarget = (target: string): boolean => {
|
|
38
40
|
const targetIdentity = ctx.findTargetIdentity(target);
|
|
@@ -57,10 +59,7 @@ export function buildScopedContext(
|
|
|
57
59
|
return null;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
return
|
|
61
|
-
target: entry.target ?? 'local',
|
|
62
|
-
targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
|
|
63
|
-
};
|
|
62
|
+
return buildGrantFromFleetEntry(entry);
|
|
64
63
|
};
|
|
65
64
|
|
|
66
65
|
// Throws the refusal a fresh spawn to the session's target gets when a
|
|
@@ -73,24 +72,9 @@ export function buildScopedContext(
|
|
|
73
72
|
}
|
|
74
73
|
};
|
|
75
74
|
|
|
76
|
-
// Throws the refusal of a change to a session whose sub-sessions the
|
|
77
|
-
// change would reach too when any of them is out of reach. The refusal
|
|
78
|
-
// holds only the session the caller sent, never a sub-session's id or
|
|
79
|
-
// how many there are.
|
|
80
|
-
const requireTreeInReach = (id: SessionID, action: string): void => {
|
|
81
|
-
if (ctx.collectChildIDs(id).every((child) => canSee(child))) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
throw new DaemonError(
|
|
86
|
-
'target_forbidden',
|
|
87
|
-
`this client may not ${action} session '${id}': it has a sub-session on a target this client may not use`,
|
|
88
|
-
{ session: id },
|
|
89
|
-
);
|
|
90
|
-
};
|
|
91
|
-
|
|
92
75
|
return {
|
|
93
76
|
...ctx,
|
|
77
|
+
isSessionVisible: canSee,
|
|
94
78
|
collectSessions: () => ctx.collectSessions().filter((session) => canSee(session.id)),
|
|
95
79
|
collectAgents: () => {
|
|
96
80
|
const list = ctx.collectAgents();
|
|
@@ -115,11 +99,12 @@ export function buildScopedContext(
|
|
|
115
99
|
}),
|
|
116
100
|
};
|
|
117
101
|
},
|
|
118
|
-
|
|
119
|
-
const
|
|
102
|
+
collectSpawnDirs: (outer) => {
|
|
103
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
120
104
|
|
|
121
|
-
return
|
|
105
|
+
return ctx.collectSpawnDirs(merged);
|
|
122
106
|
},
|
|
107
|
+
resolveSpawnParent: (id) => (canSee(id) ? ctx.resolveSpawnParent(id) : 'missing'),
|
|
123
108
|
resolveSpawnTarget: (requested) => {
|
|
124
109
|
if (requested !== undefined) {
|
|
125
110
|
requireTarget(canUseTarget, requested);
|
|
@@ -132,12 +117,10 @@ export function buildScopedContext(
|
|
|
132
117
|
return target;
|
|
133
118
|
},
|
|
134
119
|
spawnSession: async (plan, keyed) => {
|
|
135
|
-
let answer: Readonly<Record<string, unknown>>;
|
|
136
|
-
|
|
137
120
|
// A held key's replay is checked against the target its session was
|
|
138
121
|
// bound to, inside the spawn, before its answer leaves the daemon.
|
|
139
122
|
try {
|
|
140
|
-
|
|
123
|
+
return await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
|
|
141
124
|
} catch (error) {
|
|
142
125
|
// A held key's refusal may carry the session its spawn made.
|
|
143
126
|
if (error instanceof DaemonError && typeof error.data?.['effectRef'] === 'string') {
|
|
@@ -146,29 +129,17 @@ export function buildScopedContext(
|
|
|
146
129
|
|
|
147
130
|
throw error;
|
|
148
131
|
}
|
|
149
|
-
|
|
150
|
-
return answer;
|
|
151
132
|
},
|
|
152
|
-
killSession: (id) => {
|
|
153
|
-
if (!canSee(id)) {
|
|
154
|
-
return Promise.resolve(false);
|
|
155
|
-
}
|
|
156
133
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
},
|
|
161
|
-
updateSession: (id, name, pinned) => {
|
|
162
|
-
if (!canSee(id)) {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
134
|
+
// The daemon's kill takes the session's sub-sessions before its first
|
|
135
|
+
// await, so the set it kills is the set this check saw.
|
|
136
|
+
killSession: (id) => (canSee(id) ? ctx.killSession(id) : Promise.resolve(false)),
|
|
165
137
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
},
|
|
138
|
+
// A session out of reach answers before any confirm token is handed
|
|
139
|
+
// out or taken, so its host is never touched.
|
|
140
|
+
forgetSession: (id, confirmToken) =>
|
|
141
|
+
canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
|
|
142
|
+
updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
|
|
172
143
|
ackSession: (id) => canSee(id) && ctx.ackSession(id),
|
|
173
144
|
buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
|
|
174
145
|
readSessionScreen: (id) =>
|
|
@@ -190,12 +161,16 @@ export function buildScopedContext(
|
|
|
190
161
|
writeSessionLine: (sessionID, text) =>
|
|
191
162
|
canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
|
|
192
163
|
ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
|
|
193
|
-
adoptSession: (id, cols, rows) =>
|
|
194
|
-
canSee(id)
|
|
164
|
+
adoptSession: (id, cols, rows, outer) =>
|
|
165
|
+
canSee(id)
|
|
166
|
+
? ctx.adoptSession(id, cols, rows, mergeAccess(outer))
|
|
167
|
+
: Promise.resolve('missing' as const),
|
|
195
168
|
resizeSession: (client, sessionID, dims) =>
|
|
196
169
|
canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
|
|
197
|
-
readSessionRecord: (id) =>
|
|
198
|
-
canSee(id)
|
|
170
|
+
readSessionRecord: (id, outer) =>
|
|
171
|
+
canSee(id)
|
|
172
|
+
? ctx.readSessionRecord(id, mergeAccess(outer))
|
|
173
|
+
: Promise.resolve('missing' as const),
|
|
199
174
|
loadSessionTranscript: (id, from, limit) =>
|
|
200
175
|
canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
|
|
201
176
|
readEvents: (afterID, limit, waitMs, sessionID, outer) => {
|
|
@@ -203,31 +178,43 @@ export function buildScopedContext(
|
|
|
203
178
|
|
|
204
179
|
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
205
180
|
},
|
|
206
|
-
readReport: (id, outer) =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return ctx.readReport(id, merged);
|
|
210
|
-
},
|
|
211
|
-
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
181
|
+
readReport: (id, outer) => ctx.readReport(id, mergeAccess(outer)),
|
|
182
|
+
writeSessionMessage: (sessionID, from, text, keyed, outer) =>
|
|
212
183
|
canSee(sessionID)
|
|
213
|
-
? ctx.writeSessionMessage(
|
|
184
|
+
? ctx.writeSessionMessage(
|
|
185
|
+
sessionID,
|
|
186
|
+
from,
|
|
187
|
+
text,
|
|
188
|
+
buildPrincipalKey(keyed),
|
|
189
|
+
mergeAccess(outer),
|
|
190
|
+
)
|
|
214
191
|
: Promise.resolve('missing' as const),
|
|
215
192
|
readMessage: async (messageID, waitMs) => {
|
|
216
193
|
// The owner is checked before any wait, so a message outside the
|
|
217
194
|
// access answers at once, as an unknown message does.
|
|
218
195
|
const view = await ctx.readMessage(messageID, 0);
|
|
219
196
|
|
|
220
|
-
|
|
197
|
+
// Only the session the message was sent to owns it here, never one
|
|
198
|
+
// that shares its agent session id.
|
|
199
|
+
if (view === null || view.session !== view.record.atcID || !canSee(view.session)) {
|
|
221
200
|
return null;
|
|
222
201
|
}
|
|
223
202
|
|
|
224
|
-
|
|
203
|
+
if (waitMs === 0) {
|
|
204
|
+
return view;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The message may move to another session that holds its agent
|
|
208
|
+
// session id while the read waits.
|
|
209
|
+
const waited = await ctx.readMessage(messageID, waitMs);
|
|
210
|
+
|
|
211
|
+
return waited !== null && waited.session === waited.record.atcID ? waited : null;
|
|
225
212
|
},
|
|
226
|
-
attachTap: (client, sessionID) =>
|
|
227
|
-
canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
|
|
228
|
-
ackMessage: (client, sessionID, messageID) =>
|
|
213
|
+
attachTap: (client, sessionID, outer) =>
|
|
214
|
+
canSee(sessionID) ? ctx.attachTap(client, sessionID, mergeAccess(outer)) : 'missing',
|
|
215
|
+
ackMessage: (client, sessionID, messageID, outer) =>
|
|
229
216
|
canSee(sessionID)
|
|
230
|
-
? ctx.ackMessage(client, sessionID, messageID)
|
|
217
|
+
? ctx.ackMessage(client, sessionID, messageID, mergeAccess(outer))
|
|
231
218
|
: Promise.resolve('unknown' as const),
|
|
232
219
|
};
|
|
233
220
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { truncateDetail } from '../agents/truncate-detail';
|
|
2
1
|
import { PROTOCOL_V } from '../protocol/protocol';
|
|
3
2
|
import type { EventMsg } from '../protocol/protocol';
|
|
4
3
|
import type { SessionID } from '../shared/session-id';
|
|
4
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
5
5
|
import type { MessageRecord } from '../store/message-record';
|
|
6
6
|
|
|
7
7
|
/**
|