typeclaw 0.1.4 → 0.1.6
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 +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaimCompletedPayload,
|
|
3
|
+
ClaimErrorPayload,
|
|
4
|
+
ClaimStartedPayload,
|
|
5
|
+
ClientMessage,
|
|
6
|
+
ServerMessage,
|
|
7
|
+
} from '@/shared'
|
|
8
|
+
|
|
9
|
+
import { generateClaimCode } from './code'
|
|
10
|
+
|
|
11
|
+
export type ClaimSessionOptions = {
|
|
12
|
+
url: string
|
|
13
|
+
role: string
|
|
14
|
+
channel?: string
|
|
15
|
+
ttlMs?: number
|
|
16
|
+
connectTimeoutMs?: number
|
|
17
|
+
onStarted?: (payload: ClaimStartedPayload) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ClaimSessionResult =
|
|
21
|
+
| { kind: 'completed'; payload: ClaimCompletedPayload }
|
|
22
|
+
| { kind: 'error'; payload: ClaimErrorPayload }
|
|
23
|
+
| { kind: 'timeout' }
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000
|
|
26
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
|
|
27
|
+
|
|
28
|
+
export async function runClaimSession(opts: ClaimSessionOptions): Promise<ClaimSessionResult> {
|
|
29
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
|
|
30
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
31
|
+
const code = generateClaimCode()
|
|
32
|
+
|
|
33
|
+
const ws = new WebSocket(opts.url)
|
|
34
|
+
const displayUrl = redactUrl(opts.url)
|
|
35
|
+
await waitForOpen(ws, displayUrl, connectTimeoutMs)
|
|
36
|
+
await waitForConnected(ws, displayUrl, connectTimeoutMs)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const request: ClientMessage = {
|
|
40
|
+
type: 'claim_start',
|
|
41
|
+
code,
|
|
42
|
+
role: opts.role,
|
|
43
|
+
ttlMs,
|
|
44
|
+
...(opts.channel !== undefined ? { channel: opts.channel } : {}),
|
|
45
|
+
}
|
|
46
|
+
ws.send(JSON.stringify(request))
|
|
47
|
+
|
|
48
|
+
return await waitForOutcome(ws, code, ttlMs, opts.onStarted)
|
|
49
|
+
} finally {
|
|
50
|
+
try {
|
|
51
|
+
const cancel: ClientMessage = { type: 'claim_cancel' }
|
|
52
|
+
ws.send(JSON.stringify(cancel))
|
|
53
|
+
} catch {}
|
|
54
|
+
ws.close()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitForOpen(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
|
|
59
|
+
await new Promise<void>((resolve, reject) => {
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
cleanup()
|
|
62
|
+
ws.close()
|
|
63
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
64
|
+
}, timeoutMs)
|
|
65
|
+
const onOpen = () => {
|
|
66
|
+
cleanup()
|
|
67
|
+
resolve()
|
|
68
|
+
}
|
|
69
|
+
const onError = (err: unknown) => {
|
|
70
|
+
cleanup()
|
|
71
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
72
|
+
}
|
|
73
|
+
const onClose = () => {
|
|
74
|
+
cleanup()
|
|
75
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
76
|
+
}
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
ws.removeEventListener('open', onOpen)
|
|
80
|
+
ws.removeEventListener('error', onError)
|
|
81
|
+
ws.removeEventListener('close', onClose)
|
|
82
|
+
}
|
|
83
|
+
ws.addEventListener('open', onOpen, { once: true })
|
|
84
|
+
ws.addEventListener('error', onError, { once: true })
|
|
85
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The server's WS `open` handler is async (it awaits createSession) and only
|
|
90
|
+
// registers per-ws state and sends `connected` after that resolves. Bun
|
|
91
|
+
// delivers any client messages received before `open` completes, but the
|
|
92
|
+
// server's `claim_start` handler silently drops messages when state is null.
|
|
93
|
+
// Waiting here mirrors what the TUI client does (see src/tui/index.ts) and
|
|
94
|
+
// fixes the hatching-time hang where the spinner stayed on "Generating your
|
|
95
|
+
// claim code..." until the ttl expired.
|
|
96
|
+
async function waitForConnected(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
|
|
97
|
+
await new Promise<void>((resolve, reject) => {
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
cleanup()
|
|
100
|
+
ws.close()
|
|
101
|
+
reject(new Error(`timed out waiting for connected message from ${displayUrl} after ${timeoutMs}ms`))
|
|
102
|
+
}, timeoutMs)
|
|
103
|
+
const onMessage = (event: MessageEvent): void => {
|
|
104
|
+
let msg: ServerMessage
|
|
105
|
+
try {
|
|
106
|
+
msg = JSON.parse(String(event.data)) as ServerMessage
|
|
107
|
+
} catch {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (msg.type === 'connected') {
|
|
111
|
+
cleanup()
|
|
112
|
+
resolve()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (msg.type === 'error') {
|
|
116
|
+
cleanup()
|
|
117
|
+
reject(new Error(`server rejected connection to ${displayUrl}: ${msg.message}`))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const onClose = () => {
|
|
121
|
+
cleanup()
|
|
122
|
+
reject(new Error(`connection to ${displayUrl} closed before the session was ready`))
|
|
123
|
+
}
|
|
124
|
+
const cleanup = () => {
|
|
125
|
+
clearTimeout(timer)
|
|
126
|
+
ws.removeEventListener('message', onMessage)
|
|
127
|
+
ws.removeEventListener('close', onClose)
|
|
128
|
+
}
|
|
129
|
+
ws.addEventListener('message', onMessage)
|
|
130
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function waitForOutcome(
|
|
135
|
+
ws: WebSocket,
|
|
136
|
+
code: string,
|
|
137
|
+
ttlMs: number,
|
|
138
|
+
onStarted?: (payload: ClaimStartedPayload) => void,
|
|
139
|
+
): Promise<ClaimSessionResult> {
|
|
140
|
+
return new Promise<ClaimSessionResult>((resolve) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
ws.removeEventListener('message', onMessage)
|
|
143
|
+
resolve({ kind: 'timeout' })
|
|
144
|
+
}, ttlMs + 5_000)
|
|
145
|
+
|
|
146
|
+
const onMessage = (event: MessageEvent): void => {
|
|
147
|
+
let msg: ServerMessage
|
|
148
|
+
try {
|
|
149
|
+
msg = JSON.parse(String(event.data)) as ServerMessage
|
|
150
|
+
} catch {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (msg.type === 'claim_started' && msg.payload.code === code) {
|
|
154
|
+
onStarted?.(msg.payload)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (msg.type === 'claim_completed' && msg.payload.code === code) {
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
ws.removeEventListener('message', onMessage)
|
|
160
|
+
resolve({ kind: 'completed', payload: msg.payload })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (msg.type === 'claim_error' && msg.payload.code === code) {
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
ws.removeEventListener('message', onMessage)
|
|
166
|
+
resolve({ kind: 'error', payload: msg.payload })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
ws.addEventListener('message', onMessage)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function redactUrl(url: string): string {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = new URL(url)
|
|
177
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
178
|
+
return parsed.toString()
|
|
179
|
+
} catch {
|
|
180
|
+
return url
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Role-claim codes are short, human-typeable tokens the operator sends from
|
|
4
|
+
// their host CLI to the bot via a channel DM to prove ownership of that
|
|
5
|
+
// channel identity. Shape: `claim-XXXX-YYYY` where each block is 4 chars
|
|
6
|
+
// from a Crockford-style base32 alphabet (0-9 + A-Z minus I, L, O, U to
|
|
7
|
+
// dodge OCR-confusable / profane shapes). 8 chars * 5 bits = 40 bits of
|
|
8
|
+
// entropy, which is overkill for a TTL'd in-memory window but cheap to
|
|
9
|
+
// display and dictate over voice.
|
|
10
|
+
//
|
|
11
|
+
// The `claim-` prefix lets the channel router recognize potential claim
|
|
12
|
+
// attempts in a DM body without scanning the whole text for hex blocks,
|
|
13
|
+
// and distinguishes claim DMs from normal first-message text like "hi"
|
|
14
|
+
// which would otherwise need a regex of its own to disambiguate.
|
|
15
|
+
|
|
16
|
+
export const CLAIM_CODE_PREFIX = 'claim-'
|
|
17
|
+
|
|
18
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
19
|
+
const BLOCK_SIZE = 4
|
|
20
|
+
const BLOCK_COUNT = 2
|
|
21
|
+
|
|
22
|
+
export function generateClaimCode(): string {
|
|
23
|
+
const bytes = randomBytes(BLOCK_SIZE * BLOCK_COUNT)
|
|
24
|
+
const chars: string[] = []
|
|
25
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
26
|
+
chars.push(ALPHABET[bytes[i]! % ALPHABET.length]!)
|
|
27
|
+
}
|
|
28
|
+
const blocks: string[] = []
|
|
29
|
+
for (let b = 0; b < BLOCK_COUNT; b++) {
|
|
30
|
+
blocks.push(chars.slice(b * BLOCK_SIZE, (b + 1) * BLOCK_SIZE).join(''))
|
|
31
|
+
}
|
|
32
|
+
return `${CLAIM_CODE_PREFIX}${blocks.join('-')}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Extracts the first claim-code-shaped token from inbound text. Returns
|
|
36
|
+
// the canonical-case (upper) code, or null. Tolerates surrounding
|
|
37
|
+
// whitespace, punctuation, and case — chat clients may auto-correct case
|
|
38
|
+
// or surround pastes with quotes/backticks.
|
|
39
|
+
export function extractClaimCode(text: string): string | null {
|
|
40
|
+
const pattern = new RegExp(
|
|
41
|
+
`${CLAIM_CODE_PREFIX}([0-9a-zA-Z]{${BLOCK_SIZE}}(?:-[0-9a-zA-Z]{${BLOCK_SIZE}}){${BLOCK_COUNT - 1}})`,
|
|
42
|
+
'i',
|
|
43
|
+
)
|
|
44
|
+
const match = pattern.exec(text)
|
|
45
|
+
if (!match) return null
|
|
46
|
+
return `${CLAIM_CODE_PREFIX}${match[1]!.toUpperCase()}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeClaimCode(code: string): string {
|
|
50
|
+
const trimmed = code.trim()
|
|
51
|
+
if (!trimmed.toLowerCase().startsWith(CLAIM_CODE_PREFIX)) return trimmed.toUpperCase()
|
|
52
|
+
return `${CLAIM_CODE_PREFIX}${trimmed.slice(CLAIM_CODE_PREFIX.length).toUpperCase()}`
|
|
53
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ClaimHandler } from '@/channels/router'
|
|
2
|
+
import { grantRole, type PermissionService } from '@/permissions'
|
|
3
|
+
|
|
4
|
+
import { extractClaimCode } from './code'
|
|
5
|
+
import { formatClaimMatchRule } from './match-rule'
|
|
6
|
+
import { createPendingClaimRegistry, type PendingClaim, type PendingClaimRegistry } from './pending'
|
|
7
|
+
|
|
8
|
+
// ClaimController is the runtime singleton that ties the four moving parts
|
|
9
|
+
// of the role-claim flow together:
|
|
10
|
+
//
|
|
11
|
+
// 1. The host CLI (typeclaw role claim) opens a WS and sends `claim_start`.
|
|
12
|
+
// 2. The WS server forwards that to controller.startClaim().
|
|
13
|
+
// 3. The channel router's claimHandler (also wired here) intercepts DMs
|
|
14
|
+
// bearing the code and calls controller.tryConsumeInbound().
|
|
15
|
+
// 4. On consume, the controller writes to typeclaw.json#roles.<role>.match
|
|
16
|
+
// via grantRole, then reloads the live PermissionService so the new
|
|
17
|
+
// match rule takes effect without a container restart.
|
|
18
|
+
//
|
|
19
|
+
// Result events (completed / error / cancelled) are pushed to subscribers
|
|
20
|
+
// the WS server registers, so the host CLI's open WS receives the outcome
|
|
21
|
+
// over the same connection.
|
|
22
|
+
|
|
23
|
+
export type ClaimCompletedEvent = {
|
|
24
|
+
kind: 'completed'
|
|
25
|
+
code: string
|
|
26
|
+
role: string
|
|
27
|
+
matchRule: string
|
|
28
|
+
adapter: string
|
|
29
|
+
authorId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ClaimErrorEvent = {
|
|
33
|
+
kind: 'error'
|
|
34
|
+
code: string
|
|
35
|
+
reason: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ClaimCancelledEvent = {
|
|
39
|
+
kind: 'cancelled'
|
|
40
|
+
code: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ClaimResultEvent = ClaimCompletedEvent | ClaimErrorEvent | ClaimCancelledEvent
|
|
44
|
+
|
|
45
|
+
export type ClaimController = {
|
|
46
|
+
startClaim: (input: { code: string; role: string; channel?: string; ttlMs: number }) =>
|
|
47
|
+
| {
|
|
48
|
+
ok: true
|
|
49
|
+
expiresAt: number
|
|
50
|
+
}
|
|
51
|
+
| { ok: false; reason: string }
|
|
52
|
+
cancelClaim: (code: string) => boolean
|
|
53
|
+
current: () => PendingClaim | null
|
|
54
|
+
onResult: (subscriber: (event: ClaimResultEvent) => void) => () => void
|
|
55
|
+
claimHandler: ClaimHandler
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CreateClaimControllerOptions = {
|
|
59
|
+
cwd: string
|
|
60
|
+
permissions: PermissionService
|
|
61
|
+
rolesProvider: () => import('@/permissions').RolesConfig | undefined
|
|
62
|
+
now?: () => number
|
|
63
|
+
registry?: PendingClaimRegistry
|
|
64
|
+
// Test seam: injectable role granter so tests don't touch disk. Production
|
|
65
|
+
// wires the real `grantRole` from src/permissions/grant.ts.
|
|
66
|
+
grant?: (roleName: string, matchRule: string) => { ok: true; added: boolean } | { ok: false; reason: string }
|
|
67
|
+
logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const KNOWN_BUILT_IN_ROLES = new Set(['owner', 'member', 'trusted'])
|
|
71
|
+
|
|
72
|
+
export function createClaimController(opts: CreateClaimControllerOptions): ClaimController {
|
|
73
|
+
const now = opts.now ?? Date.now
|
|
74
|
+
const registry = opts.registry ?? createPendingClaimRegistry({ now })
|
|
75
|
+
const grant =
|
|
76
|
+
opts.grant ?? ((roleName: string, matchRule: string) => grantRole({ cwd: opts.cwd, roleName, matchRule }))
|
|
77
|
+
const logger = opts.logger ?? defaultLogger
|
|
78
|
+
const subscribers = new Set<(event: ClaimResultEvent) => void>()
|
|
79
|
+
|
|
80
|
+
const emit = (event: ClaimResultEvent): void => {
|
|
81
|
+
for (const sub of subscribers) {
|
|
82
|
+
try {
|
|
83
|
+
sub(event)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
logger.warn(`[role-claim] subscriber threw: ${describe(err)}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const startClaim: ClaimController['startClaim'] = ({ code, role, channel, ttlMs }) => {
|
|
91
|
+
if (!isValidRoleName(role)) {
|
|
92
|
+
return { ok: false, reason: `unknown role '${role}' — built-in roles are owner, member, trusted` }
|
|
93
|
+
}
|
|
94
|
+
const startedAt = now()
|
|
95
|
+
const pending: PendingClaim = {
|
|
96
|
+
code,
|
|
97
|
+
role,
|
|
98
|
+
ttlMs,
|
|
99
|
+
startedAt,
|
|
100
|
+
expiresAt: startedAt + ttlMs,
|
|
101
|
+
...(channel !== undefined ? { channel } : {}),
|
|
102
|
+
}
|
|
103
|
+
registry.start(pending)
|
|
104
|
+
return { ok: true, expiresAt: pending.expiresAt }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const claimHandler: ClaimHandler = async (input) => {
|
|
108
|
+
const code = extractClaimCode(input.text)
|
|
109
|
+
if (code === null) return { kind: 'fallthrough' }
|
|
110
|
+
|
|
111
|
+
const result = registry.tryConsume(
|
|
112
|
+
code,
|
|
113
|
+
{
|
|
114
|
+
adapter: input.adapter,
|
|
115
|
+
workspace: input.workspace,
|
|
116
|
+
chat: input.chat,
|
|
117
|
+
isDm: input.isDm,
|
|
118
|
+
authorId: input.authorId,
|
|
119
|
+
},
|
|
120
|
+
formatClaimMatchRule,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if (result.kind === 'no-pending') return { kind: 'fallthrough' }
|
|
124
|
+
if (result.kind === 'no-match') return { kind: 'fallthrough' }
|
|
125
|
+
if (result.kind === 'wrong-channel') {
|
|
126
|
+
const reply = `That claim is for a different channel — please run typeclaw role claim again on this one.`
|
|
127
|
+
emit({ kind: 'error', code, reason: 'wrong-channel' })
|
|
128
|
+
return { kind: 'fail', reply }
|
|
129
|
+
}
|
|
130
|
+
if (result.kind === 'expired') {
|
|
131
|
+
const reply = `That claim code has expired. Run typeclaw role claim again to start a new one.`
|
|
132
|
+
emit({ kind: 'error', code, reason: 'expired' })
|
|
133
|
+
return { kind: 'fail', reply }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const granted = grant(result.role, result.matchRule)
|
|
137
|
+
if (!granted.ok) {
|
|
138
|
+
const reply = `Sorry, I couldn't save your role: ${granted.reason}`
|
|
139
|
+
emit({ kind: 'error', code, reason: granted.reason })
|
|
140
|
+
return { kind: 'fail', reply }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
opts.permissions.replaceRoles(opts.rolesProvider())
|
|
145
|
+
} catch (err) {
|
|
146
|
+
logger.warn(`[role-claim] replaceRoles failed after grant: ${describe(err)}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
emit({
|
|
150
|
+
kind: 'completed',
|
|
151
|
+
code,
|
|
152
|
+
role: result.role,
|
|
153
|
+
matchRule: result.matchRule,
|
|
154
|
+
adapter: result.origin.adapter,
|
|
155
|
+
authorId: result.origin.authorId,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const noteAdded = granted.added ? '' : ' (already on file)'
|
|
159
|
+
const reply = `You're paired as ${result.role}${noteAdded}. Welcome aboard!`
|
|
160
|
+
return { kind: 'consumed', reply }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
startClaim,
|
|
165
|
+
cancelClaim: (code) => {
|
|
166
|
+
const cancelled = registry.cancel(code)
|
|
167
|
+
if (cancelled) emit({ kind: 'cancelled', code })
|
|
168
|
+
return cancelled
|
|
169
|
+
},
|
|
170
|
+
current: () => registry.current(),
|
|
171
|
+
onResult: (sub) => {
|
|
172
|
+
subscribers.add(sub)
|
|
173
|
+
return () => {
|
|
174
|
+
subscribers.delete(sub)
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
claimHandler,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isValidRoleName(role: string): boolean {
|
|
182
|
+
if (KNOWN_BUILT_IN_ROLES.has(role)) return true
|
|
183
|
+
return /^[a-z][a-z0-9-]*$/.test(role) && role !== 'guest'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function describe(err: unknown): string {
|
|
187
|
+
return err instanceof Error ? err.message : String(err)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const defaultLogger = {
|
|
191
|
+
info: (m: string) => console.log(m),
|
|
192
|
+
warn: (m: string) => console.warn(m),
|
|
193
|
+
error: (m: string) => console.error(m),
|
|
194
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { runClaimSession, type ClaimSessionOptions, type ClaimSessionResult } from './client'
|
|
2
|
+
export { CLAIM_CODE_PREFIX, extractClaimCode, generateClaimCode, normalizeClaimCode } from './code'
|
|
3
|
+
export {
|
|
4
|
+
createClaimController,
|
|
5
|
+
type ClaimCancelledEvent,
|
|
6
|
+
type ClaimCompletedEvent,
|
|
7
|
+
type ClaimController,
|
|
8
|
+
type ClaimErrorEvent,
|
|
9
|
+
type ClaimResultEvent,
|
|
10
|
+
type CreateClaimControllerOptions,
|
|
11
|
+
} from './controller'
|
|
12
|
+
export { formatClaimMatchRule, type PartialChannelOrigin } from './match-rule'
|
|
13
|
+
export {
|
|
14
|
+
createPendingClaimRegistry,
|
|
15
|
+
type ClaimResult,
|
|
16
|
+
type PendingClaim,
|
|
17
|
+
type PendingClaimRegistry,
|
|
18
|
+
type PendingClaimRegistryOptions,
|
|
19
|
+
} from './pending'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Builds a canonical match-rule DSL string from an inbound channel origin,
|
|
2
|
+
// for the role table. Output shapes:
|
|
3
|
+
//
|
|
4
|
+
// slack:T0123 author:U_ALICE
|
|
5
|
+
// discord:9999 author:U_ALICE
|
|
6
|
+
// telegram:42 author:U_ALICE
|
|
7
|
+
// kakao:dm/<chatId> author:<authorId>
|
|
8
|
+
//
|
|
9
|
+
// The author qualifier is always emitted so a claim grants the specific
|
|
10
|
+
// human, not the whole workspace. To grant the whole workspace, the
|
|
11
|
+
// operator edits typeclaw.json by hand or runs a future `typeclaw role grant`
|
|
12
|
+
// without --claim.
|
|
13
|
+
|
|
14
|
+
import type { ChannelKey } from '@/channels/types'
|
|
15
|
+
|
|
16
|
+
export type PartialChannelOrigin = {
|
|
17
|
+
adapter: ChannelKey['adapter']
|
|
18
|
+
workspace: string
|
|
19
|
+
chat: string
|
|
20
|
+
isDm: boolean
|
|
21
|
+
authorId: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ADAPTER_TO_PLATFORM: Record<ChannelKey['adapter'], 'slack' | 'discord' | 'telegram' | 'kakao'> = {
|
|
25
|
+
'slack-bot': 'slack',
|
|
26
|
+
'discord-bot': 'discord',
|
|
27
|
+
'telegram-bot': 'telegram',
|
|
28
|
+
kakaotalk: 'kakao',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatClaimMatchRule(origin: PartialChannelOrigin): string {
|
|
32
|
+
const platform = ADAPTER_TO_PLATFORM[origin.adapter]
|
|
33
|
+
const authorQual = ` author:${origin.authorId}`
|
|
34
|
+
if (origin.adapter === 'kakaotalk') {
|
|
35
|
+
// Kakao has no workspace; routes use dm/group/open buckets. We can't
|
|
36
|
+
// know which bucket from a partial origin alone (adapter-side classifies
|
|
37
|
+
// it), so claim flows are restricted to DM and we emit the specific
|
|
38
|
+
// chat-id form so the rule grants only this 1:1 conversation, not every
|
|
39
|
+
// DM the agent is in.
|
|
40
|
+
return `${platform}:dm/${origin.chat}${authorQual}`
|
|
41
|
+
}
|
|
42
|
+
return `${platform}:${origin.workspace}${authorQual}`
|
|
43
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { PartialChannelOrigin } from './match-rule'
|
|
2
|
+
|
|
3
|
+
export type PendingClaim = {
|
|
4
|
+
code: string
|
|
5
|
+
role: string
|
|
6
|
+
channel?: string
|
|
7
|
+
ttlMs: number
|
|
8
|
+
startedAt: number
|
|
9
|
+
expiresAt: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ClaimResult =
|
|
13
|
+
| { kind: 'consumed'; code: string; role: string; matchRule: string; origin: PartialChannelOrigin }
|
|
14
|
+
| { kind: 'no-pending' }
|
|
15
|
+
| { kind: 'no-match' }
|
|
16
|
+
| { kind: 'expired' }
|
|
17
|
+
| { kind: 'wrong-channel' }
|
|
18
|
+
|
|
19
|
+
export type PendingClaimRegistry = {
|
|
20
|
+
start: (claim: PendingClaim) => void
|
|
21
|
+
cancel: (code: string) => boolean
|
|
22
|
+
current: () => PendingClaim | null
|
|
23
|
+
// Snapshot of consumption result without actually committing the grant.
|
|
24
|
+
// The router calls this on every DM-shaped inbound; the grant only fires
|
|
25
|
+
// when the result is 'consumed'.
|
|
26
|
+
tryConsume: (
|
|
27
|
+
code: string,
|
|
28
|
+
origin: PartialChannelOrigin,
|
|
29
|
+
formatMatchRule: (origin: PartialChannelOrigin) => string,
|
|
30
|
+
) => ClaimResult
|
|
31
|
+
size: () => number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PendingClaimRegistryOptions = {
|
|
35
|
+
now?: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Single-claim-at-a-time registry. A second `start` while one is pending
|
|
39
|
+
// replaces the prior code (cancels it implicitly): the operator running
|
|
40
|
+
// `typeclaw role claim` twice from two terminals expects the second invocation
|
|
41
|
+
// to take over, not error.
|
|
42
|
+
//
|
|
43
|
+
// Stored in-memory only — claim sessions are short-lived (default 10 min)
|
|
44
|
+
// and a container restart legitimately invalidates any pending window.
|
|
45
|
+
export function createPendingClaimRegistry(opts: PendingClaimRegistryOptions = {}): PendingClaimRegistry {
|
|
46
|
+
const now = opts.now ?? Date.now
|
|
47
|
+
let pending: PendingClaim | null = null
|
|
48
|
+
|
|
49
|
+
type ExpiryCheck = { live: PendingClaim } | { live: null; reason: 'no-pending' | 'expired' }
|
|
50
|
+
|
|
51
|
+
const expireIfDue = (): ExpiryCheck => {
|
|
52
|
+
if (pending === null) return { live: null, reason: 'no-pending' }
|
|
53
|
+
if (now() >= pending.expiresAt) {
|
|
54
|
+
pending = null
|
|
55
|
+
return { live: null, reason: 'expired' }
|
|
56
|
+
}
|
|
57
|
+
return { live: pending }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
start(claim) {
|
|
62
|
+
pending = { ...claim }
|
|
63
|
+
},
|
|
64
|
+
cancel(code) {
|
|
65
|
+
if (pending !== null && pending.code === code) {
|
|
66
|
+
pending = null
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
},
|
|
71
|
+
current() {
|
|
72
|
+
const check = expireIfDue()
|
|
73
|
+
return check.live
|
|
74
|
+
},
|
|
75
|
+
tryConsume(code, origin, formatMatchRule) {
|
|
76
|
+
const check = expireIfDue()
|
|
77
|
+
if (check.live === null) {
|
|
78
|
+
return { kind: check.reason }
|
|
79
|
+
}
|
|
80
|
+
const live = check.live
|
|
81
|
+
if (code !== live.code) return { kind: 'no-match' }
|
|
82
|
+
if (live.channel !== undefined && live.channel !== origin.adapter) {
|
|
83
|
+
return { kind: 'wrong-channel' }
|
|
84
|
+
}
|
|
85
|
+
const matchRule = formatMatchRule(origin)
|
|
86
|
+
const consumed: ClaimResult = {
|
|
87
|
+
kind: 'consumed',
|
|
88
|
+
code: live.code,
|
|
89
|
+
role: live.role,
|
|
90
|
+
matchRule,
|
|
91
|
+
origin,
|
|
92
|
+
}
|
|
93
|
+
pending = null
|
|
94
|
+
return consumed
|
|
95
|
+
},
|
|
96
|
+
size() {
|
|
97
|
+
return expireIfDue().live === null ? 0 : 1
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|