@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.9
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.i18n.yaml +2 -2
- package/README.md +9 -4
- package/README.zh.md +9 -4
- package/lib/client.js +947 -354
- package/lib/client.js.map +1 -1
- package/lib/index.js +712 -180
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
- package/lib/types/client/board/TaskBoard.d.ts +3 -0
- package/lib/types/client/board/TaskForm.d.ts +28 -0
- package/lib/types/client/host-api.d.ts +2 -2
- package/lib/types/client/index.d.ts +10 -2
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/core/controller.d.ts +11 -2
- package/lib/types/core/freeze-snapshot.d.ts +71 -0
- package/lib/types/core/handover.d.ts +58 -0
- package/lib/types/core/tasks.d.ts +70 -1
- package/lib/types/core/use-cases/task-update.d.ts +16 -7
- package/lib/types/host-ledger.d.ts +17 -2
- package/lib/types/host-runner.d.ts +44 -17
- package/lib/types/host-service.d.ts +8 -5
- package/lib/types/index.d.ts +9 -1
- package/lib/types/protocol.d.ts +15 -2
- package/package.json +26 -17
- package/src/client/TaskBoardSettingsCard.tsx +2 -1
- package/src/client/board/EditTaskModal.tsx +19 -55
- package/src/client/board/NewTaskModal.tsx +59 -42
- package/src/client/board/TaskBoard.tsx +5 -3
- package/src/client/board/TaskCard.tsx +3 -0
- package/src/client/board/TaskDetail.tsx +54 -0
- package/src/client/board/TaskForm.tsx +111 -0
- package/src/client/board-mount.tsx +7 -1
- package/src/client/host-api.ts +5 -5
- package/src/client/index.ts +67 -7
- package/src/client/locales.ts +40 -2
- package/src/client/settings-form.ts +78 -92
- package/src/core/controller.ts +36 -16
- package/src/core/freeze-snapshot.ts +213 -0
- package/src/core/handover.ts +117 -0
- package/src/core/store.ts +48 -1
- package/src/core/tasks.ts +87 -0
- package/src/core/use-cases/task-update.ts +30 -10
- package/src/dsh-home.ts +1 -1
- package/src/host-ledger.ts +137 -59
- package/src/host-routes.ts +1 -1
- package/src/host-runner.ts +223 -83
- package/src/host-service.ts +14 -10
- package/src/index.ts +24 -10
- package/src/protocol.ts +98 -14
package/src/core/controller.ts
CHANGED
|
@@ -23,7 +23,11 @@ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '
|
|
|
23
23
|
export interface TaskBoardTransport {
|
|
24
24
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
25
25
|
state(): Promise<TaskBoardSnapshot>
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Submit one action; the optional initiator is the current DSH session id
|
|
28
|
+
* (issue #6 audit origin), asserted by the client and recorded by the Host.
|
|
29
|
+
*/
|
|
30
|
+
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
27
31
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -37,6 +41,10 @@ export interface SessionsControllerFace {
|
|
|
37
41
|
open(id: string): void
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
function currentOf(sessions: SessionsControllerFace | undefined): string | undefined {
|
|
45
|
+
return sessions?.list.getSnapshot().current
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
/** Controller dependencies (all swappable in tests). */
|
|
41
49
|
export interface ControllerDeps {
|
|
42
50
|
store: TaskStore
|
|
@@ -83,7 +91,7 @@ export interface ControllerSnapshot {
|
|
|
83
91
|
executionOptions: ExecutionOptionsSnapshot
|
|
84
92
|
pendingTaskIds: readonly string[]
|
|
85
93
|
transportError?: string
|
|
86
|
-
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'>
|
|
94
|
+
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -129,7 +137,7 @@ export class BoardController {
|
|
|
129
137
|
private readonly pendingTaskIds = new Set<string>()
|
|
130
138
|
private readonly taskQueues = new Map<string, Promise<void>>()
|
|
131
139
|
private transportError: string | undefined
|
|
132
|
-
private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'> | undefined
|
|
140
|
+
private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'> | undefined
|
|
133
141
|
private remoteSubscribed = false
|
|
134
142
|
private remoteInitialization: Promise<boolean> | undefined
|
|
135
143
|
|
|
@@ -200,17 +208,11 @@ export class BoardController {
|
|
|
200
208
|
|
|
201
209
|
openBoard(): void {
|
|
202
210
|
if (this.boardOpen) return
|
|
203
|
-
// The board stays open until the user explicitly closes it: by clicking
|
|
204
|
-
// the sidebar entry again, a sidebar session/workspace row, or a board
|
|
205
|
-
// action (openSession / close). Session-list churn (background
|
|
206
|
-
// navigation, the Host runner selecting a fresh execution session,
|
|
207
|
-
// settlement, other plugins) never evicts the board.
|
|
208
211
|
this.boardOpen = true
|
|
209
212
|
this.notify()
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
closeBoard(): void {
|
|
213
|
-
if (!this.boardOpen) return
|
|
214
216
|
this.boardOpen = false
|
|
215
217
|
this.notify()
|
|
216
218
|
}
|
|
@@ -396,6 +398,7 @@ export class BoardController {
|
|
|
396
398
|
* @param sessionId - the execution session to open.
|
|
397
399
|
*/
|
|
398
400
|
openSession(sessionId: string): void {
|
|
401
|
+
this.closeBoard()
|
|
399
402
|
this.deps.sessions.open(sessionId)
|
|
400
403
|
}
|
|
401
404
|
|
|
@@ -411,7 +414,24 @@ export class BoardController {
|
|
|
411
414
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
412
415
|
if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
|
|
413
416
|
if (this.deps.transport === undefined) return false
|
|
414
|
-
return await this.commitRemote({ kind: 'run', taskId: id }, id)
|
|
417
|
+
return await this.commitRemote({ kind: 'run', taskId: id }, id, currentOf(this.deps.sessions))
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Confirm a card's above-default permission binding through the Host
|
|
422
|
+
* (resolves the pending-confirmation transaction; no-op otherwise).
|
|
423
|
+
*/
|
|
424
|
+
async confirmPermission(id: string): Promise<boolean> {
|
|
425
|
+
const task = this.tasks.find(candidate => candidate.id === id)
|
|
426
|
+
if (task === undefined) return false
|
|
427
|
+
if (this.deps.transport === undefined) {
|
|
428
|
+
this.tasks = this.tasks.map(candidate => candidate.id === id
|
|
429
|
+
? { ...candidate, permissionConfirmedAt: this.now(), updatedAt: this.now() }
|
|
430
|
+
: candidate)
|
|
431
|
+
this.persistAndNotify()
|
|
432
|
+
return true
|
|
433
|
+
}
|
|
434
|
+
return await this.commitRemote({ kind: 'confirm-permission', taskId: id }, id)
|
|
415
435
|
}
|
|
416
436
|
|
|
417
437
|
/** Re-run a settled task through the Host (the Host replans and executes). */
|
|
@@ -419,7 +439,7 @@ export class BoardController {
|
|
|
419
439
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
420
440
|
if (task === undefined || task.archivedAt !== undefined) return
|
|
421
441
|
if (this.deps.transport === undefined) return
|
|
422
|
-
await this.commitRemote({ kind: 'rerun', taskId: id }, id)
|
|
442
|
+
await this.commitRemote({ kind: 'rerun', taskId: id }, id, currentOf(this.deps.sessions))
|
|
423
443
|
}
|
|
424
444
|
|
|
425
445
|
// --- internals ---------------------------------------------------------------
|
|
@@ -443,12 +463,12 @@ export class BoardController {
|
|
|
443
463
|
this.notify()
|
|
444
464
|
}
|
|
445
465
|
|
|
446
|
-
private async commitRemote(action: TaskBoardAction, taskId?: string): Promise<boolean> {
|
|
466
|
+
private async commitRemote(action: TaskBoardAction, taskId?: string, initiator?: string): Promise<boolean> {
|
|
447
467
|
const transport = this.deps.transport
|
|
448
468
|
if (transport === undefined) return true
|
|
449
|
-
if (taskId === undefined) return await this.performRemote(action)
|
|
469
|
+
if (taskId === undefined) return await this.performRemote(action, initiator)
|
|
450
470
|
const previous = this.taskQueues.get(taskId) ?? Promise.resolve()
|
|
451
|
-
const operation = previous.catch(() => {}).then(async () => await this.performRemote(action))
|
|
471
|
+
const operation = previous.catch(() => {}).then(async () => await this.performRemote(action, initiator))
|
|
452
472
|
const tail = operation.then(() => {}, () => {})
|
|
453
473
|
this.taskQueues.set(taskId, tail)
|
|
454
474
|
this.pendingTaskIds.add(taskId)
|
|
@@ -464,13 +484,13 @@ export class BoardController {
|
|
|
464
484
|
}
|
|
465
485
|
}
|
|
466
486
|
|
|
467
|
-
private async performRemote(action: TaskBoardAction): Promise<boolean> {
|
|
487
|
+
private async performRemote(action: TaskBoardAction, initiator?: string): Promise<boolean> {
|
|
468
488
|
const transport = this.deps.transport
|
|
469
489
|
if (transport === undefined) return true
|
|
470
490
|
this.transportError = undefined
|
|
471
491
|
this.notify()
|
|
472
492
|
try {
|
|
473
|
-
const accepted = this.acceptRemote(await transport.action(action))
|
|
493
|
+
const accepted = this.acceptRemote(await transport.action(action, initiator))
|
|
474
494
|
return accepted || await this.refreshRemote()
|
|
475
495
|
} catch (error) {
|
|
476
496
|
await this.refreshRemote(messageOf(error))
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freeze-request parser and security gate: parses the system-prompt-agreed
|
|
3
|
+
* freeze format into a structured context snapshot (goal / progress / next),
|
|
4
|
+
* redacts sensitive patterns to markers, rejects slash-prefixed DSH command
|
|
5
|
+
* lines, and enforces the 8 KiB per-field byte limit.
|
|
6
|
+
* Pure functions only: no session, network, or filesystem access.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Hard per-field limit: 8 KiB measured in UTF-8 bytes. */
|
|
10
|
+
export const FREEZE_FIELD_BYTE_LIMIT = 8 * 1024
|
|
11
|
+
|
|
12
|
+
/** Marker replacing every sensitive match. */
|
|
13
|
+
export const REDACTED_MARKER = '[REDACTED]'
|
|
14
|
+
|
|
15
|
+
/** Structured context snapshot carried by a continuation card. */
|
|
16
|
+
export interface FreezeSnapshot {
|
|
17
|
+
goal: string
|
|
18
|
+
progress: string
|
|
19
|
+
next: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Non-fatal notices produced by the security gate. */
|
|
23
|
+
export type FreezeWarning = 'redacted'
|
|
24
|
+
|
|
25
|
+
export type FreezeResult =
|
|
26
|
+
| { ok: true; snapshot: FreezeSnapshot; warnings: FreezeWarning[] }
|
|
27
|
+
| { ok: false; error: { code: string; message: string } }
|
|
28
|
+
|
|
29
|
+
const BEGIN = '<<<FREEZE'
|
|
30
|
+
const END = '>>>FREEZE'
|
|
31
|
+
|
|
32
|
+
const SECTION_KEYS: ReadonlyMap<string, SectionKey> = new Map([
|
|
33
|
+
['目标:', 'goal'],
|
|
34
|
+
['进度:', 'progress'],
|
|
35
|
+
['下一步:', 'next'],
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
type SectionKey = 'goal' | 'progress' | 'next'
|
|
39
|
+
|
|
40
|
+
const SECTION_ORDER: readonly SectionKey[] = ['goal', 'progress', 'next']
|
|
41
|
+
|
|
42
|
+
const SECTION_NAMES: Record<SectionKey, string> = {
|
|
43
|
+
goal: '目标',
|
|
44
|
+
progress: '进度',
|
|
45
|
+
next: '下一步',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sensitive patterns, each matched globally over every field body:
|
|
50
|
+
* PEM private key blocks (whole block collapses to one marker), Bearer
|
|
51
|
+
* credentials, OpenAI sk-, GitHub ghp_, GitLab glpat-, Slack xox* tokens,
|
|
52
|
+
* and AWS access key ids.
|
|
53
|
+
*/
|
|
54
|
+
const SENSITIVE_PATTERNS: RegExp[] = [
|
|
55
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
56
|
+
/Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
|
|
57
|
+
/sk-[A-Za-z0-9_-]{8,}/g,
|
|
58
|
+
/ghp_[A-Za-z0-9]{20,}/g,
|
|
59
|
+
/glpat-[A-Za-z0-9_-]{15,}/g,
|
|
60
|
+
/xox[bpars]-[A-Za-z0-9-]{10,}/g,
|
|
61
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
function fail(code: string, message: string): FreezeResult {
|
|
65
|
+
return { ok: false, error: { code, message } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function utf8Bytes(text: string): number {
|
|
69
|
+
return new TextEncoder().encode(text).length
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Redact sensitive patterns to the marker; reports whether any hit occurred. */
|
|
73
|
+
export function redactSensitive(text: string): { text: string; redacted: boolean } {
|
|
74
|
+
let out = text
|
|
75
|
+
let redacted = false
|
|
76
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
77
|
+
if (pattern.test(out)) {
|
|
78
|
+
redacted = true
|
|
79
|
+
out = out.replace(pattern, REDACTED_MARKER)
|
|
80
|
+
}
|
|
81
|
+
pattern.lastIndex = 0
|
|
82
|
+
}
|
|
83
|
+
return { text: out, redacted }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when any line of the text starts with "/" (a DSH command line).
|
|
88
|
+
* Leading horizontal whitespace before the slash counts too: a frozen
|
|
89
|
+
* body line like ` /kill` is still a command, not prose.
|
|
90
|
+
*/
|
|
91
|
+
export function hasSlashCommandLines(text: string): boolean {
|
|
92
|
+
return text.split(/\r?\n/).some(line => /^[ \t]+\//.test(line) || line.startsWith('/'))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Sanitize a structured freeze snapshot (the create/update action payload):
|
|
97
|
+
* shape check, slash-command taint rejection, sensitive redaction, and the
|
|
98
|
+
* per-field byte cap - the same gate parseFreezeRequest applies to
|
|
99
|
+
* free-text freeze requests, exposed for the action data plane (issue #4).
|
|
100
|
+
* @param value - the freeze object carried by an action or read back from disk.
|
|
101
|
+
* @param extraKeys - keys allowed alongside goal/progress/next (e.g. the
|
|
102
|
+
* protocol redacted flag, the ledger frozenAt stamp) and preserved verbatim
|
|
103
|
+
* when present; their validation stays with the caller.
|
|
104
|
+
*/
|
|
105
|
+
export function sanitizeFreezeSnapshot(
|
|
106
|
+
value: unknown,
|
|
107
|
+
extraKeys: readonly string[] = [],
|
|
108
|
+
): { ok: true; snapshot: FreezeSnapshot; redacted: boolean; extras: Record<string, unknown> } | { ok: false; error: { code: string; message: string } } {
|
|
109
|
+
const bad = (code: string, message: string) => ({ ok: false as const, error: { code, message } })
|
|
110
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
111
|
+
return bad('invalid-freeze', '冻结快照必须是 goal/progress/next 字符串对象')
|
|
112
|
+
}
|
|
113
|
+
const record = value as Record<string, unknown>
|
|
114
|
+
const allowed = ['goal', 'progress', 'next', ...extraKeys]
|
|
115
|
+
if (!Object.keys(record).every(key => allowed.includes(key))) {
|
|
116
|
+
return bad('invalid-freeze', '冻结快照包含未知字段')
|
|
117
|
+
}
|
|
118
|
+
for (const key of SECTION_ORDER) {
|
|
119
|
+
if (typeof record[key] !== 'string') {
|
|
120
|
+
return bad('invalid-freeze', '冻结快照字段 ' + SECTION_NAMES[key] + ' 必须是字符串')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const key of SECTION_ORDER) {
|
|
124
|
+
if (hasSlashCommandLines(record[key] as string)) {
|
|
125
|
+
return bad('dsh-command-line', '冻结文本的' + SECTION_NAMES[key] + '包含以 / 开头的命令行,整体拒绝')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let redacted = false
|
|
129
|
+
const snapshot: Record<SectionKey, string> = { goal: '', progress: '', next: '' }
|
|
130
|
+
for (const key of SECTION_ORDER) {
|
|
131
|
+
const result = redactSensitive(record[key] as string)
|
|
132
|
+
snapshot[key] = result.text
|
|
133
|
+
redacted = redacted || result.redacted
|
|
134
|
+
}
|
|
135
|
+
for (const key of SECTION_ORDER) {
|
|
136
|
+
if (utf8Bytes(snapshot[key]) > FREEZE_FIELD_BYTE_LIMIT) {
|
|
137
|
+
return bad('field-too-large', '冻结快照字段 ' + SECTION_NAMES[key] + ' 超过 8 KiB 上限')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const extras: Record<string, unknown> = {}
|
|
141
|
+
for (const key of extraKeys) if (key in record) extras[key] = record[key]
|
|
142
|
+
return { ok: true, snapshot: { goal: snapshot.goal, progress: snapshot.progress, next: snapshot.next }, redacted, extras }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse the freeze-request format: a <<<FREEZE ... >>>FREEZE block whose body
|
|
147
|
+
* is 目标: / 进度: / 下一步: section headers, each followed by body lines.
|
|
148
|
+
* The gate applies before returning: slash-command taint rejects the whole
|
|
149
|
+
* request, sensitive patterns are redacted to markers, and each field is
|
|
150
|
+
* capped at FREEZE_FIELD_BYTE_LIMIT UTF-8 bytes.
|
|
151
|
+
*/
|
|
152
|
+
export function parseFreezeRequest(input: string): FreezeResult {
|
|
153
|
+
const beginIndex = input.indexOf(BEGIN)
|
|
154
|
+
if (beginIndex === -1) {
|
|
155
|
+
return fail('missing-block', '未找到冻结请求块(需要 <<<FREEZE ... >>>FREEZE)')
|
|
156
|
+
}
|
|
157
|
+
const bodyStart = beginIndex + BEGIN.length
|
|
158
|
+
const endIndex = input.indexOf(END, bodyStart)
|
|
159
|
+
if (endIndex === -1) {
|
|
160
|
+
return fail('unterminated-block', '冻结请求块未闭合(缺少 >>>FREEZE)')
|
|
161
|
+
}
|
|
162
|
+
const block = input.slice(bodyStart, endIndex)
|
|
163
|
+
|
|
164
|
+
const raw = new Map<SectionKey, string[]>()
|
|
165
|
+
let current: SectionKey | undefined
|
|
166
|
+
for (const line of block.split(/\r?\n/)) {
|
|
167
|
+
const key = SECTION_KEYS.get(line)
|
|
168
|
+
if (key !== undefined) {
|
|
169
|
+
if (raw.has(key)) {
|
|
170
|
+
return fail('duplicate-section', `重复的段落标题:${SECTION_NAMES[key]}`)
|
|
171
|
+
}
|
|
172
|
+
raw.set(key, [])
|
|
173
|
+
current = key
|
|
174
|
+
} else if (current !== undefined) {
|
|
175
|
+
raw.get(current)!.push(line)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const snapshot: Record<SectionKey, string> = { goal: '', progress: '', next: '' }
|
|
180
|
+
for (const key of SECTION_ORDER) {
|
|
181
|
+
if (!raw.has(key)) {
|
|
182
|
+
return fail('missing-section', `冻结请求缺少段落:${SECTION_NAMES[key]}`)
|
|
183
|
+
}
|
|
184
|
+
const lines = raw.get(key)!
|
|
185
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
186
|
+
snapshot[key] = lines.join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const key of SECTION_ORDER) {
|
|
190
|
+
if (hasSlashCommandLines(snapshot[key])) {
|
|
191
|
+
return fail('dsh-command-line', `冻结文本的${SECTION_NAMES[key]}包含以 / 开头的命令行,整体拒绝`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let redacted = false
|
|
196
|
+
for (const key of SECTION_ORDER) {
|
|
197
|
+
const result = redactSensitive(snapshot[key])
|
|
198
|
+
snapshot[key] = result.text
|
|
199
|
+
redacted = redacted || result.redacted
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const key of SECTION_ORDER) {
|
|
203
|
+
if (utf8Bytes(snapshot[key]) > FREEZE_FIELD_BYTE_LIMIT) {
|
|
204
|
+
return fail('field-too-large', `冻结快照字段 ${SECTION_NAMES[key]} 超过 8 KiB 上限`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
snapshot: { goal: snapshot.goal, progress: snapshot.progress, next: snapshot.next },
|
|
211
|
+
warnings: redacted ? ['redacted'] : [],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handover bundle (issue #5, ADR 0001): the resource reference set a
|
|
3
|
+
* continuation card carries — the pinned execution triplet (workspace /
|
|
4
|
+
* agent preset / permission) plus doc/script references — so a picking-up
|
|
5
|
+
* session can start with zero context.
|
|
6
|
+
*
|
|
7
|
+
* Security gate (adversarial scenario b): a bundle whose effective
|
|
8
|
+
* permission is HIGHER than the session default is an unconfirmed binding.
|
|
9
|
+
* It enters the pending-confirmation state (the card stores the binding
|
|
10
|
+
* without a permissionConfirmedAt stamp); manual execution refuses, cron
|
|
11
|
+
* refuses to schedule, and the human confirm-permission action resolves the
|
|
12
|
+
* transaction by stamping the confirmation. Any later permission or
|
|
13
|
+
* handover change re-arms the gate (the confirmation binds the exact
|
|
14
|
+
* permission value, blocking confirm-then-swap escalation).
|
|
15
|
+
*/
|
|
16
|
+
import { isTaskPermission, type TaskPermission, type TaskRecord } from './tasks.ts'
|
|
17
|
+
|
|
18
|
+
export type { TaskPermission }
|
|
19
|
+
|
|
20
|
+
/** Wire shape of a handover bundle (before bundledAt is stamped). */
|
|
21
|
+
export interface TaskHandoverInput {
|
|
22
|
+
workspaceId?: string
|
|
23
|
+
mode?: string
|
|
24
|
+
permission?: TaskPermission
|
|
25
|
+
references: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Persisted handover bundle on a task record. */
|
|
29
|
+
export interface TaskHandover extends TaskHandoverInput {
|
|
30
|
+
/** When the bundle was attached/replaced (ms epoch, stamped by the use case). */
|
|
31
|
+
bundledAt: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Most references a bundle may carry. */
|
|
35
|
+
export const HANDOVER_MAX_REFERENCES = 32
|
|
36
|
+
/** Per-reference UTF-8 byte cap. */
|
|
37
|
+
export const HANDOVER_MAX_REFERENCE_BYTES = 512
|
|
38
|
+
/** Total references UTF-8 byte cap. */
|
|
39
|
+
export const HANDOVER_MAX_TOTAL_BYTES = 8 * 1024
|
|
40
|
+
/** Per target-id (workspace/preset) byte cap. */
|
|
41
|
+
export const HANDOVER_MAX_TARGET_BYTES = 256
|
|
42
|
+
|
|
43
|
+
/** The board's notion of the deployment session-default permission (fail-safe default). */
|
|
44
|
+
export const DEFAULT_SESSION_PERMISSION: TaskPermission = 'read-only'
|
|
45
|
+
|
|
46
|
+
/** Permission elevation rank (higher = more authority). */
|
|
47
|
+
export const PERMISSION_RANK: ReadonlyMap<TaskPermission, number> = new Map([
|
|
48
|
+
['read-only', 0],
|
|
49
|
+
['workspace-write', 1],
|
|
50
|
+
['danger-full-access', 2],
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
function byteLength(value: string): number {
|
|
54
|
+
return new TextEncoder().encode(value).length
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Gate a handover bundle from the wire or disk: exact keys, string targets
|
|
59
|
+
* under the byte cap, a known permission, and a bounded string reference
|
|
60
|
+
* list. Returns the sanitized bundle, or undefined when rejected.
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeHandover(value: unknown): TaskHandoverInput | undefined {
|
|
63
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
64
|
+
const bundle = value as Record<string, unknown>
|
|
65
|
+
const keys = Object.keys(bundle)
|
|
66
|
+
if (!keys.every(key => ['workspaceId', 'mode', 'permission', 'references'].includes(key))) return undefined
|
|
67
|
+
if (!Array.isArray(bundle.references)) return undefined
|
|
68
|
+
if (bundle.references.length > HANDOVER_MAX_REFERENCES) return undefined
|
|
69
|
+
let total = 0
|
|
70
|
+
for (const reference of bundle.references) {
|
|
71
|
+
if (typeof reference !== 'string' || reference === '') return undefined
|
|
72
|
+
const size = byteLength(reference)
|
|
73
|
+
if (size > HANDOVER_MAX_REFERENCE_BYTES) return undefined
|
|
74
|
+
total += size
|
|
75
|
+
if (total > HANDOVER_MAX_TOTAL_BYTES) return undefined
|
|
76
|
+
}
|
|
77
|
+
for (const key of ['workspaceId', 'mode'] as const) {
|
|
78
|
+
const target: unknown = bundle[key]
|
|
79
|
+
if (target !== undefined && (typeof target !== 'string' || byteLength(target) > HANDOVER_MAX_TARGET_BYTES)) return undefined
|
|
80
|
+
}
|
|
81
|
+
if (bundle.permission !== undefined && !isTaskPermission(bundle.permission)) return undefined
|
|
82
|
+
const workspaceId = bundle.workspaceId
|
|
83
|
+
const mode = bundle.mode
|
|
84
|
+
return {
|
|
85
|
+
...(typeof workspaceId === 'string' ? { workspaceId } : {}),
|
|
86
|
+
...(typeof mode === 'string' ? { mode } : {}),
|
|
87
|
+
...(bundle.permission === undefined ? {} : { permission: bundle.permission as TaskPermission }),
|
|
88
|
+
references: [...bundle.references as string[]],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build the persisted bundle from a sanitized input, stamping bundledAt. */
|
|
93
|
+
export function handoverOf(input: TaskHandoverInput, now: number): TaskHandover {
|
|
94
|
+
return { ...input, bundledAt: now }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The permission an execution session would actually run under. */
|
|
98
|
+
export function effectivePermission(task: Pick<TaskRecord, 'permission' | 'handover'>): TaskPermission | undefined {
|
|
99
|
+
return task.handover?.permission ?? task.permission
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether a binding's permission is elevated above the session default. */
|
|
103
|
+
export function exceedsSessionDefault(permission: TaskPermission | undefined, sessionDefault: TaskPermission): boolean {
|
|
104
|
+
if (permission === undefined) return false
|
|
105
|
+
return (PERMISSION_RANK.get(permission) ?? -1) > (PERMISSION_RANK.get(sessionDefault) ?? -1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The confirmation-gate predicate: an elevated permission without a human
|
|
110
|
+
* confirmation stamp. Manual run/rerun and cron must refuse such a card.
|
|
111
|
+
*/
|
|
112
|
+
export function requiresPermissionConfirmation(
|
|
113
|
+
task: Pick<TaskRecord, 'permission' | 'handover' | 'permissionConfirmedAt'>,
|
|
114
|
+
sessionDefault: TaskPermission = DEFAULT_SESSION_PERMISSION,
|
|
115
|
+
): boolean {
|
|
116
|
+
return exceedsSessionDefault(effectivePermission(task), sessionDefault) && task.permissionConfirmedAt === undefined
|
|
117
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
|
+
import type { TaskHandover } from './handover.ts'
|
|
15
|
+
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
16
|
+
import { sanitizeHandover } from './handover.ts'
|
|
14
17
|
|
|
15
18
|
/** Persistence seam for the task ledger. */
|
|
16
19
|
export interface TaskStore {
|
|
@@ -72,6 +75,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
72
75
|
if (entry.endedAt !== undefined && typeof entry.endedAt !== 'number') return false
|
|
73
76
|
if (entry.result !== undefined && entry.result !== 'succeeded' && entry.result !== 'failed' && entry.result !== 'cancelled') return false
|
|
74
77
|
if (entry.error !== undefined && typeof entry.error !== 'string') return false
|
|
78
|
+
if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
|
|
79
|
+
if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
|
|
80
|
+
if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
|
|
75
81
|
}
|
|
76
82
|
return true
|
|
77
83
|
}
|
|
@@ -107,6 +113,44 @@ function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Repair a persisted freeze snapshot: shape + gate re-check (slash taint,
|
|
118
|
+
* redaction idempotence, byte cap); a malformed or tainted snapshot is
|
|
119
|
+
* dropped (undefined) rather than dropping the whole task row, mirroring
|
|
120
|
+
* the schedule repair policy.
|
|
121
|
+
*/
|
|
122
|
+
function normalizeFreeze(value: unknown): TaskFreeze | undefined {
|
|
123
|
+
const result = sanitizeFreezeSnapshot(value, ['frozenAt', 'redacted', 'frozenBy'])
|
|
124
|
+
if (!result.ok) return undefined
|
|
125
|
+
const frozenAt = result.extras.frozenAt
|
|
126
|
+
if (typeof frozenAt !== 'number' || !Number.isFinite(frozenAt)) return undefined
|
|
127
|
+
if (result.extras.redacted !== undefined && result.extras.redacted !== true) return undefined
|
|
128
|
+
const frozenBy = result.extras.frozenBy
|
|
129
|
+
if (frozenBy !== undefined && (typeof frozenBy !== 'string' || frozenBy === '')) return undefined
|
|
130
|
+
return {
|
|
131
|
+
goal: result.snapshot.goal,
|
|
132
|
+
progress: result.snapshot.progress,
|
|
133
|
+
next: result.snapshot.next,
|
|
134
|
+
frozenAt,
|
|
135
|
+
...(result.redacted || result.extras.redacted === true ? { redacted: true } : {}),
|
|
136
|
+
...(frozenBy === undefined ? {} : { frozenBy }),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Repair a persisted handover bundle: shape re-check through the same gate
|
|
142
|
+
* as the wire path; a malformed bundle is dropped rather than dropping the
|
|
143
|
+
* task row (mirroring the schedule/freeze repair policy).
|
|
144
|
+
*/
|
|
145
|
+
function normalizeHandover(value: unknown): TaskHandover | undefined {
|
|
146
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
147
|
+
const { bundledAt, ...rest } = value as Record<string, unknown> & { bundledAt: unknown }
|
|
148
|
+
const bundle = sanitizeHandover(rest)
|
|
149
|
+
if (bundle === undefined) return undefined
|
|
150
|
+
if (typeof bundledAt !== 'number' || !Number.isFinite(bundledAt)) return undefined
|
|
151
|
+
return { ...bundle, bundledAt }
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
/** Parse + validate a persisted ledger document; invalid rows are dropped. */
|
|
111
155
|
export function parseLedger(raw: string | null): TaskRecord[] {
|
|
112
156
|
if (raw === null) return []
|
|
@@ -141,6 +185,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
141
185
|
task.mode = normalizeTargetId(row.mode)
|
|
142
186
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
143
187
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
188
|
+
task.freeze = normalizeFreeze(row.freeze)
|
|
189
|
+
task.handover = normalizeHandover(row.handover)
|
|
190
|
+
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
144
191
|
tasks.push(task)
|
|
145
192
|
}
|
|
146
193
|
return tasks
|
package/src/core/tasks.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Framework-free (no cordis, no runtime imports) so the state machine is
|
|
5
5
|
* unit-testable in isolation.
|
|
6
6
|
*/
|
|
7
|
+
import type { FreezeSnapshot } from './freeze-snapshot.ts'
|
|
8
|
+
import type { TaskHandover, TaskHandoverInput } from './handover.ts'
|
|
7
9
|
|
|
8
10
|
/** Task lifecycle status, one per kanban column. */
|
|
9
11
|
export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
|
|
@@ -26,6 +28,16 @@ export interface ExecutionRecord {
|
|
|
26
28
|
result: 'succeeded' | 'failed' | 'cancelled' | undefined
|
|
27
29
|
/** Human failure text when the run failed (prompt rejection or agent error). */
|
|
28
30
|
error: string | undefined
|
|
31
|
+
/**
|
|
32
|
+
* Session id of the DSH session that issued the run/rerun action (issue #6
|
|
33
|
+
* audit origin). Client-asserted, not a trust boundary; absent when the run
|
|
34
|
+
* was triggered by cron (source unknown).
|
|
35
|
+
*/
|
|
36
|
+
initiatedBy?: string
|
|
37
|
+
/** Freeze instant captured from the card snapshot when the run opened. */
|
|
38
|
+
frozenAt?: number
|
|
39
|
+
/** Freeze source session captured from the card snapshot when the run opened. */
|
|
40
|
+
frozenBy?: string
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
/**
|
|
@@ -64,6 +76,24 @@ export interface ScheduleRule {
|
|
|
64
76
|
lastTriggeredAt: number | undefined
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Frozen context snapshot carried by a continuation card (issue #4): the
|
|
81
|
+
* goal/progress/next text (already sanitized by the freeze gates) plus the
|
|
82
|
+
* freeze instant and the redaction warning flag.
|
|
83
|
+
*/
|
|
84
|
+
export interface TaskFreeze extends FreezeSnapshot {
|
|
85
|
+
/** When the snapshot was frozen (ms epoch, stamped by the create/update use case). */
|
|
86
|
+
frozenAt: number
|
|
87
|
+
/** True when the freeze gates redacted sensitive patterns out of the text. */
|
|
88
|
+
redacted?: boolean
|
|
89
|
+
/**
|
|
90
|
+
* Session id of the DSH session that authored the frozen snapshot (issue #6
|
|
91
|
+
* provenance): stamped by the Host ledger from the create/update action's
|
|
92
|
+
* initiator, or kept from the wire payload when no initiator was asserted.
|
|
93
|
+
*/
|
|
94
|
+
frozenBy?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
/** One task on the board. */
|
|
68
98
|
export interface TaskRecord {
|
|
69
99
|
/** Stable task id (uuid). */
|
|
@@ -102,6 +132,25 @@ export interface TaskRecord {
|
|
|
102
132
|
* `/permission <id>` slash command; absent leaves the session default.
|
|
103
133
|
*/
|
|
104
134
|
permission?: TaskPermission
|
|
135
|
+
/**
|
|
136
|
+
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
137
|
+
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
138
|
+
* 8 KiB per-field cap) by the protocol gate and re-normalized on load.
|
|
139
|
+
*/
|
|
140
|
+
freeze?: TaskFreeze
|
|
141
|
+
/**
|
|
142
|
+
* Handover bundle carried by a continuation card (issue #5): the pinned
|
|
143
|
+
* execution triplet plus doc/script references. Sanitized before it
|
|
144
|
+
* enters the ledger by the protocol gate and re-normalized on load; the
|
|
145
|
+
* bundle's triplet overrides the legacy pin fields at execution time.
|
|
146
|
+
*/
|
|
147
|
+
handover?: TaskHandover
|
|
148
|
+
/**
|
|
149
|
+
* Human confirmation stamp for an above-default effective permission
|
|
150
|
+
* (ms epoch). Absent while the binding awaits confirmation; any permission
|
|
151
|
+
* or handover change re-arms the gate by clearing it.
|
|
152
|
+
*/
|
|
153
|
+
permissionConfirmedAt?: number
|
|
105
154
|
/**
|
|
106
155
|
* When the task was archived (ms epoch). Archived tasks keep their status
|
|
107
156
|
* and execution history, leave the main board, and cannot run until restored;
|
|
@@ -142,6 +191,16 @@ export interface NewTaskInput {
|
|
|
142
191
|
* case arms it only when enabled and the expression is valid.
|
|
143
192
|
*/
|
|
144
193
|
schedule?: { enabled: boolean; cron: string }
|
|
194
|
+
/**
|
|
195
|
+
* Optional frozen context snapshot (goal/progress/next, sanitized by the
|
|
196
|
+
* protocol gate) turning the new task into a continuation card.
|
|
197
|
+
*/
|
|
198
|
+
freeze?: FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
|
|
199
|
+
/**
|
|
200
|
+
* Optional handover bundle (pinned triplet + doc/script references,
|
|
201
|
+
* sanitized by the protocol gate) attached at creation.
|
|
202
|
+
*/
|
|
203
|
+
handover?: TaskHandoverInput
|
|
145
204
|
}
|
|
146
205
|
|
|
147
206
|
/** The five kanban columns, in display order. */
|
|
@@ -180,6 +239,24 @@ export function normalizeTargetId(value: string | undefined): string | undefined
|
|
|
180
239
|
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
181
240
|
}
|
|
182
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Build the persisted freeze snapshot from a sanitized input, stamping the
|
|
244
|
+
* freeze instant (shared by the create and update use cases).
|
|
245
|
+
*/
|
|
246
|
+
export function freezeOf(
|
|
247
|
+
input: FreezeSnapshot & { redacted?: boolean; frozenBy?: string },
|
|
248
|
+
now: number,
|
|
249
|
+
): TaskFreeze {
|
|
250
|
+
return {
|
|
251
|
+
goal: input.goal,
|
|
252
|
+
progress: input.progress,
|
|
253
|
+
next: input.next,
|
|
254
|
+
frozenAt: now,
|
|
255
|
+
...(input.redacted === true ? { redacted: true } : {}),
|
|
256
|
+
...(input.frozenBy === undefined || input.frozenBy === '' ? {} : { frozenBy: input.frozenBy }),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
183
260
|
/** Create a task from user input. */
|
|
184
261
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
185
262
|
return {
|
|
@@ -194,6 +271,8 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
194
271
|
workspaceId: normalizeTargetId(input.workspaceId),
|
|
195
272
|
mode: normalizeTargetId(input.mode),
|
|
196
273
|
permission: isTaskPermission(input.permission) ? input.permission : undefined,
|
|
274
|
+
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
275
|
+
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
197
276
|
}
|
|
198
277
|
}
|
|
199
278
|
|
|
@@ -235,6 +314,7 @@ export function startExecution(
|
|
|
235
314
|
task: TaskRecord,
|
|
236
315
|
now: number,
|
|
237
316
|
executionId: string,
|
|
317
|
+
initiatedBy?: string,
|
|
238
318
|
): { task: TaskRecord; execution: ExecutionRecord } {
|
|
239
319
|
const execution: ExecutionRecord = {
|
|
240
320
|
id: executionId,
|
|
@@ -243,6 +323,13 @@ export function startExecution(
|
|
|
243
323
|
endedAt: undefined,
|
|
244
324
|
result: undefined,
|
|
245
325
|
error: undefined,
|
|
326
|
+
...(initiatedBy === undefined || initiatedBy === '' ? {} : { initiatedBy }),
|
|
327
|
+
// Capture the card's freeze provenance on the execution record so the
|
|
328
|
+
// audit trail stays queryable even if the snapshot is replaced later.
|
|
329
|
+
...(task.freeze === undefined ? {} : {
|
|
330
|
+
frozenAt: task.freeze.frozenAt,
|
|
331
|
+
...(task.freeze.frozenBy === undefined ? {} : { frozenBy: task.freeze.frozenBy }),
|
|
332
|
+
}),
|
|
246
333
|
}
|
|
247
334
|
return {
|
|
248
335
|
task: {
|