crewly 1.20.407 → 1.20.409
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/dist/backend/backend/src/constants.d.ts +34 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +34 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/activity/activity.controller.d.ts +29 -0
- package/dist/backend/backend/src/controllers/activity/activity.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/activity/activity.controller.js +77 -0
- package/dist/backend/backend/src/controllers/activity/activity.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.controller.js +3 -0
- package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-charter.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-charter.controller.js +7 -0
- package/dist/backend/backend/src/controllers/team/team-charter.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js +5 -1
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-pause.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-pause.controller.js +9 -0
- package/dist/backend/backend/src/controllers/team/team-pause.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.controller.js +49 -0
- package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team-move/team-move.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team-move/team-move.controller.js +2 -0
- package/dist/backend/backend/src/controllers/team-move/team-move.controller.js.map +1 -1
- package/dist/backend/backend/src/routes/modules/system.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/modules/system.routes.js +3 -0
- package/dist/backend/backend/src/routes/modules/system.routes.js.map +1 -1
- package/dist/backend/backend/src/services/activity/activity-log.service.d.ts +192 -0
- package/dist/backend/backend/src/services/activity/activity-log.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/activity/activity-log.service.js +349 -0
- package/dist/backend/backend/src/services/activity/activity-log.service.js.map +1 -0
- package/dist/backend/backend/src/services/browser/browser-approval.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/browser/browser-approval.service.js +7 -0
- package/dist/backend/backend/src/services/browser/browser-approval.service.js.map +1 -1
- package/dist/backend/backend/src/services/budget/team-budget-gate.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/budget/team-budget-gate.service.js +9 -0
- package/dist/backend/backend/src/services/budget/team-budget-gate.service.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +13 -0
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +39 -0
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.js +2 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/google/gmail-send-approval.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/gmail-send-approval.service.js +26 -0
- package/dist/backend/backend/src/services/google/gmail-send-approval.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +34 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +34 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/middleware/agent-origin-correction.d.ts +31 -0
- package/dist/cli/backend/src/middleware/agent-origin-correction.d.ts.map +1 -0
- package/dist/cli/backend/src/middleware/agent-origin-correction.js +31 -0
- package/dist/cli/backend/src/middleware/agent-origin-correction.js.map +1 -0
- package/dist/cli/backend/src/middleware/api-token.middleware.d.ts +171 -0
- package/dist/cli/backend/src/middleware/api-token.middleware.d.ts.map +1 -0
- package/dist/cli/backend/src/middleware/api-token.middleware.js +329 -0
- package/dist/cli/backend/src/middleware/api-token.middleware.js.map +1 -0
- package/dist/cli/backend/src/middleware/caller-identity.middleware.d.ts +290 -0
- package/dist/cli/backend/src/middleware/caller-identity.middleware.d.ts.map +1 -0
- package/dist/cli/backend/src/middleware/caller-identity.middleware.js +658 -0
- package/dist/cli/backend/src/middleware/caller-identity.middleware.js.map +1 -0
- package/dist/cli/backend/src/middleware/require-auth.middleware.d.ts +60 -0
- package/dist/cli/backend/src/middleware/require-auth.middleware.d.ts.map +1 -0
- package/dist/cli/backend/src/middleware/require-auth.middleware.js +127 -0
- package/dist/cli/backend/src/middleware/require-auth.middleware.js.map +1 -0
- package/dist/cli/backend/src/services/activity/activity-log.service.d.ts +192 -0
- package/dist/cli/backend/src/services/activity/activity-log.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/activity/activity-log.service.js +349 -0
- package/dist/cli/backend/src/services/activity/activity-log.service.js.map +1 -0
- package/dist/cli/backend/src/services/budget/team-budget-gate.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/budget/team-budget-gate.service.js +9 -0
- package/dist/cli/backend/src/services/budget/team-budget-gate.service.js.map +1 -1
- package/dist/cli/backend/src/services/core/peer-process.service.d.ts +168 -0
- package/dist/cli/backend/src/services/core/peer-process.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/core/peer-process.service.js +352 -0
- package/dist/cli/backend/src/services/core/peer-process.service.js.map +1 -0
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +13 -0
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +39 -0
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller identity (#999, specs/2026-10-03-owner-auth.md).
|
|
3
|
+
*
|
|
4
|
+
* Decides who made an `/api` request from the credentials it carries — never
|
|
5
|
+
* from a header it left out:
|
|
6
|
+
*
|
|
7
|
+
* | kind | credential |
|
|
8
|
+
* |---------------|---------------------------------------------------------------|
|
|
9
|
+
* | `agent` | a valid agent badge, or (migration window) `X-Agent-Session` |
|
|
10
|
+
* | `relay-owner` | the in-memory relay credential (phone / portal over the relay) |
|
|
11
|
+
* | `cloud` | the in-memory cloud credential (Cloud-forwarded Slack) |
|
|
12
|
+
* | `scheduler` | the scheduler credential a scheduled command's child carries |
|
|
13
|
+
* | `owner` | the owner session cookie (+ CSRF on writes) or the API token |
|
|
14
|
+
* | `anonymous` | nothing of the above — never the owner |
|
|
15
|
+
*
|
|
16
|
+
* `scheduler` is neither an owner nor an agent: it may only queue a plain
|
|
17
|
+
* message to an agent (`POST /terminal/:session/write`, see
|
|
18
|
+
* {@link ownerOrVerifiedAgentOrScheduler}); every other gated route answers it 401.
|
|
19
|
+
*
|
|
20
|
+
* Any sign of an agent wins over an owner credential. The owner API token
|
|
21
|
+
* presented from THIS machine is checked against the process tree: a client
|
|
22
|
+
* process that belongs to an agent is that agent, not the owner.
|
|
23
|
+
*
|
|
24
|
+
* The answer is kept per request in a WeakMap (a client cannot set it) and
|
|
25
|
+
* read through {@link getCallerIdentity}, {@link isOwnerCaller} and
|
|
26
|
+
* {@link rejectNonOwner}.
|
|
27
|
+
*
|
|
28
|
+
* @module middleware/caller-identity
|
|
29
|
+
*/
|
|
30
|
+
import { OWNER_AUTH_CONSTANTS, API_SECURITY_CONSTANTS, SCHEDULED_COMMANDS, ORCHESTRATOR_SESSION_NAME } from '../constants.js';
|
|
31
|
+
import { ownerSessionCookieName, verifyAgentBadge, verifyCsrfToken, verifyInternalCredential, verifyOwnerSession, } from '../services/core/owner-auth.service.js';
|
|
32
|
+
import { createHash } from 'crypto';
|
|
33
|
+
import { getApiTokenFingerprint, verifyApiToken } from '../services/core/api-token.service.js';
|
|
34
|
+
import { PeerProcessService, isHostAddress } from '../services/core/peer-process.service.js';
|
|
35
|
+
import { LoggerService } from '../services/core/logger.service.js';
|
|
36
|
+
import { extractPresentedTokenWithSource, parseCookieHeader } from './api-token.middleware.js';
|
|
37
|
+
import { getAgentOriginCorrection } from './agent-origin-correction.js';
|
|
38
|
+
const logger = LoggerService.getInstance().createComponentLogger('CallerIdentity');
|
|
39
|
+
/**
|
|
40
|
+
* Exhaustiveness guard: a new verdict kind must be handled explicitly.
|
|
41
|
+
*
|
|
42
|
+
* @param value - The unhandled value
|
|
43
|
+
* @throws Always
|
|
44
|
+
*/
|
|
45
|
+
function assertNever(value) {
|
|
46
|
+
throw new Error(`Unhandled peer verdict: ${JSON.stringify(value)}`);
|
|
47
|
+
}
|
|
48
|
+
/** Sentinel: the answer depends on which local process sent the request. */
|
|
49
|
+
const NEEDS_PEER = Symbol('needs-peer');
|
|
50
|
+
const identities = new WeakMap();
|
|
51
|
+
/**
|
|
52
|
+
* First value of a header.
|
|
53
|
+
*
|
|
54
|
+
* @param req - Request
|
|
55
|
+
* @param name - Lower-case header name
|
|
56
|
+
* @returns Trimmed value or undefined
|
|
57
|
+
*/
|
|
58
|
+
function header(req, name) {
|
|
59
|
+
const raw = (req.headers ?? {})[name];
|
|
60
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
61
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The `X-Agent-Session` (or legacy alias) a request carries.
|
|
65
|
+
*
|
|
66
|
+
* @param req - Request
|
|
67
|
+
* @returns Session or undefined
|
|
68
|
+
*/
|
|
69
|
+
function rawSessionHeader(req) {
|
|
70
|
+
return header(req, API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER) ?? header(req, API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER_LEGACY);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The sender label of a scheduled command: `scheduler:<name>`. The name comes
|
|
74
|
+
* from a header the child sets itself, so it only labels: it is kept only if it
|
|
75
|
+
* has the shape of a scheduled-command name (no spaces, brackets or newlines
|
|
76
|
+
* that could forge another sender inside the delivered note).
|
|
77
|
+
*
|
|
78
|
+
* @param name - Presented name
|
|
79
|
+
* @returns `scheduler:<name>`, or `scheduler:unknown`
|
|
80
|
+
*/
|
|
81
|
+
function schedulerSender(name) {
|
|
82
|
+
const valid = name !== undefined && SCHEDULED_COMMANDS.NAME_PATTERN.test(name);
|
|
83
|
+
return `${OWNER_AUTH_CONSTANTS.SCHEDULER_SENDER_PREFIX}${valid ? name : 'unknown'}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether the method changes state.
|
|
87
|
+
*
|
|
88
|
+
* @param req - Request
|
|
89
|
+
* @returns True for POST/PUT/PATCH/DELETE (and for an unknown method — fail safe)
|
|
90
|
+
*/
|
|
91
|
+
function isWrite(req) {
|
|
92
|
+
const method = (req.method ?? 'POST').toUpperCase();
|
|
93
|
+
return OWNER_AUTH_CONSTANTS.MUTATING_METHODS.includes(method);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The owner session a request's cookies carry, if any verifies. Every
|
|
97
|
+
* `crewly_owner_*` cookie is tried: the port suffix only keeps instances on
|
|
98
|
+
* one host from overwriting each other, and a cookie from another instance
|
|
99
|
+
* was signed with another secret and simply does not verify.
|
|
100
|
+
*
|
|
101
|
+
* @param req - Request
|
|
102
|
+
* @returns The session, or null
|
|
103
|
+
*/
|
|
104
|
+
export function readOwnerSession(req) {
|
|
105
|
+
const cookies = parseCookieHeader(typeof req.headers?.cookie === 'string' ? req.headers.cookie : undefined);
|
|
106
|
+
const preferred = ownerSessionCookieName(req.socket?.localPort);
|
|
107
|
+
const names = [preferred, ...Object.keys(cookies).filter((n) => n !== preferred && n.startsWith(OWNER_AUTH_CONSTANTS.OWNER_SESSION_COOKIE_PREFIX))];
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
const session = verifyOwnerSession(cookies[name]);
|
|
110
|
+
if (session)
|
|
111
|
+
return session;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Classify a request. Returns {@link NEEDS_PEER} when the only credential is
|
|
117
|
+
* the owner API token from this machine and no peer verdict was given.
|
|
118
|
+
*
|
|
119
|
+
* @param req - Request
|
|
120
|
+
* @param peer - Verdict on the local client process, when known
|
|
121
|
+
* @returns The identity, or NEEDS_PEER
|
|
122
|
+
*/
|
|
123
|
+
function classify(req, peer) {
|
|
124
|
+
const headerSession = rawSessionHeader(req);
|
|
125
|
+
const badgeRaw = header(req, OWNER_AUTH_CONSTANTS.AGENT_BADGE_HEADER);
|
|
126
|
+
const badgeSession = verifyAgentBadge(badgeRaw);
|
|
127
|
+
// 1. A valid badge: always that agent — unless the process tree proved the
|
|
128
|
+
// shell belongs to another agent PTY (a runtime leaked the environment).
|
|
129
|
+
if (badgeSession) {
|
|
130
|
+
const correction = getAgentOriginCorrection(req);
|
|
131
|
+
const session = correction && correction.claimed === badgeSession ? correction.actual : badgeSession;
|
|
132
|
+
return { kind: 'agent', via: 'agent-badge', session, ...(session !== badgeSession ? { note: `process tree overrode badge ${badgeSession}` } : {}) };
|
|
133
|
+
}
|
|
134
|
+
// 2. Any X-Agent-Session: an agent (migration window). Never the owner.
|
|
135
|
+
if (headerSession) {
|
|
136
|
+
return { kind: 'agent', via: 'legacy-header', session: headerSession, ...(badgeRaw ? { note: 'invalid agent badge' } : {}) };
|
|
137
|
+
}
|
|
138
|
+
// An invalid badge with no session header is not anybody we know.
|
|
139
|
+
if (badgeRaw)
|
|
140
|
+
return { kind: 'anonymous', via: 'none', note: 'invalid agent badge' };
|
|
141
|
+
// 3. The in-process forwarders.
|
|
142
|
+
const internal = verifyInternalCredential(header(req, OWNER_AUTH_CONSTANTS.INTERNAL_HEADER));
|
|
143
|
+
if (internal === 'relay')
|
|
144
|
+
return { kind: 'relay-owner', via: 'relay' };
|
|
145
|
+
if (internal === 'cloud')
|
|
146
|
+
return { kind: 'cloud', via: 'cloud' };
|
|
147
|
+
if (internal === 'scheduler')
|
|
148
|
+
return { kind: 'scheduler', via: 'scheduler', sender: schedulerSender(header(req, OWNER_AUTH_CONSTANTS.SCHEDULER_NAME_HEADER)) };
|
|
149
|
+
// 4. The dashboard's session (CSRF on writes).
|
|
150
|
+
let note;
|
|
151
|
+
const ownerSession = readOwnerSession(req);
|
|
152
|
+
if (ownerSession) {
|
|
153
|
+
if (!isWrite(req) || verifyCsrfToken(ownerSession.id, header(req, OWNER_AUTH_CONSTANTS.CSRF_HEADER))) {
|
|
154
|
+
return { kind: 'owner', via: 'owner-session', ownerSessionId: ownerSession.id };
|
|
155
|
+
}
|
|
156
|
+
note = 'owner session without a valid CSRF token on a write';
|
|
157
|
+
}
|
|
158
|
+
// 5. The owner API token.
|
|
159
|
+
const presented = extractPresentedTokenWithSource(req);
|
|
160
|
+
if (presented && verifyApiToken(presented.token)) {
|
|
161
|
+
if (presented.source === 'cookie' && isWrite(req)) {
|
|
162
|
+
return { kind: 'anonymous', via: 'none', note: 'API token only in a cookie on a write' };
|
|
163
|
+
}
|
|
164
|
+
if (!peer) {
|
|
165
|
+
const address = req.socket?.remoteAddress ?? '';
|
|
166
|
+
// An empty address is a socket the sender already closed, not a remote
|
|
167
|
+
// caller: never the owner on that basis (#1010 review).
|
|
168
|
+
if (address && !isHostAddress(address))
|
|
169
|
+
return { kind: 'owner', via: 'api-token' };
|
|
170
|
+
return NEEDS_PEER;
|
|
171
|
+
}
|
|
172
|
+
switch (peer.kind) {
|
|
173
|
+
case 'agent':
|
|
174
|
+
return {
|
|
175
|
+
kind: 'agent',
|
|
176
|
+
via: 'process-tree',
|
|
177
|
+
...(peer.session ? { session: peer.session } : {}),
|
|
178
|
+
note: `agent process (${peer.signal}) presented the owner API token`,
|
|
179
|
+
};
|
|
180
|
+
case 'gone':
|
|
181
|
+
// The lookup ran and the sender was not there (it exited first — an
|
|
182
|
+
// agent can write over a raw socket and quit). Fail closed.
|
|
183
|
+
return { kind: 'anonymous', via: 'none', note: `owner token from a vanished local process: ${peer.reason}` };
|
|
184
|
+
case 'unknown':
|
|
185
|
+
// The lookup could not run (tool missing, timeout): fail open.
|
|
186
|
+
return { kind: 'owner', via: 'api-token', note: `process check skipped: ${peer.reason}` };
|
|
187
|
+
case 'remote':
|
|
188
|
+
case 'self':
|
|
189
|
+
case 'not-agent':
|
|
190
|
+
return { kind: 'owner', via: 'api-token' };
|
|
191
|
+
default:
|
|
192
|
+
return assertNever(peer);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { kind: 'anonymous', via: 'none', ...(note ? { note } : {}) };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Classify without a process lookup. A local API-token caller is anonymous
|
|
199
|
+
* here (it needs {@link callerIdentityMiddleware}'s process check to be the
|
|
200
|
+
* owner); everything else is decided exactly as the middleware would.
|
|
201
|
+
*
|
|
202
|
+
* @param req - Request
|
|
203
|
+
* @returns The identity
|
|
204
|
+
*/
|
|
205
|
+
export function classifyCallerSync(req) {
|
|
206
|
+
const result = classify(req);
|
|
207
|
+
return result === NEEDS_PEER ? { kind: 'anonymous', via: 'none', note: 'local API token needs the process check' } : result;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Classify with the process check for a local API-token caller.
|
|
211
|
+
*
|
|
212
|
+
* @param req - Request
|
|
213
|
+
* @param peers - Process classifier
|
|
214
|
+
* @returns The identity
|
|
215
|
+
*/
|
|
216
|
+
export async function classifyCaller(req, peers) {
|
|
217
|
+
const first = classify(req);
|
|
218
|
+
if (first !== NEEDS_PEER && first.via === 'legacy-header')
|
|
219
|
+
return confirmLegacyAgent(req, first, peers);
|
|
220
|
+
if (first !== NEEDS_PEER)
|
|
221
|
+
return first;
|
|
222
|
+
const verdict = await peers.classify(req.socket ?? null);
|
|
223
|
+
const second = classify(req, verdict);
|
|
224
|
+
return second === NEEDS_PEER ? { kind: 'anonymous', via: 'none' } : second;
|
|
225
|
+
}
|
|
226
|
+
/** Note on a legacy-header call the process tree confirmed. */
|
|
227
|
+
export const LEGACY_CONFIRMED_NOTE = 'session header confirmed by the process tree';
|
|
228
|
+
/**
|
|
229
|
+
* A call with `X-Agent-Session` but no valid badge (none sent, or one minted
|
|
230
|
+
* by an earlier backend process — the secret is per process) is that agent
|
|
231
|
+
* only when the process tree says so (#1024 review): the client process on
|
|
232
|
+
* the other end of THIS connection (lsof / ss, never the self-reported
|
|
233
|
+
* `X-Agent-Pid`) must run under that same session's agent PTY (or be that
|
|
234
|
+
* session's Crewly Agent child). Then it counts as verified (`process-tree`);
|
|
235
|
+
* otherwise it stays `legacy-header`, which the routes that need proof
|
|
236
|
+
* (terminal writes, key reads) refuse with 403.
|
|
237
|
+
*
|
|
238
|
+
* @param req - Request
|
|
239
|
+
* @param legacy - The legacy-header identity
|
|
240
|
+
* @param peers - Process classifier
|
|
241
|
+
* @returns The confirmed identity, or `legacy` unchanged
|
|
242
|
+
*/
|
|
243
|
+
async function confirmLegacyAgent(req, legacy, peers) {
|
|
244
|
+
if (!legacy.session)
|
|
245
|
+
return legacy;
|
|
246
|
+
try {
|
|
247
|
+
const verdict = await peers.classify(req.socket ?? null);
|
|
248
|
+
if (verdict.kind === 'agent' && verdict.session === legacy.session) {
|
|
249
|
+
return { kind: 'agent', via: 'process-tree', session: legacy.session, note: LEGACY_CONFIRMED_NOTE };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
/* no verdict: stays unconfirmed */
|
|
254
|
+
}
|
|
255
|
+
return legacy;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Store a request's identity (the middleware; tests).
|
|
259
|
+
*
|
|
260
|
+
* @param req - Request
|
|
261
|
+
* @param identity - Identity
|
|
262
|
+
*/
|
|
263
|
+
export function setCallerIdentity(req, identity) {
|
|
264
|
+
identities.set(req, identity);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The identity of a request's caller: what the middleware decided, or a
|
|
268
|
+
* synchronous classification for a request it did not see (unit tests that
|
|
269
|
+
* mount a router alone).
|
|
270
|
+
*
|
|
271
|
+
* @param req - Request
|
|
272
|
+
* @returns The identity
|
|
273
|
+
*/
|
|
274
|
+
export function getCallerIdentity(req) {
|
|
275
|
+
return identities.get(req) ?? classifyCallerSync(req);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Whether the caller holds an owner credential: the dashboard session, the
|
|
279
|
+
* owner API token, or the phone / portal relay.
|
|
280
|
+
*
|
|
281
|
+
* @param req - Request
|
|
282
|
+
* @returns True for the owner
|
|
283
|
+
*/
|
|
284
|
+
export function isOwnerCaller(req) {
|
|
285
|
+
const { kind } = getCallerIdentity(req);
|
|
286
|
+
return kind === 'owner' || kind === 'relay-owner';
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The agent session of the caller, when it is an agent.
|
|
290
|
+
*
|
|
291
|
+
* @param req - Request
|
|
292
|
+
* @returns Session or undefined
|
|
293
|
+
*/
|
|
294
|
+
export function callerAgentSession(req) {
|
|
295
|
+
const id = getCallerIdentity(req);
|
|
296
|
+
return id.kind === 'agent' ? id.session : undefined;
|
|
297
|
+
}
|
|
298
|
+
/** The 401 body for a caller with no owner credential. */
|
|
299
|
+
export const OWNER_AUTH_REQUIRED_BODY = Object.freeze({
|
|
300
|
+
success: false,
|
|
301
|
+
error: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_AUTH_REQUIRED,
|
|
302
|
+
code: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_AUTH_REQUIRED,
|
|
303
|
+
message: 'This action needs the owner: use the dashboard on this machine, the phone app, or the API token (`crewly token`).',
|
|
304
|
+
});
|
|
305
|
+
/**
|
|
306
|
+
* Whether a request comes from a browser that holds no valid owner session:
|
|
307
|
+
* a dashboard tab opened before the upgrade (its bundle never asks for a
|
|
308
|
+
* session), or one whose session ended with a backend restart.
|
|
309
|
+
*
|
|
310
|
+
* @param req - Request
|
|
311
|
+
* @returns True for a browser request without a session
|
|
312
|
+
*/
|
|
313
|
+
export function isBrowserWithoutSession(req) {
|
|
314
|
+
const h = req.headers ?? {};
|
|
315
|
+
const browser = Boolean(h['sec-fetch-site'] || h['sec-fetch-mode'] || h.origin || h.referer || /Mozilla\//.test(String(h['user-agent'] ?? '')));
|
|
316
|
+
return browser && !readOwnerSession(req);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The 401 body for a caller with no owner credential. A browser without a
|
|
320
|
+
* session gets the plain instruction in `error` — what an old dashboard tab
|
|
321
|
+
* shows the owner — and every body carries `code: owner_auth_required`,
|
|
322
|
+
* which the new dashboard reacts to by refreshing its session and retrying.
|
|
323
|
+
*
|
|
324
|
+
* @param req - Request (optional)
|
|
325
|
+
* @returns Response body
|
|
326
|
+
*/
|
|
327
|
+
export function ownerAuthRequiredBody(req) {
|
|
328
|
+
if (req && isBrowserWithoutSession(req)) {
|
|
329
|
+
return {
|
|
330
|
+
success: false,
|
|
331
|
+
error: OWNER_AUTH_CONSTANTS.RELOAD_MESSAGE,
|
|
332
|
+
code: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_AUTH_REQUIRED,
|
|
333
|
+
reload: true,
|
|
334
|
+
message: OWNER_AUTH_CONSTANTS.RELOAD_MESSAGE,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return { ...OWNER_AUTH_REQUIRED_BODY };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Thrown by handlers that map errors to responses themselves (decisions,
|
|
341
|
+
* project tickets): carries the owner-auth 401 body.
|
|
342
|
+
*/
|
|
343
|
+
export class OwnerAuthRequiredError extends Error {
|
|
344
|
+
body;
|
|
345
|
+
status = 401;
|
|
346
|
+
/**
|
|
347
|
+
* @param body - The 401 body ({@link ownerAuthRequiredBody})
|
|
348
|
+
*/
|
|
349
|
+
constructor(body) {
|
|
350
|
+
super(String(body.error));
|
|
351
|
+
this.body = body;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Answer 401 owner_auth_required. No `WWW-Authenticate: Crewly-Token`: the
|
|
356
|
+
* dashboard refreshes its session instead of prompting for the API token.
|
|
357
|
+
*
|
|
358
|
+
* @param res - Response
|
|
359
|
+
* @param req - Request, so a browser without a session is told to reload
|
|
360
|
+
*/
|
|
361
|
+
export function sendOwnerAuthRequired(res, req) {
|
|
362
|
+
res.status(401).json(ownerAuthRequiredBody(req));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Refuse a caller that is not the owner. Agents get `agentBody` (403, the
|
|
366
|
+
* route's own wording); everyone else without an owner credential gets 401.
|
|
367
|
+
*
|
|
368
|
+
* @param req - Request
|
|
369
|
+
* @param res - Response
|
|
370
|
+
* @param agentBody - 403 body for an agent
|
|
371
|
+
* @returns True when refused (response written)
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* ```ts
|
|
375
|
+
* if (rejectNonOwner(req, res, { success: false, error: 'Only the owner can dismiss a ticket' })) return;
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
export function rejectNonOwner(req, res, agentBody = { success: false, error: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_ONLY, message: 'Only the owner can do this.' }) {
|
|
379
|
+
const id = getCallerIdentity(req);
|
|
380
|
+
if (id.kind === 'owner' || id.kind === 'relay-owner')
|
|
381
|
+
return false;
|
|
382
|
+
if (id.kind === 'agent') {
|
|
383
|
+
res.status(403).json(agentBody);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
sendOwnerAuthRequired(res, req);
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Whether the caller is the orchestrator itself, proven by its badge or the
|
|
391
|
+
* process tree (the bare `X-Agent-Session` header is not proof). No other
|
|
392
|
+
* agent qualifies.
|
|
393
|
+
*
|
|
394
|
+
* @param req - Request
|
|
395
|
+
* @returns True for the verified orchestrator
|
|
396
|
+
*/
|
|
397
|
+
export function isOrchestratorCaller(req) {
|
|
398
|
+
const id = getCallerIdentity(req);
|
|
399
|
+
return isVerifiedAgent(id) && id.session === ORCHESTRATOR_SESSION_NAME;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* {@link rejectNonOwner}, except that the verified orchestrator passes too.
|
|
403
|
+
*
|
|
404
|
+
* For actions the owner can ask the orchestrator to do on their behalf
|
|
405
|
+
* (owner-away rule: the owner is on the phone and talks to the orc, so a
|
|
406
|
+
* gate that only the dashboard can open leaves the request hanging). Every
|
|
407
|
+
* other agent still gets the route's 403.
|
|
408
|
+
*
|
|
409
|
+
* @param req - Request
|
|
410
|
+
* @param res - Response
|
|
411
|
+
* @param agentBody - 403 body for any other agent
|
|
412
|
+
* @returns True when a response was sent
|
|
413
|
+
*/
|
|
414
|
+
export function rejectNonOwnerOrOrchestrator(req, res, agentBody = { success: false, error: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_ONLY, message: 'Only the owner can do this.' }) {
|
|
415
|
+
if (isOrchestratorCaller(req))
|
|
416
|
+
return false;
|
|
417
|
+
return rejectNonOwner(req, res, agentBody);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Express middleware form of {@link rejectNonOwner}.
|
|
421
|
+
*
|
|
422
|
+
* @param req - Request
|
|
423
|
+
* @param res - Response
|
|
424
|
+
* @param next - Next
|
|
425
|
+
*/
|
|
426
|
+
export const requireOwner = (req, res, next) => {
|
|
427
|
+
if (rejectNonOwner(req, res))
|
|
428
|
+
return;
|
|
429
|
+
next();
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* Owner-only decisions with an audit fingerprint (OKR approve/reject, signal
|
|
433
|
+
* digest items). Agents get 403 `owner_approval_required`; a caller with no
|
|
434
|
+
* owner credential gets 401. Before #999 this required the raw API token
|
|
435
|
+
* even from loopback, so the local dashboard could not use it; the owner
|
|
436
|
+
* session now counts too.
|
|
437
|
+
*
|
|
438
|
+
* On success `res.locals.ownerTokenFingerprint` names the credential for the
|
|
439
|
+
* audit trail: 8 hex of sha256(API token), `session-<8 hex>` for a dashboard
|
|
440
|
+
* session, or `relay`.
|
|
441
|
+
*
|
|
442
|
+
* @param req - Request
|
|
443
|
+
* @param res - Response
|
|
444
|
+
* @param next - Next
|
|
445
|
+
*/
|
|
446
|
+
export const requireOwnerToken = (req, res, next) => {
|
|
447
|
+
const id = getCallerIdentity(req);
|
|
448
|
+
if (id.kind === 'agent') {
|
|
449
|
+
res.status(403).json({
|
|
450
|
+
success: false,
|
|
451
|
+
error: API_SECURITY_CONSTANTS.ERRORS.OWNER_APPROVAL_REQUIRED,
|
|
452
|
+
hint: 'This decision must be made by the owner from the dashboard or with the API token, not by an agent session.',
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (id.kind !== 'owner' && id.kind !== 'relay-owner') {
|
|
457
|
+
sendOwnerAuthRequired(res, req);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (id.via === 'api-token') {
|
|
461
|
+
res.locals.ownerTokenFingerprint = getApiTokenFingerprint(extractPresentedTokenWithSource(req)?.token);
|
|
462
|
+
}
|
|
463
|
+
else if (id.via === 'owner-session' && id.ownerSessionId) {
|
|
464
|
+
res.locals.ownerTokenFingerprint = `session-${createHash('sha256').update(id.ownerSessionId).digest('hex').slice(0, 8)}`;
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
res.locals.ownerTokenFingerprint = id.via;
|
|
468
|
+
}
|
|
469
|
+
next();
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* {@link requireOwner} with the route's own 403 wording for agents.
|
|
473
|
+
*
|
|
474
|
+
* @param agentBody - 403 body for an agent
|
|
475
|
+
* @returns Express middleware
|
|
476
|
+
*/
|
|
477
|
+
export function ownerOnly(agentBody) {
|
|
478
|
+
return (req, res, next) => {
|
|
479
|
+
if (rejectNonOwner(req, res, agentBody))
|
|
480
|
+
return;
|
|
481
|
+
next();
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Whether an identity is an agent proven by a credential: its badge, or the
|
|
486
|
+
* process tree (an agent PTY's process presented the owner token). The
|
|
487
|
+
* legacy `X-Agent-Session` header alone is not proof — any local process can
|
|
488
|
+
* set it.
|
|
489
|
+
*
|
|
490
|
+
* @param identity - Caller identity
|
|
491
|
+
* @returns True for a badge or process-tree agent with a known session
|
|
492
|
+
*/
|
|
493
|
+
export function isVerifiedAgent(identity) {
|
|
494
|
+
return identity.kind === 'agent' && Boolean(identity.session) && (identity.via === 'agent-badge' || identity.via === 'process-tree');
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Refuse a caller that is neither the owner nor a verified agent
|
|
498
|
+
* ({@link isVerifiedAgent}). An agent with only the legacy header gets 403
|
|
499
|
+
* `agent_badge_required`; everyone else without a credential gets 401.
|
|
500
|
+
*
|
|
501
|
+
* @param req - Request
|
|
502
|
+
* @param res - Response
|
|
503
|
+
* @param action - What the caller tried, for the 403 message (e.g. "Writing into an agent's terminal")
|
|
504
|
+
* @returns True when refused (response written)
|
|
505
|
+
*/
|
|
506
|
+
export function rejectUnverifiedCaller(req, res, action) {
|
|
507
|
+
const id = getCallerIdentity(req);
|
|
508
|
+
if (id.kind === 'owner' || id.kind === 'relay-owner' || isVerifiedAgent(id))
|
|
509
|
+
return false;
|
|
510
|
+
if (id.kind === 'agent') {
|
|
511
|
+
res.status(403).json({
|
|
512
|
+
success: false,
|
|
513
|
+
error: OWNER_AUTH_CONSTANTS.ERRORS.AGENT_BADGE_REQUIRED,
|
|
514
|
+
code: OWNER_AUTH_CONSTANTS.ERRORS.AGENT_BADGE_REQUIRED,
|
|
515
|
+
message: `${action} needs the agent badge (CREWLY_AGENT_BADGE). Restart the agent so it gets one.`,
|
|
516
|
+
});
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
sendOwnerAuthRequired(res, req);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Express middleware form of {@link rejectUnverifiedCaller}: the owner, or an
|
|
524
|
+
* agent identified by its badge (or the process tree).
|
|
525
|
+
*
|
|
526
|
+
* @param action - What the route does, for the 403 message
|
|
527
|
+
* @returns Express middleware
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* ```ts
|
|
531
|
+
* router.post('/terminal/:s/write', ownerOrVerifiedAgent("Writing into an agent's terminal"), handler);
|
|
532
|
+
* ```
|
|
533
|
+
*/
|
|
534
|
+
export function ownerOrVerifiedAgent(action) {
|
|
535
|
+
return (req, res, next) => {
|
|
536
|
+
if (rejectUnverifiedCaller(req, res, action))
|
|
537
|
+
return;
|
|
538
|
+
next();
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* {@link ownerOrVerifiedAgent} for the one route a scheduled command may use:
|
|
543
|
+
* delivering a plain message to an agent. A `scheduler` caller passes only with
|
|
544
|
+
* `mode: "message"` and no `workItemId` (a hand-over can clear the agent's
|
|
545
|
+
* conversation); anything else is 403 `scheduler_message_only`. Owner and
|
|
546
|
+
* verified agents are treated exactly as {@link ownerOrVerifiedAgent} does.
|
|
547
|
+
*
|
|
548
|
+
* @param action - What the route does, for the 403 message
|
|
549
|
+
* @returns Express middleware
|
|
550
|
+
*/
|
|
551
|
+
export function ownerOrVerifiedAgentOrScheduler(action) {
|
|
552
|
+
return (req, res, next) => {
|
|
553
|
+
if (getCallerIdentity(req).kind === 'scheduler') {
|
|
554
|
+
const body = (req.body ?? {});
|
|
555
|
+
if (body.mode !== 'message' || body.workItemId !== undefined) {
|
|
556
|
+
res.status(403).json({
|
|
557
|
+
success: false,
|
|
558
|
+
error: OWNER_AUTH_CONSTANTS.ERRORS.SCHEDULER_MESSAGE_ONLY,
|
|
559
|
+
code: OWNER_AUTH_CONSTANTS.ERRORS.SCHEDULER_MESSAGE_ONLY,
|
|
560
|
+
message: `${action}: a scheduled command may only deliver a plain message (mode "message", no workItemId).`,
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
next();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (rejectUnverifiedCaller(req, res, action))
|
|
568
|
+
return;
|
|
569
|
+
next();
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const lastWarned = new Map();
|
|
573
|
+
/**
|
|
574
|
+
* Warn at most once per LEGACY_WARN_THROTTLE_MS per key.
|
|
575
|
+
*
|
|
576
|
+
* @param key - Throttle key
|
|
577
|
+
* @param message - Message
|
|
578
|
+
* @param meta - Fields
|
|
579
|
+
*/
|
|
580
|
+
function warnThrottled(key, message, meta) {
|
|
581
|
+
const now = Date.now();
|
|
582
|
+
const last = lastWarned.get(key);
|
|
583
|
+
if (last !== undefined && now - last < OWNER_AUTH_CONSTANTS.LEGACY_WARN_THROTTLE_MS)
|
|
584
|
+
return;
|
|
585
|
+
if (lastWarned.size > 1000)
|
|
586
|
+
lastWarned.clear();
|
|
587
|
+
lastWarned.set(key, now);
|
|
588
|
+
logger.warn(message, meta);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Point `X-Agent-Session` at the identified agent, so code that reads the
|
|
592
|
+
* header sees the authenticated identity. Never adds the header: a call
|
|
593
|
+
* that left it out on purpose (`remote-browser --no-bind`) keeps it out.
|
|
594
|
+
*
|
|
595
|
+
* @param req - Request (headers mutated)
|
|
596
|
+
* @param identity - Its identity
|
|
597
|
+
*/
|
|
598
|
+
function normaliseSessionHeader(req, identity) {
|
|
599
|
+
if (identity.kind !== 'agent' || !identity.session)
|
|
600
|
+
return;
|
|
601
|
+
for (const name of [API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER, API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER_LEGACY]) {
|
|
602
|
+
const current = header(req, name);
|
|
603
|
+
if (current !== undefined && current !== identity.session)
|
|
604
|
+
req.headers[name] = identity.session;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Build the `/api` middleware.
|
|
609
|
+
*
|
|
610
|
+
* @param peers - Process classifier. The server passes one that knows the
|
|
611
|
+
* live agent PTYs (`liveSessionPids` from the agent-origin middleware), so
|
|
612
|
+
* the terminal check and session names work; without it only the
|
|
613
|
+
* ancestry and environment checks run.
|
|
614
|
+
* @returns Express middleware
|
|
615
|
+
*/
|
|
616
|
+
export function createCallerIdentityMiddleware(peers = new PeerProcessService()) {
|
|
617
|
+
return (req, _res, next) => {
|
|
618
|
+
classifyCaller(req, peers)
|
|
619
|
+
.then((identity) => {
|
|
620
|
+
identities.set(req, identity);
|
|
621
|
+
normaliseSessionHeader(req, identity);
|
|
622
|
+
if (identity.via === 'legacy-header') {
|
|
623
|
+
warnThrottled(`legacy:${identity.session}`, 'Agent call without a valid agent badge, and the process tree did not confirm the session — treated as that agent only where a bare session header is still accepted; terminal writes and API-key reads refuse it (403 agent_badge_required). Restart the agent so it gets CREWLY_AGENT_BADGE.', {
|
|
624
|
+
session: identity.session,
|
|
625
|
+
path: req.path,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
else if (identity.note === LEGACY_CONFIRMED_NOTE) {
|
|
629
|
+
warnThrottled(`legacy-ok:${identity.session}`, 'Agent call without a valid agent badge — accepted because its process runs under that agent\'s PTY. Restart the agent so it gets a current CREWLY_AGENT_BADGE.', {
|
|
630
|
+
session: identity.session,
|
|
631
|
+
path: req.path,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
else if (identity.via === 'process-tree') {
|
|
635
|
+
warnThrottled(`tree:${identity.session ?? '?'}`, 'An agent process presented the owner API token — treated as that agent, not the owner.', {
|
|
636
|
+
session: identity.session,
|
|
637
|
+
path: req.path,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
else if (identity.note?.startsWith('process check skipped')) {
|
|
641
|
+
warnThrottled('peer-unknown', 'Could not tell which local process sent an owner-token request — accepted as the owner (fail open).', {
|
|
642
|
+
reason: identity.note,
|
|
643
|
+
path: req.path,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
next();
|
|
647
|
+
})
|
|
648
|
+
.catch((error) => {
|
|
649
|
+
// Classification never throws in practice; if it does, the request is anonymous.
|
|
650
|
+
identities.set(req, { kind: 'anonymous', via: 'none', note: 'classification failed' });
|
|
651
|
+
logger.warn('Caller classification failed — treating the request as anonymous', {
|
|
652
|
+
error: error instanceof Error ? error.message : String(error),
|
|
653
|
+
});
|
|
654
|
+
next();
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
//# sourceMappingURL=caller-identity.middleware.js.map
|