@zgeoff/atc 2.15.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +15 -4
- package/src/daemon/broker-authority-error.ts +41 -0
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +63 -76
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +258 -90
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/protocol/hook-event.ts +6 -2
- package/src/statusline.ts +8 -6
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@better-auth/oauth-provider": "1.7.6",
|
|
44
44
|
"@xterm/addon-serialize": "0.14.0",
|
|
45
45
|
"@xterm/headless": "6.0.0",
|
|
46
|
-
"@zgeoff/imp-client": "0.
|
|
46
|
+
"@zgeoff/imp-client": "0.27.0",
|
|
47
47
|
"better-auth": "1.7.6",
|
|
48
48
|
"bun-pty": "0.4.10",
|
|
49
49
|
"citty": "0.2.2",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { AgentID } from '../shared/agent-id';
|
|
3
|
+
import { toShellArg } from '../shared/to-shell-arg';
|
|
3
4
|
import { buildCLICommand } from './build-cli-command';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -20,7 +21,9 @@ export interface HookSettingsProfile {
|
|
|
20
21
|
* The settings object injected into wrangled sessions via
|
|
21
22
|
* `claude --settings`. The user's own settings are untouched; these hooks
|
|
22
23
|
* only exist in sessions atc spawns, and identify themselves via
|
|
23
|
-
* ATC_SESSION_ID in the env.
|
|
24
|
+
* ATC_SESSION_ID in the env and the agent id on their command line. A
|
|
25
|
+
* nested harness inherits the env but not the command line, so the agent id
|
|
26
|
+
* keeps its reports from passing as the session's own.
|
|
24
27
|
*
|
|
25
28
|
* A settings-file env block outranks a shell export of the same variable, so
|
|
26
29
|
* a session's backend is decided here rather than by whatever the terminal
|
|
@@ -37,7 +40,13 @@ export function buildHookSettings(
|
|
|
37
40
|
): Record<string, unknown> {
|
|
38
41
|
const entry = [
|
|
39
42
|
{
|
|
40
|
-
hooks: [
|
|
43
|
+
hooks: [
|
|
44
|
+
{
|
|
45
|
+
type: 'command',
|
|
46
|
+
command: buildCLICommand(`hook-report --agent ${toShellArg(profile.id)}`, cliArgv),
|
|
47
|
+
timeout: 5,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
41
50
|
},
|
|
42
51
|
];
|
|
43
52
|
|
|
@@ -61,7 +70,7 @@ export function buildHookSettings(
|
|
|
61
70
|
// padding.
|
|
62
71
|
statusLine: {
|
|
63
72
|
type: 'command',
|
|
64
|
-
command: buildCLICommand(
|
|
73
|
+
command: buildCLICommand(`statusline --agent ${toShellArg(profile.id)}`, cliArgv),
|
|
65
74
|
padding: statuslinePadding,
|
|
66
75
|
},
|
|
67
76
|
...(profile.env === undefined || Object.keys(profile.env).length === 0
|
|
@@ -17,7 +17,7 @@ export function printCodexHookFile(): void {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function buildCodexHookFile(): string {
|
|
20
|
-
const cmd = buildCLICommand('hook-report');
|
|
20
|
+
const cmd = buildCLICommand('hook-report --agent codex');
|
|
21
21
|
const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
|
|
22
22
|
const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
|
|
23
23
|
|
|
@@ -19,7 +19,7 @@ export function printGrokHookFile(): void {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function buildGrokHookFile(): string {
|
|
22
|
-
const cmd = buildCLICommand('hook-report');
|
|
22
|
+
const cmd = buildCLICommand('hook-report --agent grok');
|
|
23
23
|
const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
|
|
24
24
|
const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
|
|
25
25
|
|
package/src/cli.ts
CHANGED
|
@@ -291,10 +291,16 @@ const main = defineCommand({
|
|
|
291
291
|
description: 'Forward a hook event from a wrangled session to the atc socket',
|
|
292
292
|
hidden: true,
|
|
293
293
|
},
|
|
294
|
-
|
|
294
|
+
|
|
295
|
+
// No arg is required: a citty usage error exits nonzero, and
|
|
296
|
+
// reporters must always exit 0.
|
|
297
|
+
args: {
|
|
298
|
+
agent: { type: 'string', default: '' },
|
|
299
|
+
},
|
|
300
|
+
async run(ctx) {
|
|
295
301
|
const reporter = await import('./hook-report');
|
|
296
302
|
|
|
297
|
-
await reporter.runHookReport();
|
|
303
|
+
await reporter.runHookReport(ctx.args.agent);
|
|
298
304
|
},
|
|
299
305
|
}),
|
|
300
306
|
tap: () =>
|
|
@@ -352,10 +358,15 @@ const main = defineCommand({
|
|
|
352
358
|
description: 'Render the chained statusline for a wrangled session',
|
|
353
359
|
hidden: true,
|
|
354
360
|
},
|
|
355
|
-
|
|
361
|
+
|
|
362
|
+
// A statusline command must always exit 0 too, so no arg is required.
|
|
363
|
+
args: {
|
|
364
|
+
agent: { type: 'string', default: '' },
|
|
365
|
+
},
|
|
366
|
+
async run(ctx) {
|
|
356
367
|
const statusline = await import('./statusline');
|
|
357
368
|
|
|
358
|
-
await statusline.runStatusline();
|
|
369
|
+
await statusline.runStatusline(ctx.args.agent);
|
|
359
370
|
},
|
|
360
371
|
}),
|
|
361
372
|
},
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why impd's credential broker may not be used for a session, or why atc
|
|
3
|
+
* may not clean up after one:
|
|
4
|
+
*
|
|
5
|
+
* - `auth_impd_too_old`: impd lacks grantable tokens or secret rebinds.
|
|
6
|
+
* - `auth_token_scope`: the token's scope is below `manage`.
|
|
7
|
+
* - `auth_token_too_broad`: the token can reach imps outside atc's
|
|
8
|
+
* namespace, through no imp patterns or a pattern whose literal text
|
|
9
|
+
* before its first `*` does not start with the namespace prefix.
|
|
10
|
+
* - `auth_imp_out_of_scope`: an imp the call touches is outside the
|
|
11
|
+
* token's patterns.
|
|
12
|
+
* - `auth_secret_not_grantable`: a bound secret is not on the token's
|
|
13
|
+
* grantable list.
|
|
14
|
+
* - `auth_runtime_mismatch`: the imp under the recorded name has another
|
|
15
|
+
* id than the one recorded.
|
|
16
|
+
*/
|
|
17
|
+
export type BrokerAuthorityCode =
|
|
18
|
+
| 'auth_impd_too_old'
|
|
19
|
+
| 'auth_token_scope'
|
|
20
|
+
| 'auth_token_too_broad'
|
|
21
|
+
| 'auth_imp_out_of_scope'
|
|
22
|
+
| 'auth_secret_not_grantable'
|
|
23
|
+
| 'auth_runtime_mismatch';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A refusal to provision through impd's credential broker or to clean up
|
|
27
|
+
* after it, carrying its code and the detail the code defines.
|
|
28
|
+
*/
|
|
29
|
+
export class BrokerAuthorityError extends Error {
|
|
30
|
+
readonly code: BrokerAuthorityCode;
|
|
31
|
+
|
|
32
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
33
|
+
|
|
34
|
+
constructor(code: BrokerAuthorityCode, message: string, data: Readonly<Record<string, unknown>>) {
|
|
35
|
+
super(message);
|
|
36
|
+
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.data = data;
|
|
39
|
+
this.name = 'BrokerAuthorityError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,8 +1,8 @@
|
|
|
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 { buildTargetIdentity } from './build-target-identity';
|
|
6
6
|
import type { DaemonContext } from './daemon-context';
|
|
7
7
|
import type { KeyedRequest } from './idempotency-ledger';
|
|
8
8
|
import type { TargetAccess, TargetGrant } from './target-access';
|
|
@@ -10,14 +10,16 @@ import type { TargetAccess, TargetGrant } from './target-access';
|
|
|
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
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ImpSecret, ImpSecretRule } from './imp-port';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One secret as a resolved binding expects impd to hold it: its kind and
|
|
5
|
+
* the complete set of rules, one per host.
|
|
6
|
+
*/
|
|
7
|
+
export interface ExpectedSecret {
|
|
8
|
+
readonly secret: string;
|
|
9
|
+
readonly kind: ImpSecret['kind'];
|
|
10
|
+
readonly rules: readonly ImpSecretRule[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SecretRuleMismatch =
|
|
14
|
+
| { readonly secret: string; readonly reason: 'missing' }
|
|
15
|
+
| {
|
|
16
|
+
readonly secret: string;
|
|
17
|
+
readonly reason: 'kind';
|
|
18
|
+
readonly expected: ImpSecret['kind'];
|
|
19
|
+
readonly actual: ImpSecret['kind'];
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
readonly secret: string;
|
|
23
|
+
readonly reason: 'rules';
|
|
24
|
+
readonly expected: readonly ImpSecretRule[];
|
|
25
|
+
readonly actual: readonly ImpSecretRule[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compares the secrets a resolved binding expects with the secrets impd
|
|
30
|
+
* lists, secret by secret. Each expected secret must exist with the same
|
|
31
|
+
* kind and exactly the same set of rules, in any order: a rule impd holds
|
|
32
|
+
* that the binding lacks is a mismatch as much as a missing one, since it
|
|
33
|
+
* sends the credential to a host the binding never approved. An empty
|
|
34
|
+
* result means every expected secret matches; secrets the binding does not
|
|
35
|
+
* expect are ignored.
|
|
36
|
+
*/
|
|
37
|
+
export function collectSecretRuleMismatches(
|
|
38
|
+
expected: readonly ExpectedSecret[],
|
|
39
|
+
secrets: readonly ImpSecret[],
|
|
40
|
+
): SecretRuleMismatch[] {
|
|
41
|
+
const held = new Map(secrets.map((secret) => [secret.name, secret]));
|
|
42
|
+
|
|
43
|
+
const mismatches: SecretRuleMismatch[] = [];
|
|
44
|
+
|
|
45
|
+
for (const want of expected) {
|
|
46
|
+
const actual = held.get(want.secret);
|
|
47
|
+
|
|
48
|
+
if (actual === undefined) {
|
|
49
|
+
mismatches.push({ secret: want.secret, reason: 'missing' });
|
|
50
|
+
} else if (actual.kind !== want.kind) {
|
|
51
|
+
mismatches.push({
|
|
52
|
+
secret: want.secret,
|
|
53
|
+
reason: 'kind',
|
|
54
|
+
expected: want.kind,
|
|
55
|
+
actual: actual.kind,
|
|
56
|
+
});
|
|
57
|
+
} else if (!hasSameRules(want.rules, actual.rules)) {
|
|
58
|
+
mismatches.push({
|
|
59
|
+
secret: want.secret,
|
|
60
|
+
reason: 'rules',
|
|
61
|
+
expected: want.rules,
|
|
62
|
+
actual: actual.rules,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return mismatches;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasSameRules(left: readonly ImpSecretRule[], right: readonly ImpSecretRule[]): boolean {
|
|
71
|
+
const leftKeys = left.map((rule) => toRuleKey(rule)).toSorted();
|
|
72
|
+
const rightKeys = right.map((rule) => toRuleKey(rule)).toSorted();
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A rule as one comparable string; JSON keeps a missing user apart from
|
|
80
|
+
// any user a basic rule can hold.
|
|
81
|
+
function toRuleKey(rule: ImpSecretRule): string {
|
|
82
|
+
return JSON.stringify([rule.host, rule.header, rule.scheme, rule.user ?? null]);
|
|
83
|
+
}
|