@danypops/papyrus 0.16.0 → 0.17.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/extension/src/domain-tools.ts +7 -1
- package/extension/src/index.ts +27 -2
- package/extension/src/session-identity.ts +22 -0
- package/extension/src/tasks.ts +4 -3
- package/package.json +3 -2
- package/src/adapters/sqlite-session-identity-store.ts +46 -0
- package/src/cli.ts +53 -8
- package/src/constants.ts +3 -1
- package/src/db.ts +23 -0
- package/src/domain/session-identity.ts +50 -0
- package/src/modules/session-identity.ts +38 -0
- package/src/modules/tasks.ts +13 -5
- package/src/ports/session-identity-store.ts +9 -0
- package/src/service.ts +11 -2
- package/src/session-identity-service.ts +55 -0
|
@@ -8,6 +8,7 @@ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
|
8
8
|
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
9
9
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
10
10
|
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
11
|
+
import { sessionSecretField } from "./session-identity.ts";
|
|
11
12
|
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
12
13
|
import { callService } from "./service-client.ts";
|
|
13
14
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
@@ -77,7 +78,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
77
78
|
const action = params.action;
|
|
78
79
|
// Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
|
|
79
80
|
// without depending on the model to know or supply its own session identity.
|
|
80
|
-
|
|
81
|
+
// session_secret is looked up by the resolved session_id itself (not blindly the
|
|
82
|
+
// current session's), so a model that explicitly overrides session_id to a DIFFERENT
|
|
83
|
+
// session never gets this session's secret smuggled in on its behalf -- the cache only
|
|
84
|
+
// ever holds this extension's own registered session anyway (see session-identity.ts).
|
|
85
|
+
const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
|
|
86
|
+
const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
|
|
81
87
|
if (action === "create") {
|
|
82
88
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
83
89
|
return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
|
package/extension/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, comp
|
|
|
31
31
|
import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
|
|
32
32
|
import { showContextView } from "./context-view.ts";
|
|
33
33
|
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
34
|
+
import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
|
|
34
35
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
35
36
|
import {
|
|
36
37
|
createArtifactDetails,
|
|
@@ -197,6 +198,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
197
198
|
source: "task-continuation",
|
|
198
199
|
reason: automaticPauseReason(decision.reason),
|
|
199
200
|
session_id: sessionId,
|
|
201
|
+
...sessionSecretField(sessionId),
|
|
200
202
|
});
|
|
201
203
|
emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
|
|
202
204
|
if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
|
|
@@ -499,6 +501,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
499
501
|
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
500
502
|
|
|
501
503
|
pi.on("session_start", async (_event, ctx) => {
|
|
504
|
+
// Registers this session's identity with the daemon as early as possible -- before any
|
|
505
|
+
// Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
|
|
506
|
+
// domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
|
|
507
|
+
// unavailable during startup, and every other Focus-mutating call already tolerates an
|
|
508
|
+
// unregistered/never-armored session_id (opt-in armor), so a missed registration here is
|
|
509
|
+
// not worth surfacing to the user.
|
|
510
|
+
try {
|
|
511
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
512
|
+
const { secret } = await callService<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
|
|
513
|
+
cacheSessionSecret(sessionId, secret);
|
|
514
|
+
} catch {
|
|
515
|
+
// intentionally silent -- see comment above
|
|
516
|
+
}
|
|
502
517
|
if (!ctx.hasUI) return;
|
|
503
518
|
overlay ??= new TaskOverlay();
|
|
504
519
|
overlay.setUI(ctx.ui);
|
|
@@ -510,7 +525,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
510
525
|
pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
|
|
511
526
|
pi.on("session_compact", async () => { await overlay?.refresh(); });
|
|
512
527
|
pi.on("session_tree", async () => { await overlay?.refresh(); });
|
|
513
|
-
pi.on("session_shutdown", async () => {
|
|
528
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
529
|
+
overlay?.dispose();
|
|
530
|
+
overlay = undefined;
|
|
531
|
+
try {
|
|
532
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
533
|
+
await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
534
|
+
forgetSessionSecret(sessionId);
|
|
535
|
+
} catch {
|
|
536
|
+
// intentionally silent -- see session_start's comment above
|
|
537
|
+
}
|
|
538
|
+
});
|
|
514
539
|
|
|
515
540
|
// Update widget after any papyrus tool call
|
|
516
541
|
pi.on("tool_execution_end", async (event) => {
|
|
@@ -530,7 +555,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
530
555
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
531
556
|
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
|
|
532
557
|
if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
|
|
533
|
-
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId });
|
|
558
|
+
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
534
559
|
emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
|
|
535
560
|
}
|
|
536
561
|
} catch {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side cache for the extension's own session_secret, registered with the daemon at
|
|
3
|
+
* session_start and released at session_shutdown (see index.ts). Keyed by sessionId (not a
|
|
4
|
+
* single "current" variable) defensively -- multiple call sites reference an explicit
|
|
5
|
+
* sessionId already, and a Map costs nothing extra for real correctness. See
|
|
6
|
+
* src/domain/session-identity.ts (daemon side) for the full design rationale.
|
|
7
|
+
*/
|
|
8
|
+
const secretsBySessionId = new Map<string, string>();
|
|
9
|
+
|
|
10
|
+
export function cacheSessionSecret(sessionId: string, secret: string): void {
|
|
11
|
+
secretsBySessionId.set(sessionId, secret);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function forgetSessionSecret(sessionId: string): void {
|
|
15
|
+
secretsBySessionId.delete(sessionId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Spread into any Focus-mutating request body alongside session_id -- empty object when no secret is cached for this sessionId (unregistered, or registration hasn't completed yet), matching the daemon's opt-in-armor default. */
|
|
19
|
+
export function sessionSecretField(sessionId: string | undefined): { session_secret?: string } {
|
|
20
|
+
const secret = sessionId ? secretsBySessionId.get(sessionId) : undefined;
|
|
21
|
+
return secret ? { session_secret: secret } : {};
|
|
22
|
+
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
|
8
8
|
import { Container, Input, Spacer, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import { callService } from "./service-client.ts";
|
|
10
10
|
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
11
|
+
import { sessionSecretField } from "./session-identity.ts";
|
|
11
12
|
import { showTaskDetails } from "./task-detail-view.ts";
|
|
12
13
|
import { showTaskGraph } from "./task-graph.ts";
|
|
13
14
|
|
|
@@ -175,7 +176,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
175
176
|
}
|
|
176
177
|
} else if (choice === "Make active") {
|
|
177
178
|
try {
|
|
178
|
-
const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
179
|
+
const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
179
180
|
emitTaskFocusEvent({ taskId: focused.id, sessionId, status: "focused" });
|
|
180
181
|
ctx.ui.notify(`Active: ${action.row.title}`, "info");
|
|
181
182
|
} catch (error) {
|
|
@@ -184,11 +185,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
184
185
|
} else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
|
|
185
186
|
try {
|
|
186
187
|
if (choice === "Clear focus") {
|
|
187
|
-
await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
188
|
+
await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
188
189
|
emitTaskFocusEvent({ taskId: null, sessionId, status: "cleared" });
|
|
189
190
|
} else {
|
|
190
191
|
const operation = choice === "Pause focus" ? "tasks.pause" : "tasks.unpause";
|
|
191
|
-
const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
192
|
+
const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
192
193
|
emitTaskFocusEvent({ taskId: result.artifact.id, sessionId, status: choice === "Pause focus" ? "paused" : "unpaused" });
|
|
193
194
|
}
|
|
194
195
|
ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"files": ["src", "extension", "README.md"],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"beautiful-mermaid": "1.1.3"
|
|
44
|
+
"beautiful-mermaid": "1.1.3",
|
|
45
|
+
"@danypops/daemon-kit": "^0.2.0"
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SESSION_IDENTITY_MAX_ROWS } from "../constants.ts";
|
|
2
|
+
import type { Db } from "../db.ts";
|
|
3
|
+
import { inTransaction } from "../db.ts";
|
|
4
|
+
import type { SessionIdentityRecord, SessionIdentityStore } from "../ports/session-identity-store.ts";
|
|
5
|
+
|
|
6
|
+
export class SQLiteSessionIdentityStore implements SessionIdentityStore {
|
|
7
|
+
constructor(private readonly db: Db) {}
|
|
8
|
+
|
|
9
|
+
find(sessionId: string): SessionIdentityRecord | undefined {
|
|
10
|
+
const row = this.db.prepare("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = ?").get(sessionId) as
|
|
11
|
+
| { session_id: string; secret_hash: string; registered_at: string; last_seen_at: string }
|
|
12
|
+
| null;
|
|
13
|
+
return row ? { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at } : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
upsert(record: SessionIdentityRecord): void {
|
|
17
|
+
inTransaction(this.db, () => {
|
|
18
|
+
this.evictOldestBeyondCap(record.sessionId);
|
|
19
|
+
this.db.prepare(`
|
|
20
|
+
INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
|
|
21
|
+
VALUES (?, ?, ?, ?)
|
|
22
|
+
ON CONFLICT(session_id) DO UPDATE SET secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at
|
|
23
|
+
`).run(record.sessionId, record.secretHash, record.registeredAt, record.lastSeenAt);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
remove(sessionId: string): void {
|
|
28
|
+
this.db.prepare("DELETE FROM session_identities WHERE session_id = ?").run(sessionId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
touch(sessionId: string, lastSeenAt: string): void {
|
|
32
|
+
this.db.prepare("UPDATE session_identities SET last_seen_at = ? WHERE session_id = ?").run(lastSeenAt, sessionId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
count(): number {
|
|
36
|
+
return (this.db.prepare("SELECT COUNT(*) AS count FROM session_identities").get() as { count: number }).count;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bounds distinct registered session identities; evicts the least-recently-seen beyond the cap. Mirrors SQLiteTaskFocusStore.evictOldestBeyondCap exactly. */
|
|
40
|
+
private evictOldestBeyondCap(sessionId: string): void {
|
|
41
|
+
const exists = this.db.prepare("SELECT 1 FROM session_identities WHERE session_id = ?").get(sessionId);
|
|
42
|
+
if (exists) return;
|
|
43
|
+
if (this.count() < SESSION_IDENTITY_MAX_ROWS) return;
|
|
44
|
+
this.db.exec("DELETE FROM session_identities WHERE session_id = (SELECT session_id FROM session_identities ORDER BY last_seen_at ASC LIMIT 1)");
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -112,18 +112,20 @@ const USAGE = `Usage:
|
|
|
112
112
|
papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
|
|
113
113
|
papyrus notes archive <id> <completed|duplicate|declined|superseded> [--reason <reason>] [--json]
|
|
114
114
|
papyrus log append --source <id> --level <debug|info|warning|error> --message <text> --operation-id <id> [--source-label <text>] [--fields-json <json>] [--session-id <id>] [--occurred-at <iso>] [--global] [--json]
|
|
115
|
+
papyrus session register --session-id <id> [--json]
|
|
116
|
+
papyrus session release --session-id <id> [--session-secret <secret>] [--json]
|
|
115
117
|
papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
|
|
116
118
|
papyrus tasks plan [--session-id <id>] [--json]
|
|
117
119
|
papyrus tasks graph [--session-id <id>] [--json]
|
|
118
120
|
papyrus tasks active [--session-id <id>] [--json]
|
|
119
121
|
papyrus tasks focused [--session-id <id>] [--json]
|
|
120
|
-
papyrus tasks pause [--session-id <id>] [--json]
|
|
121
|
-
papyrus tasks unpause [--session-id <id>] [--json]
|
|
122
|
-
papyrus tasks clear-focus [--session-id <id>] [--json]
|
|
122
|
+
papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
|
|
123
|
+
papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
|
|
124
|
+
papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
|
|
123
125
|
papyrus tasks history <id> [--json]
|
|
124
126
|
papyrus tasks scope [project|all|graph <root-id>] [--json]
|
|
125
127
|
papyrus tasks assign-project <id> [project-root] [--json]
|
|
126
|
-
papyrus tasks focus <id> [--session-id <id>] [--json]
|
|
128
|
+
papyrus tasks focus <id> [--session-id <id>] [--session-secret <secret>] [--json]
|
|
127
129
|
papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
|
|
128
130
|
papyrus tasks complete <id> [--session-id <id>] [--json]
|
|
129
131
|
papyrus tasks start <id> [--session-id <id>] [--json]
|
|
@@ -142,7 +144,8 @@ const USAGE = `Usage:
|
|
|
142
144
|
papyrus tasks set-checklist <id> --checklist-json <json> [--json]
|
|
143
145
|
papyrus tasks context [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
|
|
144
146
|
|
|
145
|
-
A "--session-id" scopes Task Focus to one agent session; omit it to use the shared "global" Focus (today's behavior)
|
|
147
|
+
A "--session-id" scopes Task Focus to one agent session; omit it to use the shared "global" Focus (today's behavior).
|
|
148
|
+
Once a session id is registered ("papyrus session register"), mutating its Focus (focus/pause/unpause/clear-focus) requires the matching "--session-secret"; an unregistered session id is unaffected.`;
|
|
146
149
|
|
|
147
150
|
function usage(): never {
|
|
148
151
|
console.error(USAGE);
|
|
@@ -909,6 +912,33 @@ export async function runLogCli(args: string[], client: TaskCliClient, projectRo
|
|
|
909
912
|
throw new Error("log action must be append or query");
|
|
910
913
|
}
|
|
911
914
|
|
|
915
|
+
export async function runSessionIdentityCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
916
|
+
const json = args.includes("--json");
|
|
917
|
+
const positional: string[] = [];
|
|
918
|
+
let sessionId: string | undefined;
|
|
919
|
+
let secret: string | undefined;
|
|
920
|
+
for (let index = 0; index < args.length; index++) {
|
|
921
|
+
const argument = args[index]!;
|
|
922
|
+
if (argument === "--json") continue;
|
|
923
|
+
if (argument === "--session-id") { sessionId = args[++index]; continue; }
|
|
924
|
+
if (argument === "--session-secret") { secret = args[++index]; continue; }
|
|
925
|
+
if (argument.startsWith("--")) throw new Error(`unknown session option ${argument}`);
|
|
926
|
+
positional.push(argument);
|
|
927
|
+
}
|
|
928
|
+
const [action] = positional;
|
|
929
|
+
if (action === "register") {
|
|
930
|
+
if (!sessionId) throw new Error("session register requires --session-id");
|
|
931
|
+
const result = await client.call<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
|
|
932
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
933
|
+
}
|
|
934
|
+
if (action === "release") {
|
|
935
|
+
if (!sessionId) throw new Error("session release requires --session-id");
|
|
936
|
+
const result = await client.call<Record<string, unknown>, { released: boolean }>("session.release", { session_id: sessionId, ...(secret ? { session_secret: secret } : {}) });
|
|
937
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
938
|
+
}
|
|
939
|
+
throw new Error("session action must be register or release");
|
|
940
|
+
}
|
|
941
|
+
|
|
912
942
|
export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
913
943
|
const json = args.includes("--json");
|
|
914
944
|
const positional: string[] = [];
|
|
@@ -975,6 +1005,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
975
1005
|
const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
|
|
976
1006
|
let reason: string | undefined;
|
|
977
1007
|
let sessionId: string | undefined;
|
|
1008
|
+
let sessionSecret: string | undefined;
|
|
978
1009
|
let title: string | undefined;
|
|
979
1010
|
let body: string | undefined;
|
|
980
1011
|
let status: string | undefined;
|
|
@@ -997,6 +1028,11 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
997
1028
|
if (!sessionId) throw new Error("--session-id requires a value");
|
|
998
1029
|
continue;
|
|
999
1030
|
}
|
|
1031
|
+
if (argument === "--session-secret") {
|
|
1032
|
+
sessionSecret = args[++index];
|
|
1033
|
+
if (!sessionSecret) throw new Error("--session-secret requires a value");
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1000
1036
|
if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason") {
|
|
1001
1037
|
const value = args[++index];
|
|
1002
1038
|
if (value === undefined) throw new Error(`${argument} requires a value`);
|
|
@@ -1046,6 +1082,10 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
1046
1082
|
const reasonSupportedActions = new Set(["update", "depend", "undepend", "contain", "uncontain"]);
|
|
1047
1083
|
if (reason !== undefined && !reasonSupportedActions.has(action ?? "")) throw new Error("--reason is only supported by tasks update, depend, undepend, contain, and uncontain");
|
|
1048
1084
|
const sessionScope = sessionId ? { session_id: sessionId } : {};
|
|
1085
|
+
// Only meaningful alongside a registered session_id (see session.register); required by the
|
|
1086
|
+
// daemon only for the specific Focus-mutating operations below (focus/pause/unpause/clear_focus)
|
|
1087
|
+
// once that session_id has been armored (see session-identity-service.ts assertAuthorized).
|
|
1088
|
+
const sessionSecretField = sessionSecret ? { session_secret: sessionSecret } : {};
|
|
1049
1089
|
let result: unknown;
|
|
1050
1090
|
let human: string;
|
|
1051
1091
|
switch (action) {
|
|
@@ -1067,14 +1107,14 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
1067
1107
|
case "unpause": {
|
|
1068
1108
|
if (id) throw new Error(`tasks ${action} accepts no positional arguments`);
|
|
1069
1109
|
const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
|
|
1070
|
-
const focus = await client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli", ...sessionScope });
|
|
1110
|
+
const focus = await client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
|
|
1071
1111
|
result = focus;
|
|
1072
1112
|
human = `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`;
|
|
1073
1113
|
break;
|
|
1074
1114
|
}
|
|
1075
1115
|
case "clear-focus": {
|
|
1076
1116
|
if (id) throw new Error("tasks clear-focus accepts no positional arguments");
|
|
1077
|
-
const cleared = await client.call<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli", ...sessionScope });
|
|
1117
|
+
const cleared = await client.call<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
|
|
1078
1118
|
result = cleared;
|
|
1079
1119
|
human = cleared.cleared ? "Task focus cleared." : "No focused task.";
|
|
1080
1120
|
break;
|
|
@@ -1206,7 +1246,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
1206
1246
|
}
|
|
1207
1247
|
case "focus": {
|
|
1208
1248
|
if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
|
|
1209
|
-
const active = await client.call<Record<string, unknown>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli", ...sessionScope });
|
|
1249
|
+
const active = await client.call<Record<string, unknown>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
|
|
1210
1250
|
result = active;
|
|
1211
1251
|
human = `Active: ${artifactLabel(active)}`;
|
|
1212
1252
|
break;
|
|
@@ -1308,6 +1348,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
1308
1348
|
console.log(await runLogCli(args.slice(1), client));
|
|
1309
1349
|
return;
|
|
1310
1350
|
}
|
|
1351
|
+
if (command === "session") {
|
|
1352
|
+
const client = await connectPapyrusClient();
|
|
1353
|
+
console.log(await runSessionIdentityCli(args.slice(1), client));
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1311
1356
|
if (command === "migrate") {
|
|
1312
1357
|
const client = await connectPapyrusClient();
|
|
1313
1358
|
console.log(await runMigrationCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 13;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -146,6 +146,8 @@ export const TASK_FOCUS_DEFAULT_SCOPE = "global";
|
|
|
146
146
|
export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
|
|
147
147
|
/** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
|
|
148
148
|
export const TASK_FOCUS_MAX_SCOPES = 500;
|
|
149
|
+
/** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
|
|
150
|
+
export const SESSION_IDENTITY_MAX_ROWS = 2_000;
|
|
149
151
|
/** Persisted project and focused-graph Task view bounds. */
|
|
150
152
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
151
153
|
/** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
|
package/src/db.ts
CHANGED
|
@@ -210,6 +210,12 @@ CREATE TABLE IF NOT EXISTS log_entries (
|
|
|
210
210
|
CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
|
|
211
211
|
CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
|
|
212
212
|
BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
|
|
213
|
+
CREATE TABLE IF NOT EXISTS session_identities (
|
|
214
|
+
session_id TEXT PRIMARY KEY,
|
|
215
|
+
secret_hash TEXT NOT NULL,
|
|
216
|
+
registered_at TEXT NOT NULL,
|
|
217
|
+
last_seen_at TEXT NOT NULL
|
|
218
|
+
);
|
|
213
219
|
`;
|
|
214
220
|
|
|
215
221
|
const SEED_SQL = `
|
|
@@ -287,6 +293,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
|
|
|
287
293
|
{ version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
|
|
288
294
|
{ version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
|
|
289
295
|
{ version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
|
|
296
|
+
{ version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
|
|
290
297
|
];
|
|
291
298
|
|
|
292
299
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
@@ -637,6 +644,22 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
637
644
|
`);
|
|
638
645
|
applied.push("remove-discourse");
|
|
639
646
|
}
|
|
647
|
+
if (schemaVersion(db) === 12) {
|
|
648
|
+
// See domain/session-identity.ts and verify-caller-identity-behind-papyrus-mutation-
|
|
649
|
+
// attribution-koxt: first-touch capability binding for session_id, the one place it is
|
|
650
|
+
// behavior-affecting today (Task Focus). Purely additive -- a session_id that never
|
|
651
|
+
// registers here behaves exactly as before.
|
|
652
|
+
db.exec(`
|
|
653
|
+
CREATE TABLE IF NOT EXISTS session_identities (
|
|
654
|
+
session_id TEXT PRIMARY KEY,
|
|
655
|
+
secret_hash TEXT NOT NULL,
|
|
656
|
+
registered_at TEXT NOT NULL,
|
|
657
|
+
last_seen_at TEXT NOT NULL
|
|
658
|
+
);
|
|
659
|
+
PRAGMA user_version = 13;
|
|
660
|
+
`);
|
|
661
|
+
applied.push("session-identity");
|
|
662
|
+
}
|
|
640
663
|
if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
641
664
|
});
|
|
642
665
|
if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain/session-identity.ts — closes the gap flagged by
|
|
3
|
+
* verify-caller-identity-behind-papyrus-mutation-attribution-koxt: session_id was, until now,
|
|
4
|
+
* pure caller-supplied free text at the RPC boundary, yet scope-task-focus-and-its-
|
|
5
|
+
* tuicontext-injection-to-the-request-8d5n made it load-bearing for *behavior* (Task Focus
|
|
6
|
+
* is keyed by session_id) -- a forged session_id can pause/unpause/clear/redirect a live
|
|
7
|
+
* session's Focus, not merely mislabel history.
|
|
8
|
+
*
|
|
9
|
+
* The real constraint this design works within: Papyrus's daemon authenticates every client
|
|
10
|
+
* with ONE shared bearer token per machine (src/daemon-state.ts) -- every authenticated
|
|
11
|
+
* caller looks identical to the daemon. Checked directly against Bun's public server API:
|
|
12
|
+
* no SO_PEERCRED-equivalent exists for either a TCP or a Unix-socket listener, so real
|
|
13
|
+
* kernel-verified per-process identity is not cheaply achievable; building it would mean FFI
|
|
14
|
+
* against libc, disproportionate to this task. Pi's session ids are uuidv7 (time-ordered, not
|
|
15
|
+
* cryptographically opaque -- confirmed by reading @earendil-works/pi-coding-agent's
|
|
16
|
+
* session-manager.ts source), so they were never meant to double as secrets either.
|
|
17
|
+
*
|
|
18
|
+
* The actual cryptographic primitive (secret generation, hashing, constant-time verify,
|
|
19
|
+
* first-touch registration semantics) is NOT reimplemented here -- it lives in
|
|
20
|
+
* @danypops/daemon-kit's session-identity module. That gap (a shared bearer token cannot
|
|
21
|
+
* distinguish callers; a session id needs a real credential once it becomes behavior-
|
|
22
|
+
* affecting) is generic to every daemon-kit-shaped daemon, not Papyrus-specific -- Papyrus's
|
|
23
|
+
* own daemon.ts/service.ts predates daemon-kit and has not migrated onto it, but this one
|
|
24
|
+
* capability is adopted narrowly regardless (daemon-kit's exports map is designed for
|
|
25
|
+
* exactly this: "a consumer only pulls in what it uses"). This file only wires that generic
|
|
26
|
+
* primitive to Papyrus's own SQLite storage (see adapters/sqlite-session-identity-store.ts)
|
|
27
|
+
* and to Task Focus specifically, the one place session_id is behavior-affecting today.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately "opt-in armor", not a breaking migration: a session_id that was never
|
|
30
|
+
* registered behaves exactly as before (open), so every already-open session and every
|
|
31
|
+
* caller that never calls session.register (bare CLI use of the "global" scope, older
|
|
32
|
+
* Papyrus builds, test fixtures) is unaffected. Every real Pi session becomes armored
|
|
33
|
+
* automatically the moment its extension fires session_start.
|
|
34
|
+
*
|
|
35
|
+
* Explicitly NOT solved by this: a race at first contact (whoever registers a session_id
|
|
36
|
+
* first becomes its legitimate owner) -- an attacker who wins that race before the real
|
|
37
|
+
* session ever registers still prevails. This is a real, disclosed, accepted residual limit,
|
|
38
|
+
* not oversold as "verified identity"; scribe's own prior art never solved caller identity
|
|
39
|
+
* either. Registering as soon as the extension's session_start hook fires (before any
|
|
40
|
+
* Focus-mutating call could plausibly happen) shrinks this window to something small, not
|
|
41
|
+
* zero.
|
|
42
|
+
*
|
|
43
|
+
* actor/source free-text fields are explicitly NOT in scope here -- they remain audit-trail
|
|
44
|
+
* labeling only, never a security boundary, matching the conclusion already reached when this
|
|
45
|
+
* task was deferred from add-a-generic-mutation-event-log-to-the-papyrus-artifactstor-at6h.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export function assertValidSessionId(sessionId: string): void {
|
|
49
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("session_id is required");
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/session-identity.ts — the session-identity domain as a registered Papyrus-native
|
|
3
|
+
* module. Deliberately self-contained: no artifact/task infrastructure imports, matching the
|
|
4
|
+
* precedent set by modules/logs.ts. See domain/session-identity.ts for the design rationale.
|
|
5
|
+
*/
|
|
6
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
7
|
+
import type { SessionIdentity } from "../session-identity-service.ts";
|
|
8
|
+
|
|
9
|
+
const MODULE_ID = "session-identity";
|
|
10
|
+
|
|
11
|
+
type OperationInput = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
function string(input: OperationInput, key: string): string {
|
|
14
|
+
const value = input[key];
|
|
15
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
20
|
+
const value = input[key];
|
|
21
|
+
if (value === undefined) return undefined;
|
|
22
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
27
|
+
export const SESSION_IDENTITY_OPERATION_NAMES = ["session.register", "session.release"] as const;
|
|
28
|
+
|
|
29
|
+
/** Registers every session.* operation against one SessionIdentity instance. */
|
|
30
|
+
export function sessionIdentityOperations(sessionIdentity: SessionIdentity): OperationDefinition[] {
|
|
31
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
32
|
+
name, moduleId: MODULE_ID, execute,
|
|
33
|
+
});
|
|
34
|
+
return [
|
|
35
|
+
define("session.register", (input: OperationInput) => sessionIdentity.register(string(input, "session_id"))),
|
|
36
|
+
define("session.release", (input: OperationInput) => sessionIdentity.release(string(input, "session_id"), optionalString(input, "session_secret"))),
|
|
37
|
+
];
|
|
38
|
+
}
|
package/src/modules/tasks.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { TaskEventContext, TaskEventDirection } from "../domain/task-event.
|
|
|
23
23
|
import type { TaskViewMode } from "../domain/task-scope.ts";
|
|
24
24
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
25
25
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
26
|
+
import type { SessionIdentity } from "../session-identity-service.ts";
|
|
26
27
|
import { taskContext } from "../task-context.ts";
|
|
27
28
|
import { projectTaskExecution } from "../task-execution.ts";
|
|
28
29
|
import { Tasks, type TaskStatus } from "../task-service.ts";
|
|
@@ -92,10 +93,17 @@ export const TASKS_OPERATION_NAMES = [
|
|
|
92
93
|
"tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain",
|
|
93
94
|
] as const;
|
|
94
95
|
|
|
95
|
-
export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): OperationDefinition[] {
|
|
96
|
+
export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
|
|
96
97
|
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
97
98
|
name, moduleId: MODULE_ID, execute,
|
|
98
99
|
});
|
|
100
|
+
// Enforced only for the operations where session_id is BEHAVIOR-affecting (it selects
|
|
101
|
+
// which Task Focus row is mutated), not for every session_id-carrying operation -- see
|
|
102
|
+
// domain/session-identity.ts. A session_id with no registered identity passes through
|
|
103
|
+
// unchanged (opt-in armor).
|
|
104
|
+
const guardFocusMutation = (input: OperationInput): void => {
|
|
105
|
+
sessionIdentity.assertAuthorized(eventContext(input).sessionId, optionalString(input, "session_secret"));
|
|
106
|
+
};
|
|
99
107
|
return [
|
|
100
108
|
define("tasks.create", (input: OperationInput) => tasks.create({
|
|
101
109
|
title: string(input, "title"),
|
|
@@ -139,10 +147,10 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): Operati
|
|
|
139
147
|
)),
|
|
140
148
|
define("tasks.active", (input: OperationInput) => tasks.active(taskFilter(input))),
|
|
141
149
|
define("tasks.focused", (input: OperationInput) => tasks.focused(taskFilter(input))),
|
|
142
|
-
define("tasks.focus", (input: OperationInput) => tasks.focus(string(input, "id"), eventContext(input))),
|
|
143
|
-
define("tasks.pause", (input: OperationInput) => tasks.pauseFocus(eventContext(input))),
|
|
144
|
-
define("tasks.unpause", (input: OperationInput) => tasks.unpauseFocus(eventContext(input))),
|
|
145
|
-
define("tasks.clear_focus", (input: OperationInput) => tasks.clearFocus(eventContext(input))),
|
|
150
|
+
define("tasks.focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.focus(string(input, "id"), eventContext(input)); }),
|
|
151
|
+
define("tasks.pause", (input: OperationInput) => { guardFocusMutation(input); return tasks.pauseFocus(eventContext(input)); }),
|
|
152
|
+
define("tasks.unpause", (input: OperationInput) => { guardFocusMutation(input); return tasks.unpauseFocus(eventContext(input)); }),
|
|
153
|
+
define("tasks.clear_focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.clearFocus(eventContext(input)); }),
|
|
146
154
|
define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
|
|
147
155
|
define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
|
|
148
156
|
define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SessionIdentityRecord, SessionIdentityStore as DaemonKitSessionIdentityStore } from "@danypops/daemon-kit/session-identity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Papyrus's persistence port for @danypops/daemon-kit's storage-agnostic session-identity
|
|
5
|
+
* primitive -- re-exported under this project's own port naming convention (src/ports/*)
|
|
6
|
+
* rather than importing the daemon-kit interface name directly at every call site.
|
|
7
|
+
*/
|
|
8
|
+
export type SessionIdentityStore = DaemonKitSessionIdentityStore;
|
|
9
|
+
export type { SessionIdentityRecord };
|
package/src/service.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-s
|
|
|
8
8
|
import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
|
|
9
9
|
import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
|
|
10
10
|
import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
|
|
11
|
+
import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
|
|
11
12
|
import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
12
13
|
import { AuthorityRegistry, AuthorizedArtifactWriter, type AuthorityClaim } from "./authority-registry.ts";
|
|
13
14
|
import type { TaskEventContext } from "./domain/task-event.ts";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
|
|
25
26
|
import { Logs } from "./log-service.ts";
|
|
26
27
|
import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
|
|
28
|
+
import { SessionIdentity, InvalidSessionSecretError } from "./session-identity-service.ts";
|
|
27
29
|
import { OperationRegistry } from "./module-registry.ts";
|
|
28
30
|
import { docsOperations, DOCS_OPERATION_NAMES } from "./modules/docs.ts";
|
|
29
31
|
import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./modules/graph-projection.ts";
|
|
@@ -31,6 +33,7 @@ import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
|
|
|
31
33
|
import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
|
|
32
34
|
import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
|
|
33
35
|
import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
36
|
+
import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
|
|
34
37
|
import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -68,6 +71,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
68
71
|
...SKILLS_OPERATION_NAMES,
|
|
69
72
|
...GRAPH_PROJECTION_OPERATION_NAMES,
|
|
70
73
|
...LOGS_OPERATION_NAMES,
|
|
74
|
+
...SESSION_IDENTITY_OPERATION_NAMES,
|
|
71
75
|
] as const;
|
|
72
76
|
|
|
73
77
|
export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
@@ -77,6 +81,7 @@ type OperationHandler = (input: OperationInput) => unknown;
|
|
|
77
81
|
export class UnknownOperationError extends Error {}
|
|
78
82
|
export class MigrationRequiredError extends Error {}
|
|
79
83
|
export class PayloadTooLargeError extends Error {}
|
|
84
|
+
export { InvalidSessionSecretError };
|
|
80
85
|
|
|
81
86
|
function string(input: OperationInput, key: string): string {
|
|
82
87
|
const value = input[key];
|
|
@@ -366,6 +371,8 @@ function handlers(
|
|
|
366
371
|
"graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
|
|
367
372
|
"logs.append": forwardToModule("logs.append"),
|
|
368
373
|
"logs.query": forwardToModule("logs.query"),
|
|
374
|
+
"session.register": forwardToModule("session.register"),
|
|
375
|
+
"session.release": forwardToModule("session.release"),
|
|
369
376
|
};
|
|
370
377
|
}
|
|
371
378
|
|
|
@@ -381,11 +388,13 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
381
388
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
382
389
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
383
390
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
391
|
+
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
384
392
|
const authority = createAuthorityRegistry();
|
|
385
393
|
const moduleRegistry = new OperationRegistry();
|
|
386
394
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
387
395
|
moduleRegistry.registerAll(logsOperations(logs));
|
|
388
|
-
moduleRegistry.registerAll(
|
|
396
|
+
moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
|
|
397
|
+
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
389
398
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
390
399
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
391
400
|
moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
|
|
@@ -467,7 +476,7 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
|
|
|
467
476
|
}
|
|
468
477
|
return json({ result: await deps.service.execute(body.op, input as OperationInput) });
|
|
469
478
|
} catch (error) {
|
|
470
|
-
const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : 400;
|
|
479
|
+
const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : error instanceof InvalidSessionSecretError ? 403 : 400;
|
|
471
480
|
return json({ error: error instanceof Error ? error.message : String(error) }, { status });
|
|
472
481
|
}
|
|
473
482
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isSessionRegistered, registerSessionIdentity, releaseSessionIdentity, verifySessionSecret } from "@danypops/daemon-kit/session-identity";
|
|
2
|
+
import { assertValidSessionId } from "./domain/session-identity.ts";
|
|
3
|
+
import type { SessionIdentityStore } from "./ports/session-identity-store.ts";
|
|
4
|
+
|
|
5
|
+
export interface RegisterSessionIdentityResult {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
secret: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Thrown by assertSessionAuthorized when a session_id has a registered identity but the caller did not present a matching session_secret. A distinguishable type so service.ts can map it to HTTP 403, separate from generic validation's 400. */
|
|
11
|
+
export class InvalidSessionSecretError extends Error {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Thin Papyrus-side wrapper over @danypops/daemon-kit's storage-agnostic session-identity
|
|
15
|
+
* primitive: validates input shape, binds it to Papyrus's own SQLite-backed store, and
|
|
16
|
+
* exposes the exact three operations Task Focus enforcement needs (see
|
|
17
|
+
* assertAuthorizedForFocus in src/modules/tasks.ts). See domain/session-identity.ts for the
|
|
18
|
+
* full design rationale and its explicitly accepted residual limits.
|
|
19
|
+
*/
|
|
20
|
+
export class SessionIdentity {
|
|
21
|
+
constructor(private readonly store: SessionIdentityStore) {}
|
|
22
|
+
|
|
23
|
+
register(sessionId: string): RegisterSessionIdentityResult {
|
|
24
|
+
assertValidSessionId(sessionId);
|
|
25
|
+
return registerSessionIdentity(this.store, sessionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
release(sessionId: string, secret: string | undefined): { released: boolean } {
|
|
29
|
+
assertValidSessionId(sessionId);
|
|
30
|
+
const wasRegistered = isSessionRegistered(this.store, sessionId);
|
|
31
|
+
releaseSessionIdentity(this.store, sessionId, secret);
|
|
32
|
+
return { released: wasRegistered && !isSessionRegistered(this.store, sessionId) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isRegistered(sessionId: string): boolean {
|
|
36
|
+
return isSessionRegistered(this.store, sessionId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
verify(sessionId: string, secret: string | undefined): boolean {
|
|
40
|
+
return verifySessionSecret(this.store, sessionId, secret);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The enforcement point for a Focus-mutating operation: opt-in armor, matching
|
|
45
|
+
* domain/session-identity.ts's design -- a sessionId with no registered identity passes
|
|
46
|
+
* through unauthenticated exactly as before (undefined sessionId included, since that maps
|
|
47
|
+
* to Task Focus's "global" scope, never armored). Only once a sessionId is registered does
|
|
48
|
+
* a matching session_secret become mandatory.
|
|
49
|
+
*/
|
|
50
|
+
assertAuthorized(sessionId: string | undefined, secret: string | undefined): void {
|
|
51
|
+
if (sessionId === undefined) return;
|
|
52
|
+
if (!this.isRegistered(sessionId)) return;
|
|
53
|
+
if (!this.verify(sessionId, secret)) throw new InvalidSessionSecretError(`session "${sessionId}" is registered; a valid session_secret is required to mutate its Task Focus`);
|
|
54
|
+
}
|
|
55
|
+
}
|