hippo-memory 1.63.1 → 1.64.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/README.md +13 -5
- package/dist/agent-memories/claude-code.js +3 -3
- package/dist/ambient-store.d.ts +2 -0
- package/dist/ambient-store.js +4 -3
- package/dist/api/assemble.d.ts +2 -0
- package/dist/api/assemble.js +11 -7
- package/dist/api/auth.d.ts +15 -11
- package/dist/api/auth.js +116 -22
- package/dist/api/context-select.d.ts +5 -3
- package/dist/api/context-select.js +6 -11
- package/dist/api/context-types.d.ts +2 -1
- package/dist/api/context.d.ts +1 -1
- package/dist/api/context.js +79 -55
- package/dist/api/dormant.d.ts +5 -5
- package/dist/api/dormant.js +13 -9
- package/dist/api/drill-down.d.ts +2 -0
- package/dist/api/drill-down.js +12 -7
- package/dist/api/forget.d.ts +1 -1
- package/dist/api/forget.js +8 -4
- package/dist/api/learn.js +5 -3
- package/dist/api/outcome.d.ts +1 -1
- package/dist/api/outcome.js +2 -1
- package/dist/api/promote.js +8 -5
- package/dist/api/recall-types.d.ts +4 -2
- package/dist/api/recall.js +51 -36
- package/dist/api/remember.d.ts +7 -0
- package/dist/api/remember.js +26 -2
- package/dist/api/sleep-run.d.ts +28 -0
- package/dist/api/sleep-run.js +267 -0
- package/dist/api/sleep.d.ts +5 -45
- package/dist/api/sleep.js +5 -252
- package/dist/api/types.d.ts +5 -0
- package/dist/api/types.js +4 -0
- package/dist/audit-prune.js +1 -1
- package/dist/audit.d.ts +1 -3
- package/dist/audit.js +7 -103
- package/dist/auth.d.ts +48 -4
- package/dist/auth.js +111 -42
- package/dist/autolearn.js +2 -2
- package/dist/capture/caller-session.d.ts +16 -0
- package/dist/capture/caller-session.js +58 -0
- package/dist/capture/command.d.ts +19 -1
- package/dist/capture/command.js +28 -21
- package/dist/capture/compact-resume-caller.d.ts +11 -0
- package/dist/capture/compact-resume-caller.js +35 -0
- package/dist/capture/compact.d.ts +0 -14
- package/dist/capture/compact.js +7 -160
- package/dist/capture/compaction-items-caller.d.ts +16 -0
- package/dist/capture/compaction-items-caller.js +32 -0
- package/dist/capture/extract.d.ts +2 -14
- package/dist/capture/extract.js +135 -346
- package/dist/capture/failure-caller.d.ts +21 -0
- package/dist/capture/failure-caller.js +74 -0
- package/dist/capture/failure-reading.d.ts +44 -0
- package/dist/capture/failure-reading.js +78 -0
- package/dist/capture/pre-compact-caller.d.ts +16 -0
- package/dist/capture/pre-compact-caller.js +39 -0
- package/dist/capture/session-end-caller.d.ts +21 -0
- package/dist/capture/session-end-caller.js +30 -0
- package/dist/capture/session-texts.d.ts +14 -0
- package/dist/capture/session-texts.js +42 -0
- package/dist/capture/transcript.d.ts +6 -1
- package/dist/capture/transcript.js +20 -16
- package/dist/capture/working-state.d.ts +21 -0
- package/dist/capture/working-state.js +154 -0
- package/dist/capture-error.d.ts +8 -22
- package/dist/capture-error.js +9 -61
- package/dist/cli/audit.js +25 -0
- package/dist/cli/auth.js +4 -3
- package/dist/cli/context.js +13 -158
- package/dist/cli/continuity.js +2 -1
- package/dist/cli/init.js +5 -3
- package/dist/cli/last-sleep.d.ts +6 -0
- package/dist/cli/last-sleep.js +68 -0
- package/dist/cli/quality-repair-once.d.ts +3 -0
- package/dist/cli/quality-repair-once.js +36 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.d.ts +2 -0
- package/dist/cli/remember.js +4 -1
- package/dist/cli/session-hooks.d.ts +0 -3
- package/dist/cli/session-hooks.js +12 -49
- package/dist/cli/setup.js +10 -2
- package/dist/cli/shared.d.ts +7 -14
- package/dist/cli/shared.js +19 -78
- package/dist/cli/sleep.js +6 -3
- package/dist/cli/usage.js +18 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -2
- package/dist/client.js +1 -1
- package/dist/compaction-record.d.ts +19 -1
- package/dist/compaction-record.js +61 -132
- package/dist/compaction-spool.d.ts +45 -0
- package/dist/compaction-spool.js +454 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +49 -0
- package/dist/connectors/github/deletion.d.ts +1 -1
- package/dist/connectors/github/deletion.js +5 -17
- package/dist/consolidate/conflicts.js +4 -0
- package/dist/consolidate/flush-units.d.ts +9 -0
- package/dist/consolidate/flush-units.js +63 -0
- package/dist/consolidate/llm-passes.js +5 -4
- package/dist/consolidate/merge.js +5 -1
- package/dist/consolidate/run.d.ts +2 -1
- package/dist/consolidate/sleep.d.ts +2 -0
- package/dist/consolidate/sleep.js +82 -15
- package/dist/context-render.d.ts +14 -0
- package/dist/context-render.js +28 -0
- package/dist/dag.js +6 -0
- package/dist/dashboard-actions.js +3 -2
- package/dist/dashboard.js +4 -3
- package/dist/db/backup.d.ts +6 -0
- package/dist/db/backup.js +29 -0
- package/dist/db/busy.d.ts +9 -0
- package/dist/db/busy.js +35 -0
- package/dist/db/continuity.d.ts +1 -0
- package/dist/db/continuity.js +31 -4
- package/dist/db/meta.d.ts +2 -0
- package/dist/db/meta.js +7 -0
- package/dist/db/migrate.js +2 -3
- package/dist/db/migrations/index.d.ts +1 -1
- package/dist/db/migrations/index.js +5 -1
- package/dist/db/migrations/v39.js +2 -7
- package/dist/db/migrations/v53.d.ts +3 -0
- package/dist/db/migrations/v53.js +16 -0
- package/dist/db/migrations/v54.d.ts +3 -0
- package/dist/db/migrations/v54.js +25 -0
- package/dist/db/open.d.ts +11 -0
- package/dist/db/open.js +27 -0
- package/dist/db/tables.d.ts +2 -0
- package/dist/db/tables.js +6 -2
- package/dist/db.d.ts +2 -2
- package/dist/db.js +2 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +31 -15
- package/dist/dormant.d.ts +16 -20
- package/dist/dormant.js +24 -30
- package/dist/entry/json-hooks.d.ts +3 -0
- package/dist/entry/json-hooks.js +4 -0
- package/dist/entry/project-identity.d.ts +2 -0
- package/dist/entry/project-identity.js +3 -0
- package/dist/entry/session-text.d.ts +7 -0
- package/dist/entry/session-text.js +8 -0
- package/dist/extract.js +6 -0
- package/dist/failure-log.d.ts +10 -2
- package/dist/failure-log.js +19 -2
- package/dist/gated-write.js +1 -1
- package/dist/graph/delta.d.ts +44 -0
- package/dist/graph/delta.js +109 -0
- package/dist/graph/types.d.ts +5 -0
- package/dist/graph/write.d.ts +20 -20
- package/dist/graph/write.js +103 -36
- package/dist/graph-extract.d.ts +43 -9
- package/dist/graph-extract.js +140 -157
- package/dist/graph-recall.d.ts +1 -0
- package/dist/graph-recall.js +2 -2
- package/dist/graph-view.d.ts +1 -0
- package/dist/graph-view.js +25 -2
- package/dist/half-life-migration.js +5 -1
- package/dist/handoff-evidence.d.ts +5 -0
- package/dist/handoff-evidence.js +30 -0
- package/dist/home-path.d.ts +5 -0
- package/dist/home-path.js +24 -0
- package/dist/hooks/json-hooks.d.ts +13 -2
- package/dist/hooks/json-hooks.js +246 -117
- package/dist/hooks/opencode.js +1 -1
- package/dist/hooks/shared.d.ts +2 -0
- package/dist/hooks/shared.js +6 -1
- package/dist/http-util.d.ts +16 -7
- package/dist/http-util.js +65 -20
- package/dist/importers/vault.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +7 -0
- package/dist/ledger-db.d.ts +10 -0
- package/dist/ledger-db.js +42 -0
- package/dist/mcp/admin-tools.d.ts +2 -2
- package/dist/mcp/admin-tools.js +47 -5
- package/dist/mcp/memory-tools.js +7 -2
- package/dist/mcp/protocol.d.ts +6 -0
- package/dist/mcp/protocol.js +4 -2
- package/dist/mcp/recall-tools.js +19 -6
- package/dist/mcp/request.js +4 -0
- package/dist/mcp/session-state.d.ts +4 -0
- package/dist/mcp/session-state.js +15 -6
- package/dist/mcp/shared-gate.d.ts +4 -0
- package/dist/mcp/shared-gate.js +22 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/memory-quality.d.ts +35 -0
- package/dist/memory-quality.js +191 -0
- package/dist/memory.d.ts +4 -4
- package/dist/physics-state.js +1 -1
- package/dist/pilot-arm.d.ts +14 -3
- package/dist/pilot-arm.js +32 -10
- package/dist/postinstall.js +2 -2
- package/dist/project-briefs.js +4 -4
- package/dist/project-identity.d.ts +14 -10
- package/dist/project-identity.js +29 -11
- package/dist/project-merge.d.ts +0 -2
- package/dist/project-merge.js +9 -13
- package/dist/prompt-hook.d.ts +65 -0
- package/dist/prompt-hook.js +213 -0
- package/dist/prompt-recall.js +1 -1
- package/dist/quality-repair.d.ts +27 -0
- package/dist/quality-repair.js +220 -0
- package/dist/rate-limit.d.ts +1 -0
- package/dist/rate-limit.js +5 -3
- package/dist/raw-archive.d.ts +1 -1
- package/dist/raw-archive.js +6 -16
- package/dist/recall-scope.d.ts +28 -5
- package/dist/recall-scope.js +52 -7
- package/dist/recall-trace.js +1 -1
- package/dist/reject-flow.d.ts +2 -0
- package/dist/reject-flow.js +20 -6
- package/dist/secret-detect.js +7 -8
- package/dist/server/auth.d.ts +8 -6
- package/dist/server/auth.js +85 -38
- package/dist/server/client-ip.d.ts +2 -0
- package/dist/server/client-ip.js +16 -1
- package/dist/server/mcp-http.d.ts +9 -3
- package/dist/server/mcp-http.js +56 -13
- package/dist/server/request.d.ts +1 -1
- package/dist/server/request.js +5 -5
- package/dist/server/routes/admin.js +12 -6
- package/dist/server/routes/memories.js +9 -1
- package/dist/server/routes/recall.js +14 -2
- package/dist/server/types.d.ts +42 -5
- package/dist/server/validation.d.ts +5 -2
- package/dist/server/validation.js +19 -2
- package/dist/server.d.ts +21 -4
- package/dist/server.js +207 -49
- package/dist/session-digest.d.ts +0 -1
- package/dist/session-digest.js +3 -12
- package/dist/session-owners.d.ts +7 -0
- package/dist/session-owners.js +54 -0
- package/dist/share-scrub.d.ts +3 -0
- package/dist/share-scrub.js +8 -0
- package/dist/shared.d.ts +1 -0
- package/dist/shared.js +26 -18
- package/dist/store/candidates.d.ts +14 -3
- package/dist/store/candidates.js +48 -26
- package/dist/store/conflicts.d.ts +15 -0
- package/dist/store/conflicts.js +68 -44
- package/dist/store/delete-and-batch.d.ts +25 -1
- package/dist/store/delete-and-batch.js +121 -69
- package/dist/store/entry-reads.d.ts +5 -6
- package/dist/store/entry-reads.js +24 -12
- package/dist/store/entry-row.d.ts +7 -10
- package/dist/store/entry-row.js +40 -18
- package/dist/store/entry-writes.d.ts +3 -3
- package/dist/store/entry-writes.js +9 -23
- package/dist/store/handoffs.d.ts +9 -6
- package/dist/store/handoffs.js +72 -59
- package/dist/store/index-and-stats.js +2 -2
- package/dist/store/open.d.ts +3 -1
- package/dist/store/open.js +3 -3
- package/dist/store/search-rows.d.ts +8 -5
- package/dist/store/search-rows.js +15 -18
- package/dist/store/sessions.d.ts +25 -6
- package/dist/store/sessions.js +61 -19
- package/dist/store/summaries.d.ts +1 -1
- package/dist/store/summaries.js +3 -18
- package/dist/store/tenant-lookup.d.ts +7 -3
- package/dist/store/tenant-lookup.js +9 -9
- package/dist/store-port.d.ts +13 -0
- package/dist/store-port.js +20 -0
- package/dist/version.d.ts +5 -1
- package/dist/version.js +5 -1
- package/dist/working-memory.js +1 -1
- package/dist/write-budget.d.ts +15 -0
- package/dist/write-budget.js +15 -0
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -4
package/dist/server.js
CHANGED
|
@@ -2,20 +2,22 @@ import { envPort, envRequireAuth, envV1Rps } from './env.js';
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { detectServer, removePidfileIfOwned, writePidfile } from './server-detect.js';
|
|
5
|
-
import { closeHippoDb, getHippoDbPath,
|
|
5
|
+
import { closeHippoDb, getHippoDbPath, isStoreBusy, openHippoDb, SERVER_DB_WAIT_MS, withBusyWait, withSqliteBlocked } from './db.js';
|
|
6
|
+
import { sqliteStore } from './store-port.js';
|
|
6
7
|
import { auditWriteFailureCount } from './audit.js';
|
|
7
8
|
import { PACKAGE_VERSION } from './version.js';
|
|
8
9
|
import { errorFields, log } from './log.js';
|
|
9
10
|
import { createRateLimiter } from './rate-limit.js';
|
|
10
|
-
import { authRevoke, RecallContractError } from './api.js';
|
|
11
|
+
import { authCreateSelf, authRevoke, RecallContractError } from './api.js';
|
|
11
12
|
import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
|
|
12
13
|
import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
|
|
13
|
-
import { BodyTooLargeError, HttpError, JSON_HEADERS, sendJson } from './http-util.js';
|
|
14
|
+
import { BodyTimeoutError, BodyTooLargeError, closeAfterReply, HttpError, JSON_HEADERS, sendJson } from './http-util.js';
|
|
14
15
|
import { ForbiddenError } from './api-errors.js';
|
|
15
|
-
import { isLoopback } from './server/auth.js';
|
|
16
|
+
import { buildContextWithAuth, isLoopback, LIMITER_MAX_KEYS, requireAuth } from './server/auth.js';
|
|
16
17
|
import { enforceRateLimit } from './server/client-ip.js';
|
|
17
18
|
import { drainAndClose } from './server/lifecycle.js';
|
|
18
19
|
import { handleMcpPost, handleMcpStream } from './server/mcp-http.js';
|
|
20
|
+
import { MCP_PROJECT_SCOPED_HEADER } from './project-identity.js';
|
|
19
21
|
import { logRequestFailure, matchPath, parseRequest, rejectEncodedSlash, replyFor, requestIds, resolveRequestId, sendError } from './server/request.js';
|
|
20
22
|
import { handleApproveQuarantine, handleCreateAuthKey, handleListAudit, handleListAuthKeys, handleListQuarantine, handleRejectQuarantine, handleRevokeAuthKey } from './server/routes/admin.js';
|
|
21
23
|
import { handleCloseCustomerNote, handleCreateCustomerNote, handleGetCustomerNote, handleListCustomerNotes, handleSupersedeCustomerNote } from './server/routes/customer-notes.js';
|
|
@@ -28,14 +30,36 @@ import { handleCloseProcess, handleCreateProcess, handleGetProcess, handleListPr
|
|
|
28
30
|
import { handleCloseProjectBrief, handleCreateProjectBrief, handleGetProjectBrief, handleListProjectBriefs, handleRefreshProjectBrief, handleSupersedeProjectBrief } from './server/routes/project-briefs.js';
|
|
29
31
|
import { handleAssembleSession, handleDrillRecall, handleGetContext, handleRecallMemories } from './server/routes/recall.js';
|
|
30
32
|
import { handleCloseSkill, handleCreateSkill, handleExportSkills, handleGetSkill, handleListSkills, handleSupersedeSkill } from './server/routes/skills.js';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
import { parseJsonBody } from './server/validation.js';
|
|
34
|
+
// Add-on packages mint and revoke keys through these without importing the whole api surface.
|
|
35
|
+
export { authCreateSelf, authRevoke, ForbiddenError };
|
|
33
36
|
// Published on the hippo-memory/server subpath before they moved to http-util.ts, so they stay exported here.
|
|
34
37
|
export { isCrossSite, LOOPBACK_HOST_HEADER } from './http-util.js';
|
|
35
38
|
// The code behind these lives in src/server/; this subpath keeps exporting them.
|
|
36
39
|
export { __resetSessionRecallHistoryHttp } from './server/routes/recall.js';
|
|
37
40
|
export { clientIpForRateLimit } from './server/client-ip.js';
|
|
38
41
|
export { isLoopback, isReservedActor } from './server/auth.js';
|
|
42
|
+
// What an add-on route handler needs: HttpError for its 4xx replies, promptHookContext for a caller that renders the prompt hook elsewhere, JsonValue for its body.
|
|
43
|
+
export { HttpError } from './http-util.js';
|
|
44
|
+
export { promptHookContext } from './prompt-hook.js';
|
|
45
|
+
// A session-end route stores the turns its caller read from a transcript on the caller's own machine.
|
|
46
|
+
export { captureSessionTexts } from './capture/session-texts.js';
|
|
47
|
+
// An add-on serves from another database by passing serve() its own HippoStore.
|
|
48
|
+
export { sqliteStore } from './store-port.js';
|
|
49
|
+
export { StoreBusyError } from './db.js';
|
|
50
|
+
// An add-on's install step mints the first admin key into a store folder it names, which `hippo auth create` cannot reach.
|
|
51
|
+
export { authCreate } from './api.js';
|
|
52
|
+
// An add-on that serves a team store checks the flag before it starts.
|
|
53
|
+
export { isSharedStore } from './config.js';
|
|
54
|
+
export { ownerOrSubject } from './api.js';
|
|
55
|
+
// A route that writes for a session binds it to the caller's owner first.
|
|
56
|
+
export { bindSessionOwner } from './session-owners.js';
|
|
57
|
+
// A hook route for a caller on another machine: each call binds the session, then writes under the caller's owner and project.
|
|
58
|
+
export { preCompactForCaller } from './capture/pre-compact-caller.js';
|
|
59
|
+
export { compactResumeForCaller } from './capture/compact-resume-caller.js';
|
|
60
|
+
export { saveCompactionItemsForCaller } from './capture/compaction-items-caller.js';
|
|
61
|
+
export { captureFailureForCaller } from './capture/failure-caller.js';
|
|
62
|
+
export { sessionEndHandoffForCaller } from './capture/session-end-caller.js';
|
|
39
63
|
// Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
|
|
40
64
|
// New unauth routes MUST be added here AND get a corresponding entry in
|
|
41
65
|
// tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
|
|
@@ -45,6 +69,7 @@ export { isLoopback, isReservedActor } from './server/auth.js';
|
|
|
45
69
|
// `buildContextWithAuth` / `requireAuth`. Adding a route here without
|
|
46
70
|
// adding the corresponding `isPublicRoute` short-circuit in a handler is
|
|
47
71
|
// a no-op (auth still applies), so the failure mode is fail-closed.
|
|
72
|
+
// The only other unauthenticated paths are ServeOpts.publicJson's: GET only, checked at boot by assertPublicJson.
|
|
48
73
|
const PUBLIC_ROUTES = new Set([
|
|
49
74
|
'POST /v1/connectors/slack/events',
|
|
50
75
|
'POST /v1/connectors/github/events',
|
|
@@ -128,46 +153,122 @@ const V1_ROUTES = [
|
|
|
128
153
|
{ method: 'POST', regex: /^\/v1\/customer-notes\/(\d+)\/close$/, handler: handleCloseCustomerNote },
|
|
129
154
|
{ method: 'GET', regex: /^\/v1\/customer-notes\/(\d+)$/, handler: handleGetCustomerNote },
|
|
130
155
|
];
|
|
131
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
/** The route's handler bound to this request's path params, or null when method or path differ. The matcher runs before the
|
|
157
|
+
* method check, as the inline route blocks did, so a malformed `%` escape still throws from matchPath on any method. */
|
|
158
|
+
function routeMatches(route, method, path) {
|
|
159
|
+
if ('path' in route)
|
|
160
|
+
return method === route.method && path === route.path ? route.handler : null;
|
|
161
|
+
if ('pattern' in route) {
|
|
162
|
+
const params = matchPath(route.pattern, path);
|
|
163
|
+
return method === route.method && params ? (r) => route.handler(r, params) : null;
|
|
164
|
+
}
|
|
165
|
+
const match = path.match(route.regex);
|
|
166
|
+
return method === route.method && match ? (r) => route.handler(r, match) : null;
|
|
167
|
+
}
|
|
168
|
+
/** Run the first /v1 route whose method and path match. */
|
|
135
169
|
async function dispatchV1Route(r, method, path) {
|
|
136
170
|
for (const route of V1_ROUTES) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const params = matchPath(route.pattern, path);
|
|
145
|
-
if (method === route.method && params) {
|
|
146
|
-
await route.handler(r, params);
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
const match = path.match(route.regex);
|
|
152
|
-
if (method === route.method && match) {
|
|
153
|
-
await route.handler(r, match);
|
|
154
|
-
return true;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
171
|
+
const run = routeMatches(route, method, path);
|
|
172
|
+
if (run === null)
|
|
173
|
+
continue;
|
|
174
|
+
if (!route.storeReady)
|
|
175
|
+
await refuseUnportedRoute(r.req, r.opts);
|
|
176
|
+
await run(r);
|
|
177
|
+
return true;
|
|
157
178
|
}
|
|
158
179
|
return false;
|
|
159
180
|
}
|
|
181
|
+
const PLAIN_SEGMENT_RE = /^[A-Za-z0-9._~-]+$/;
|
|
182
|
+
function isPlainV1Path(path) {
|
|
183
|
+
return path.startsWith('/v1/') && new URL(path, 'http://h').pathname === path
|
|
184
|
+
&& path.slice('/v1/'.length).split('/').every((segment) => PLAIN_SEGMENT_RE.test(segment));
|
|
185
|
+
}
|
|
186
|
+
// A plain path never holds a `%`, so matchPath cannot throw here.
|
|
187
|
+
function isCorePath(method, path) {
|
|
188
|
+
return PUBLIC_ROUTES.has(`${method} ${path}`) || V1_ROUTES.some((route) => routeMatches(route, method, path) !== null);
|
|
189
|
+
}
|
|
190
|
+
/** Boot-time check: an add-on path must be plain, unique and not one core serves, so no add-on shadows a core route or hides from dispatch. */
|
|
191
|
+
function assertAddonRoutes(routes) {
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
for (const { path } of routes) {
|
|
194
|
+
if (!isPlainV1Path(path))
|
|
195
|
+
throw new Error(`add-on route '${path}' is not a plain /v1/ path (segments use A-Z a-z 0-9 . _ ~ -)`);
|
|
196
|
+
if (seen.has(path))
|
|
197
|
+
throw new Error(`add-on route '${path}' is registered twice`);
|
|
198
|
+
if (isCorePath('POST', path))
|
|
199
|
+
throw new Error(`add-on route '${path}' is already served by core`);
|
|
200
|
+
seen.add(path);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const PUBLIC_JSON_MAX_BYTES = 64 * 1024;
|
|
204
|
+
/** Boot-time check and serialization: a public path that a core GET route serves would open that route to anyone. */
|
|
205
|
+
function assertPublicJson(publicJson) {
|
|
206
|
+
const bodies = new Map();
|
|
207
|
+
for (const [path, value] of Object.entries(publicJson)) {
|
|
208
|
+
if (!isPlainV1Path(path))
|
|
209
|
+
throw new Error(`public JSON path '${path}' is not a plain /v1/ path (segments use A-Z a-z 0-9 . _ ~ -)`);
|
|
210
|
+
if (isCorePath('GET', path))
|
|
211
|
+
throw new Error(`public JSON path '${path}' is already served by core`);
|
|
212
|
+
const text = JSON.stringify(value);
|
|
213
|
+
if (text === undefined)
|
|
214
|
+
throw new Error(`public JSON at '${path}' is not JSON`);
|
|
215
|
+
if (Buffer.byteLength(text) > PUBLIC_JSON_MAX_BYTES)
|
|
216
|
+
throw new Error(`public JSON at '${path}' is over 64 KiB`);
|
|
217
|
+
bodies.set(path, text);
|
|
218
|
+
}
|
|
219
|
+
return bodies;
|
|
220
|
+
}
|
|
221
|
+
/** Core authenticates and parses before the handler runs, so an add-on route gets the same 401, 400 and 501 as a core one. */
|
|
222
|
+
async function dispatchAddonRoute({ req, res, opts }, method, path) {
|
|
223
|
+
const route = method === 'POST' ? opts.routes?.find((r) => r.path === path) : undefined;
|
|
224
|
+
if (!route)
|
|
225
|
+
return false;
|
|
226
|
+
await refuseUnportedRoute(req, opts);
|
|
227
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
228
|
+
const body = await parseJsonBody(req, ctx);
|
|
229
|
+
sendJson(res, 200, await route.handler({ ctx, body }));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
/** No auth, body read or store access, so a caller with no key gets it under any store. */
|
|
233
|
+
function dispatchPublicJson({ res, opts }, method, path) {
|
|
234
|
+
const text = method === 'GET' ? opts.publicJsonBodies.get(path) : undefined;
|
|
235
|
+
if (text === undefined)
|
|
236
|
+
return false;
|
|
237
|
+
res.writeHead(200, { ...JSON_HEADERS, 'cache-control': 'no-store' });
|
|
238
|
+
res.end(text);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
const NOT_ON_STORE_MESSAGE = 'not available on this store';
|
|
242
|
+
function assertSqliteStore(opts) {
|
|
243
|
+
if (opts.store.kind !== 'sqlite')
|
|
244
|
+
throw new HttpError(501, NOT_ON_STORE_MESSAGE);
|
|
245
|
+
}
|
|
246
|
+
/** Under another store, a route not yet ported answers 501 without running; the caller is checked first, so a bad key is still a 401. */
|
|
247
|
+
async function refuseUnportedRoute(req, opts) {
|
|
248
|
+
if (opts.store.kind === 'sqlite')
|
|
249
|
+
return;
|
|
250
|
+
await requireAuth(req, opts);
|
|
251
|
+
assertSqliteStore(opts);
|
|
252
|
+
}
|
|
160
253
|
async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
|
|
161
254
|
// Pre-decode raw-URL slash check. Catches `%2F` / `%2f` before
|
|
162
255
|
// Node's URL parser collapses them and they slip past the route table.
|
|
163
256
|
rejectEncodedSlash(req.url ?? '/');
|
|
164
257
|
const { method, path, query } = parseRequest(req);
|
|
258
|
+
// An older core ignores X-Hippo-Project, so every /mcp reply, a 429 or 401 too, tells the client this one filters by it.
|
|
259
|
+
if (path === '/mcp')
|
|
260
|
+
res.setHeader(MCP_PROJECT_SCOPED_HEADER, '1');
|
|
165
261
|
if (method === 'GET' && path === '/health') {
|
|
166
262
|
sendHealth(req, res, startedAt);
|
|
167
263
|
return;
|
|
168
264
|
}
|
|
169
265
|
enforceRateLimit(req, path, limiter);
|
|
170
|
-
|
|
266
|
+
const routeRequest = { req, res, opts, query };
|
|
267
|
+
if (await dispatchV1Route(routeRequest, method, path))
|
|
268
|
+
return;
|
|
269
|
+
if (dispatchPublicJson(routeRequest, method, path))
|
|
270
|
+
return;
|
|
271
|
+
if (await dispatchAddonRoute(routeRequest, method, path))
|
|
171
272
|
return;
|
|
172
273
|
if (method === 'POST' && path === '/v1/connectors/slack/events') {
|
|
173
274
|
// Bearer auth deliberately skipped: this route is in PUBLIC_ROUTES and authenticates via the Slack HMAC signature.
|
|
@@ -175,6 +276,7 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
|
|
|
175
276
|
// Defensive: PUBLIC_ROUTES drift would land here. Fail closed.
|
|
176
277
|
throw new HttpError(401, 'auth required');
|
|
177
278
|
}
|
|
279
|
+
assertSqliteStore(opts);
|
|
178
280
|
await handleSlackEventsWebhook({ req, res, opts });
|
|
179
281
|
return;
|
|
180
282
|
}
|
|
@@ -182,13 +284,16 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
|
|
|
182
284
|
if (!isPublicRoute(method, path)) {
|
|
183
285
|
throw new HttpError(401, 'auth required');
|
|
184
286
|
}
|
|
287
|
+
assertSqliteStore(opts);
|
|
185
288
|
await handleGitHubEventsWebhook({ req, res, opts });
|
|
186
289
|
return;
|
|
187
290
|
}
|
|
188
291
|
if (method === 'POST' && path === '/mcp') {
|
|
292
|
+
await refuseUnportedRoute(req, opts);
|
|
189
293
|
await handleMcpPost(req, res, opts);
|
|
190
294
|
return;
|
|
191
295
|
}
|
|
296
|
+
// Store-ready: the stream and its heartbeat only authenticate, and auth goes through the port.
|
|
192
297
|
if (method === 'GET' && path === '/mcp/stream') {
|
|
193
298
|
await handleMcpStream(req, res, opts, streamSlots);
|
|
194
299
|
return;
|
|
@@ -232,17 +337,48 @@ async function assertNoLiveServer(hippoRoot) {
|
|
|
232
337
|
`Stop that server before starting another on the same hippoRoot.`);
|
|
233
338
|
}
|
|
234
339
|
}
|
|
235
|
-
|
|
340
|
+
// SHORTCUT: buckets live in this process and reset on restart or LRU eviction; move them to the store if serve ever runs as several processes.
|
|
341
|
+
function limiterFor({ ratePerSec, burst }) {
|
|
342
|
+
return createRateLimiter({ ratePerSec, burst, idleEvictMs: 60000, maxKeys: LIMITER_MAX_KEYS });
|
|
343
|
+
}
|
|
344
|
+
function bootRateLimiter(perAddress) {
|
|
345
|
+
if (perAddress === 'off')
|
|
346
|
+
return undefined;
|
|
347
|
+
if (perAddress !== undefined)
|
|
348
|
+
return limiterFor(perAddress);
|
|
236
349
|
// Per-IP rate limiter for /v1/* and /mcp*. Built here (not at module scope) so
|
|
237
350
|
// HIPPO_V1_RPS is read at boot, matching HIPPO_PORT above and letting a test
|
|
238
351
|
// set the rate before serve(). A non-positive or non-finite value disables
|
|
239
352
|
// limiting (the opt-out knob).
|
|
240
353
|
const v1Rps = Number(envV1Rps() ?? 20);
|
|
241
|
-
return Number.isFinite(v1Rps) && v1Rps > 0
|
|
242
|
-
? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
|
|
243
|
-
: undefined;
|
|
354
|
+
return Number.isFinite(v1Rps) && v1Rps > 0 ? limiterFor({ ratePerSec: v1Rps, burst: v1Rps * 2 }) : undefined;
|
|
244
355
|
}
|
|
245
|
-
|
|
356
|
+
const DEFAULT_FAILED_AUTH = { ratePerSec: 20, burst: 40 };
|
|
357
|
+
function assertRateLimitSpec(name, { ratePerSec, burst }) {
|
|
358
|
+
if (!Number.isFinite(ratePerSec) || ratePerSec <= 0)
|
|
359
|
+
throw new Error(`rateLimits.${name}.ratePerSec must be a finite number above 0`);
|
|
360
|
+
if (!Number.isFinite(burst) || burst < 1)
|
|
361
|
+
throw new Error(`rateLimits.${name}.burst must be a finite number of at least 1`);
|
|
362
|
+
}
|
|
363
|
+
/** Refuses a bad spec at boot, since a NaN or zero rate would otherwise turn a bucket off or refuse every request. */
|
|
364
|
+
function bootLimiters(rateLimits) {
|
|
365
|
+
const { perCaller, perAddress, failedAuthPerAddress = DEFAULT_FAILED_AUTH } = rateLimits ?? {};
|
|
366
|
+
if (perCaller !== undefined)
|
|
367
|
+
assertRateLimitSpec('perCaller', perCaller);
|
|
368
|
+
if (perAddress !== undefined && perAddress !== 'off')
|
|
369
|
+
assertRateLimitSpec('perAddress', perAddress);
|
|
370
|
+
assertRateLimitSpec('failedAuthPerAddress', failedAuthPerAddress);
|
|
371
|
+
return {
|
|
372
|
+
perAddress: bootRateLimiter(perAddress),
|
|
373
|
+
callerLimiter: perCaller === undefined ? undefined : limiterFor(perCaller),
|
|
374
|
+
failedAuthLimiter: limiterFor(failedAuthPerAddress),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function createStoreHolder(hippoRoot, store) {
|
|
378
|
+
if (store.kind !== 'sqlite') {
|
|
379
|
+
log.info(`serve: the '${store.kind}' store serves ${hippoRoot}, so no hippo.db connection is held`);
|
|
380
|
+
return { hold: () => { }, release: () => { } };
|
|
381
|
+
}
|
|
246
382
|
// Handlers open and close their own connections; while this one is held, none of those closes is SQLite's last,
|
|
247
383
|
// which checkpoints and deletes the WAL. It opens only once the store exists, so serving never creates one.
|
|
248
384
|
let heldDb;
|
|
@@ -276,8 +412,10 @@ function replyWithFailure(req, res, err, requestId) {
|
|
|
276
412
|
catch { /* socket already gone */ }
|
|
277
413
|
return;
|
|
278
414
|
}
|
|
279
|
-
if (
|
|
415
|
+
if (isStoreBusy(err))
|
|
280
416
|
res.setHeader('Retry-After', '1');
|
|
417
|
+
else if (err instanceof HttpError && err.retryAfterSec !== undefined)
|
|
418
|
+
res.setHeader('Retry-After', String(err.retryAfterSec));
|
|
281
419
|
if (mapped.status === 500) {
|
|
282
420
|
// The id lets an operator find the logged cause without the client seeing internal text.
|
|
283
421
|
sendJson(res, 500, { error: mapped.message, requestId });
|
|
@@ -288,10 +426,20 @@ function replyWithFailure(req, res, err, requestId) {
|
|
|
288
426
|
sendJson(res, 400, { error: err.message, code: err.code });
|
|
289
427
|
return;
|
|
290
428
|
}
|
|
429
|
+
// readBody hit its cap or deadline, so close once the 413 or 408 is out rather than drain what the client keeps sending.
|
|
430
|
+
if (err instanceof BodyTooLargeError || err instanceof BodyTimeoutError)
|
|
431
|
+
res.once('finish', () => closeAfterReply(req));
|
|
291
432
|
sendError(res, mapped.status, mapped.message);
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
433
|
+
}
|
|
434
|
+
function replyOrClose(req, res, err, requestId) {
|
|
435
|
+
try {
|
|
436
|
+
replyWithFailure(req, res, err, requestId);
|
|
437
|
+
}
|
|
438
|
+
catch (replyErr) {
|
|
439
|
+
// A throw here would be an unhandled rejection, which stops the daemon for every caller.
|
|
440
|
+
log.error(`serve: failure reply not sent, socket closed: ${replyErr instanceof Error ? replyErr.message : String(replyErr)}`, { requestId });
|
|
441
|
+
res.destroy();
|
|
442
|
+
}
|
|
295
443
|
}
|
|
296
444
|
function setKeepAliveTimeouts(server) {
|
|
297
445
|
// The default 5s keepAliveTimeout closes idle sockets just as clients reuse them (ECONNRESET).
|
|
@@ -341,7 +489,7 @@ function installSignalHandlers(stop) {
|
|
|
341
489
|
* requireAuth) has shipped and every route checks it except GET /health
|
|
342
490
|
* (public by design for platform health checks) and the two connector
|
|
343
491
|
* webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
|
|
344
|
-
* secrets and 404 when those secrets are unset. But the loopback
|
|
492
|
+
* secrets and 404 when those secrets are unset, and any publicJson GET path. But the loopback
|
|
345
493
|
* no-auth fallback inside buildContextWithAuth still admits unauthenticated
|
|
346
494
|
* requests from a loopback remote address (unless they carry Forwarded,
|
|
347
495
|
* X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get
|
|
@@ -357,27 +505,35 @@ function installSignalHandlers(stop) {
|
|
|
357
505
|
export async function serve(opts) {
|
|
358
506
|
const host = opts.host ?? '127.0.0.1';
|
|
359
507
|
const requestedPort = opts.port ?? Number(envPort() ?? 6789);
|
|
508
|
+
// A frozen copy, so a route the caller adds or renames after boot never skips the check below.
|
|
509
|
+
const routes = Object.freeze((opts.routes ?? []).map(({ path, handler }) => Object.freeze({ path, handler })));
|
|
510
|
+
assertAddonRoutes(routes);
|
|
511
|
+
const publicJsonBodies = assertPublicJson(opts.publicJson ?? {});
|
|
512
|
+
const { perAddress: limiter, callerLimiter, failedAuthLimiter } = bootLimiters(opts.rateLimits);
|
|
360
513
|
assertBindable(host);
|
|
361
514
|
await assertNoLiveServer(opts.hippoRoot);
|
|
362
515
|
// The server's start time. Single source of truth: it is returned by every
|
|
363
516
|
// GET /health response and (below) written into the pidfile, so detectServer
|
|
364
517
|
// can match the two and prove a pid-reusing impostor is not the real server.
|
|
365
518
|
const startedAt = new Date().toISOString();
|
|
366
|
-
const limiter = bootRateLimiter();
|
|
367
519
|
// Open /mcp/stream count per client key, so the cap is per server rather than per process.
|
|
368
520
|
const streamSlots = new Map();
|
|
369
|
-
const
|
|
521
|
+
const served = {
|
|
522
|
+
...opts, routes, publicJsonBodies, store: opts.store ?? sqliteStore(opts.hippoRoot), callerLimiter, failedAuthLimiter,
|
|
523
|
+
};
|
|
524
|
+
const { kind } = served.store;
|
|
525
|
+
const holder = createStoreHolder(opts.hippoRoot, served.store);
|
|
370
526
|
const inflight = new Set();
|
|
371
527
|
const server = createServer((req, res) => {
|
|
372
|
-
res.once('finish',
|
|
528
|
+
res.once('finish', holder.hold);
|
|
373
529
|
inflight.add(res);
|
|
374
530
|
res.once('close', () => inflight.delete(res));
|
|
375
531
|
const requestId = resolveRequestId(req.headers['x-request-id']);
|
|
376
532
|
requestIds.set(req, requestId);
|
|
377
533
|
res.setHeader('X-Request-Id', requestId);
|
|
378
|
-
withBusyWait(SERVER_DB_WAIT_MS, () => handleRequest(req, res,
|
|
379
|
-
|
|
380
|
-
|
|
534
|
+
const run = () => withBusyWait(SERVER_DB_WAIT_MS, () => handleRequest(req, res, served, startedAt, streamSlots, limiter));
|
|
535
|
+
// A missed port under another store would otherwise create and write a hippo.db that store never reads.
|
|
536
|
+
(kind === 'sqlite' ? run() : withSqliteBlocked(kind, run)).catch((err) => replyOrClose(req, res, err, requestId));
|
|
381
537
|
});
|
|
382
538
|
setKeepAliveTimeouts(server);
|
|
383
539
|
await listenOn(server, requestedPort, host);
|
|
@@ -389,7 +545,7 @@ export async function serve(opts) {
|
|
|
389
545
|
const actualPort = addressInfo.port;
|
|
390
546
|
const url = `http://${host.includes(':') ? `[${host}]` : host}:${actualPort}`;
|
|
391
547
|
writePidfile(opts.hippoRoot, { port: actualPort, url, startedAt });
|
|
392
|
-
|
|
548
|
+
holder.hold();
|
|
393
549
|
let stopping = false;
|
|
394
550
|
const stop = async () => {
|
|
395
551
|
if (stopping)
|
|
@@ -400,7 +556,9 @@ export async function serve(opts) {
|
|
|
400
556
|
// unconditional unlink here would orphan it.
|
|
401
557
|
removePidfileIfOwned(opts.hippoRoot, { pid: process.pid, startedAt });
|
|
402
558
|
await drainAndClose(server, inflight, opts.shutdownDrainMs ?? 5000);
|
|
403
|
-
|
|
559
|
+
holder.release();
|
|
560
|
+
if (!opts.store)
|
|
561
|
+
await served.store.close();
|
|
404
562
|
};
|
|
405
563
|
if (opts.handleSignals)
|
|
406
564
|
installSignalHandlers(stop);
|
package/dist/session-digest.d.ts
CHANGED
|
@@ -37,7 +37,6 @@ export declare function __setDigestExistsProbe(probe: ((p: string) => boolean) |
|
|
|
37
37
|
export declare function realFsPath(p: string): string | null;
|
|
38
38
|
/** Repo-relative with forward slashes, '' for the root itself, null outside the repo or when the path cannot be resolved. */
|
|
39
39
|
export declare function repoRelative(p: string, repoRoot: string): string | null;
|
|
40
|
-
export declare const USER_SEGMENT: readonly RegExp[];
|
|
41
40
|
/** Sentences of the reply; code spans and "e.g." are held out of the split so their dots never end a sentence. */
|
|
42
41
|
export declare function digestSentences(text: string): string[];
|
|
43
42
|
export declare function sentenceScore(sentence: string): number;
|
package/dist/session-digest.js
CHANGED
|
@@ -3,10 +3,10 @@ import { createHash } from 'crypto';
|
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { collectSessionTurns } from './capture/transcript.js';
|
|
6
|
-
import { splitSentences } from './capture/extract.js';
|
|
7
6
|
import { isObjectLike, isStringValue } from './capture-contract.js';
|
|
8
7
|
import { PATCH_SUCCESS_LINE, patchPaths, shellPatch } from './codex-patch.js';
|
|
9
8
|
import { loadConfig } from './config.js';
|
|
9
|
+
import { USER_SEGMENT } from './home-path.js';
|
|
10
10
|
import { errorMessage } from './log.js';
|
|
11
11
|
import { createMemory, Layer } from './memory.js';
|
|
12
12
|
import { RejectedValueError } from './rejection.js';
|
|
@@ -304,16 +304,6 @@ function changedFiles(edits, repoRoot) {
|
|
|
304
304
|
}
|
|
305
305
|
return files;
|
|
306
306
|
}
|
|
307
|
-
// A home-directory path names its user and the store may be shared, so such a sentence is dropped.
|
|
308
|
-
export const USER_SEGMENT = [
|
|
309
|
-
/[A-Za-z]:[\\/]+(?:Users|Documents and Settings)[\\/]+[^\\/\s]+/i,
|
|
310
|
-
/(?<![\w.])(?:\/mnt)?\/[A-Za-z]\/Users\/[^/\s]+/i,
|
|
311
|
-
/\\\\\?\\/,
|
|
312
|
-
/(?<![\w.~-])(?:\/var)?\/home\/[^/\s]+/,
|
|
313
|
-
/(?<![\w.~-])(?:\/var)?\/root(?![\w.-])/,
|
|
314
|
-
/(?<![\w.~-])\/Users\/[^/\s]+/,
|
|
315
|
-
/[\\/][A-Z0-9_$]{1,6}~\d{1,6}(?:\.[A-Z0-9]{1,3})?(?![\w~])|\b[A-Z0-9_$]{1,6}~\d{1,6}(?:\.[A-Z0-9]{1,3})?[\\/]/,
|
|
316
|
-
];
|
|
317
307
|
const ABSOLUTE_PATH = /(?<![\w.~/\\:-])(?:\\\\\?\\)?(?:[A-Za-z]:[\\/]|\/)[^\s`'"<>|*?()[\]{},;]+/g;
|
|
318
308
|
function rewriteRepoPaths(sentence, repoRoot) {
|
|
319
309
|
return sentence.replace(ABSOLUTE_PATH, (token) => {
|
|
@@ -369,7 +359,7 @@ function proseText(text) {
|
|
|
369
359
|
export function digestSentences(text) {
|
|
370
360
|
const held = [];
|
|
371
361
|
const masked = proseText(text).replace(PROTECTED, (m) => `\uE000${held.push(m) - 1}\uE001`);
|
|
372
|
-
return
|
|
362
|
+
return masked.split(/(?<=[.!?])\s+|\n/).filter((s) => s.trim().length > 5)
|
|
373
363
|
.map((s) => s.replace(/\uE000(\d+)\uE001/g, (_m, i) => held[Number(i)]).trim())
|
|
374
364
|
.filter(Boolean);
|
|
375
365
|
}
|
|
@@ -410,6 +400,7 @@ function rankedSentences(finalText, windows, repoRoot) {
|
|
|
410
400
|
if (/[?:]$/.test(sentence) || opensOnReferent(sentence) || echoes(sentence, windows))
|
|
411
401
|
return;
|
|
412
402
|
const text = rewriteRepoPaths(sentence, repoRoot);
|
|
403
|
+
// A home-directory path names its user and the store may be shared, so such a sentence is dropped.
|
|
413
404
|
if (text.length > MAX_SENTENCE_CHARS || USER_SEGMENT.some((re) => re.test(text)))
|
|
414
405
|
return;
|
|
415
406
|
kept.push({ index, text, score: sentenceScore(text) });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Context } from './api/types.js';
|
|
2
|
+
import { type DatabaseSyncLike } from './db.js';
|
|
3
|
+
/** Binds under the write lock and returns the stored owner, which is not `owner` when a concurrent first bind won. */
|
|
4
|
+
export declare function insertBinding(db: DatabaseSyncLike, tenantId: string, sessionId: string, owner: string): string | null;
|
|
5
|
+
/** Binds `sessionId` to the caller's owner on first use; a session another owner holds is a 409, so the client can set it aside. */
|
|
6
|
+
export declare function bindSessionOwner(ctx: Context, sessionId: string): void;
|
|
7
|
+
//# sourceMappingURL=session-owners.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A session id belongs to the first owner that binds it, so one developer's client cannot write into another's session.
|
|
2
|
+
import { ConflictError } from './api-errors.js';
|
|
3
|
+
import { ownerOrSubject } from './api/types.js';
|
|
4
|
+
import { closeHippoDb, execWithBusyRetry, HOOK_DB_WAIT_MS, openHippoDb, scopedBusyWait } from './db.js';
|
|
5
|
+
import { raiseMinBinary } from './db/meta.js';
|
|
6
|
+
import { assertTenantId } from './tenant.js';
|
|
7
|
+
import { TASK_OWNER_MIN_BINARY } from './version.js';
|
|
8
|
+
/** Bindings older than this are pruned when a new session binds. */
|
|
9
|
+
const BINDING_RETENTION_DAYS = 90;
|
|
10
|
+
function boundOwner(db, tenantId, sessionId) {
|
|
11
|
+
// SAFETY: the SELECT names exactly this one TEXT column.
|
|
12
|
+
const row = db.prepare(`SELECT owner_subject FROM session_owners WHERE tenant_id = ? AND session_id = ?`).get(tenantId, sessionId);
|
|
13
|
+
return row?.owner_subject ?? null;
|
|
14
|
+
}
|
|
15
|
+
/** Binds under the write lock and returns the stored owner, which is not `owner` when a concurrent first bind won. */
|
|
16
|
+
export function insertBinding(db, tenantId, sessionId, owner) {
|
|
17
|
+
execWithBusyRetry(db, 'BEGIN IMMEDIATE', scopedBusyWait() ?? HOOK_DB_WAIT_MS);
|
|
18
|
+
try {
|
|
19
|
+
const result = db.prepare(`INSERT OR IGNORE INTO session_owners(tenant_id, session_id, owner_subject, created_at) VALUES (?, ?, ?, ?)`)
|
|
20
|
+
.run(tenantId, sessionId, owner, new Date().toISOString());
|
|
21
|
+
// An older binary supersedes across owners, so the first binding shuts it out in the same transaction.
|
|
22
|
+
if (Number(result.changes ?? 0) === 1)
|
|
23
|
+
raiseMinBinary(db, TASK_OWNER_MIN_BINARY);
|
|
24
|
+
// Pruned on write, as the failure log is; a pruned session id is a UUID no other caller knows.
|
|
25
|
+
// SHORTCUT: no created_at index, so each first bind scans the table; add one past a few hundred thousand rows.
|
|
26
|
+
db.prepare(`DELETE FROM session_owners WHERE created_at < ?`).run(new Date(Date.now() - BINDING_RETENTION_DAYS * 86_400_000).toISOString());
|
|
27
|
+
db.exec('COMMIT');
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
try {
|
|
31
|
+
db.exec('ROLLBACK');
|
|
32
|
+
}
|
|
33
|
+
catch { /* the insert's error is the one to surface */ }
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
// Re-read, since a concurrent first bind may have won the INSERT OR IGNORE.
|
|
37
|
+
return boundOwner(db, tenantId, sessionId);
|
|
38
|
+
}
|
|
39
|
+
/** Binds `sessionId` to the caller's owner on first use; a session another owner holds is a 409, so the client can set it aside. */
|
|
40
|
+
export function bindSessionOwner(ctx, sessionId) {
|
|
41
|
+
assertTenantId('bindSessionOwner', ctx.tenantId);
|
|
42
|
+
const owner = ownerOrSubject(ctx.actor);
|
|
43
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
44
|
+
try {
|
|
45
|
+
// A bound session is the common case after the first prompt, so it must not take the write lock.
|
|
46
|
+
const bound = boundOwner(db, ctx.tenantId, sessionId) ?? insertBinding(db, ctx.tenantId, sessionId, owner);
|
|
47
|
+
if (bound !== owner)
|
|
48
|
+
throw new ConflictError('session id belongs to another caller');
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
closeHippoDb(db);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=session-owners.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The scrub for transcript text that leaves the machine it was typed on.
|
|
2
|
+
import { maskHomePaths } from './home-path.js';
|
|
3
|
+
import { maskEmails, redactSecretsStrict } from './secret-detect.js';
|
|
4
|
+
/** Secrets first, then emails, then home paths: a home-path mask could split a secret so that no pattern matches it. */
|
|
5
|
+
export function scrubForSharing(text) {
|
|
6
|
+
return maskHomePaths(maskEmails(redactSecretsStrict(text)));
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=share-scrub.js.map
|