typeclaw 0.4.0 → 0.5.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/package.json +1 -1
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +87 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/index.ts +87 -3
- package/src/channels/router.ts +194 -28
- package/src/channels/types.ts +3 -1
- package/src/cli/init.ts +146 -42
- package/src/cli/model.ts +10 -2
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/provider.ts +3 -20
- package/src/config/config.ts +59 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +18 -1
- package/src/cron/consumer.ts +129 -43
- package/src/init/dockerfile.ts +109 -3
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +14 -3
- package/src/init/oauth-login.ts +17 -3
- package/src/permissions/builtins.ts +29 -7
- package/src/permissions/permissions.ts +24 -7
- package/src/plugin/define.ts +2 -0
- package/src/plugin/manager.ts +14 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/index.ts +2 -1
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-permissions/SKILL.md +35 -17
- package/src/tui/index.ts +35 -3
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +57 -25
|
@@ -1,8 +1,28 @@
|
|
|
1
|
+
import type { SecuritySeverity } from '../permissions'
|
|
1
2
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
2
3
|
import { getRemoteTaint, recordRemoteTaint } from './remote-taint-state'
|
|
3
4
|
|
|
4
5
|
export const GUARD_GIT_EXFIL = 'gitExfil'
|
|
6
|
+
// Classified `high` (audience-leak axis): `git push` sends every tracked
|
|
7
|
+
// file to a remote git host. The host (GitHub/GitLab/attacker-controlled
|
|
8
|
+
// box) is a third-party audience outside the operator's control loop.
|
|
9
|
+
// Even a private remote owned by an attacker is now outside the
|
|
10
|
+
// perimeter. No role auto-bypasses high — owner pushing from TUI must ack
|
|
11
|
+
// each push. The historical per-guard string `security.bypass.gitExfil`
|
|
12
|
+
// remains valid as an explicit grant for operators who knowingly want to
|
|
13
|
+
// re-open the auto-bypass (see SKILL.md must-not-do guidance).
|
|
14
|
+
export const GUARD_GIT_EXFIL_SEVERITY: SecuritySeverity = 'high'
|
|
5
15
|
export const GUARD_GIT_REMOTE_TAINTED = 'gitRemoteTainted'
|
|
16
|
+
// Classified `high` (audience-leak axis): same path as gitExfil, second
|
|
17
|
+
// step. A push after a mid-session `git remote set-url` to an
|
|
18
|
+
// attacker-controlled URL is exactly the breach pattern that motivated
|
|
19
|
+
// the entire security plugin per PR #134. The recorder-vs-checker split
|
|
20
|
+
// (see comment on recordGitRemoteTaintIfAny below) is still load-bearing:
|
|
21
|
+
// the recorder fires for anyone who can run the underlying command (ack
|
|
22
|
+
// or the per-guard `bypassGitExfil` grant), so even if an operator
|
|
23
|
+
// explicitly grants `bypassGitExfil` to a role, the second-step taint
|
|
24
|
+
// check still fires on the eventual push.
|
|
25
|
+
export const GUARD_GIT_REMOTE_TAINTED_SEVERITY: SecuritySeverity = 'high'
|
|
6
26
|
|
|
7
27
|
// Anchors we reuse: a `git` token must be at start-of-line or follow a shell
|
|
8
28
|
// separator. This blocks `git push` while letting `cgit-something` through
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import type { SecuritySeverity } from '../permissions'
|
|
1
2
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
2
3
|
|
|
3
4
|
export const GUARD_OUTBOUND_SECRET = 'outboundSecret'
|
|
5
|
+
// Classified `high` (audience-leak axis): bypass posts credential-shaped
|
|
6
|
+
// text to a chat channel whose readership is a third-party audience
|
|
7
|
+
// outside the operator's control loop. Channel readers, push-notification
|
|
8
|
+
// previews, search indexes, and other bots in the channel all see the
|
|
9
|
+
// secret before the operator can intervene. Owner-in-public-channel is
|
|
10
|
+
// the canonical motivating case: even owner asking the agent to "post the
|
|
11
|
+
// deploy status" should not be able to silently include a stack-trace
|
|
12
|
+
// `Bearer ghp_...` line. The whole point of the high tier is that
|
|
13
|
+
// audience-leak guards require per-call ack from every role, including
|
|
14
|
+
// owner — see AGENTS.md `## Permissions` rules of thumb.
|
|
15
|
+
export const GUARD_OUTBOUND_SECRET_SEVERITY: SecuritySeverity = 'high'
|
|
4
16
|
|
|
5
17
|
const SIGNATURE_PATTERNS: ReadonlyArray<{ kind: string; pattern: RegExp }> = [
|
|
6
18
|
{ kind: 'aws_access_key_id', pattern: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|ACCA)[A-Z0-9]{16}\b/ },
|
|
@@ -463,10 +463,30 @@ export function detectPromptInjection(prompt: string): InjectionMatch[] {
|
|
|
463
463
|
|
|
464
464
|
const DEFENSE_MARKER = '[security/prompt-injection]'
|
|
465
465
|
|
|
466
|
+
// Subagent prompts are constructed by trusted bundled code, not from raw
|
|
467
|
+
// user input. The backup-diagnose subagent in particular embeds raw git
|
|
468
|
+
// stderr (which legitimately contains literal "git push --help" hint
|
|
469
|
+
// strings on fast-forward rejection or missing-upstream failures) — those
|
|
470
|
+
// hits would otherwise trigger the git_exfil category and inject a "do
|
|
471
|
+
// NOT run git push" rule that contradicts the subagent's own
|
|
472
|
+
// system-prompt instructions to retry with an ack. Under the audience-
|
|
473
|
+
// leak policy the runtime tool.before is the universal backstop for
|
|
474
|
+
// `git push` regardless of role (no role auto-bypasses), so the prompt-
|
|
475
|
+
// side git_exfil category is strictly redundant for subagent origins.
|
|
476
|
+
// Other categories (system_prompt_dump, secret_demand,
|
|
477
|
+
// fake_privileged_skill) still fire for subagents because their threats
|
|
478
|
+
// (e.g. memory-logger ingesting an attacker's transcript) are real.
|
|
479
|
+
function filterForOrigin(matches: InjectionMatch[], origin: SessionPromptEvent['origin']): InjectionMatch[] {
|
|
480
|
+
if (origin?.kind !== 'subagent') return matches
|
|
481
|
+
return matches.filter((m) => m.category !== 'git_exfil')
|
|
482
|
+
}
|
|
483
|
+
|
|
466
484
|
export function applyPromptInjectionDefense(event: SessionPromptEvent): InjectionMatch[] {
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
469
|
-
if (event.prompt.includes(DEFENSE_MARKER)) return
|
|
485
|
+
const allMatches = detectPromptInjection(event.prompt)
|
|
486
|
+
if (allMatches.length === 0) return allMatches
|
|
487
|
+
if (event.prompt.includes(DEFENSE_MARKER)) return allMatches
|
|
488
|
+
const matches = filterForOrigin(allMatches, event.origin)
|
|
489
|
+
if (matches.length === 0) return allMatches
|
|
470
490
|
|
|
471
491
|
const categories = Array.from(new Set(matches.map((m) => m.category))).join(', ')
|
|
472
492
|
const note = [
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import type { SecuritySeverity } from '../permissions'
|
|
1
2
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
2
3
|
|
|
3
4
|
export const GUARD_SECRET_EXFIL_BASH = 'secretExfilBash'
|
|
5
|
+
// Classified `medium` (silent-attack axis): bypass dumps the whole
|
|
6
|
+
// environment (every API key, every token) into the agent's tool-result
|
|
7
|
+
// buffer. No direct channel side effect — operator only sees on session
|
|
8
|
+
// review — but the secrets are now in model context and one channel_send
|
|
9
|
+
// away from a third-party audience. Silent at the moment of leak.
|
|
10
|
+
export const GUARD_SECRET_EXFIL_BASH_SEVERITY: SecuritySeverity = 'medium'
|
|
4
11
|
|
|
5
12
|
const DANGEROUS_COMMAND_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
|
|
6
13
|
{ pattern: /(^|[\s;|&(`$])(env|printenv)([\s;|&)`]|$)/, label: 'env / printenv (full environment dump)' },
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
+
import type { SecuritySeverity } from '../permissions'
|
|
3
4
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
4
5
|
|
|
5
6
|
export const GUARD_SECRET_EXFIL_READ = 'secretExfilRead'
|
|
7
|
+
// Classified `medium` (silent-attack axis): bypass returns `.env` /
|
|
8
|
+
// credential-file contents into model context. Same shape as
|
|
9
|
+
// secretExfilBash — silent at the moment of read, becomes catastrophic on
|
|
10
|
+
// the next channel-side tool call that quotes it.
|
|
11
|
+
export const GUARD_SECRET_EXFIL_READ_SEVERITY: SecuritySeverity = 'medium'
|
|
6
12
|
|
|
7
13
|
const SENSITIVE_BASENAMES = new Set([
|
|
8
14
|
'.env',
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import type { SecuritySeverity } from '../permissions'
|
|
1
2
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
2
3
|
|
|
3
4
|
export const GUARD_SESSION_SEARCH_SECRETS = 'sessionSearchSecrets'
|
|
5
|
+
// Classified `medium` (silent-attack axis): bypass returns secret-shaped
|
|
6
|
+
// session-search hits into the agent's tool-result buffer. The operator
|
|
7
|
+
// doesn't see the raw hits — the agent summarizes them — so the leak is
|
|
8
|
+
// silent from the operator's perspective even though it's a read tool.
|
|
9
|
+
// The hits then live in model context as a precondition for a later
|
|
10
|
+
// channel_send leak; outboundSecret would catch the actual send, but
|
|
11
|
+
// silent-recon-then-summarize is its own attack shape.
|
|
12
|
+
export const GUARD_SESSION_SEARCH_SECRETS_SEVERITY: SecuritySeverity = 'medium'
|
|
4
13
|
|
|
5
14
|
const SESSION_SEARCH_TOOLS: ReadonlySet<string> = new Set([
|
|
6
15
|
'session_search',
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import type { SecuritySeverity } from '../permissions'
|
|
1
2
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
2
3
|
|
|
3
4
|
export const GUARD_SSRF = 'ssrf'
|
|
5
|
+
// Classified `medium` (silent-attack axis): bypass lets `curl
|
|
6
|
+
// http://169.254.169.254/...` return cloud-metadata IAM credentials into
|
|
7
|
+
// model context. Silent — no channel side effect at the moment of fetch.
|
|
8
|
+
// Catastrophic on follow-up because the model now has live cloud creds.
|
|
9
|
+
export const GUARD_SSRF_SEVERITY: SecuritySeverity = 'medium'
|
|
4
10
|
|
|
5
11
|
const ALWAYS_BLOCKED_HOSTS = new Set([
|
|
6
12
|
'localhost',
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import type { SecuritySeverity } from '../permissions'
|
|
1
2
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
2
3
|
|
|
3
4
|
export const GUARD_SYSTEM_PROMPT_LEAK = 'systemPromptLeak'
|
|
5
|
+
// Classified `high` (audience-leak axis): bypass posts TypeClaw runtime
|
|
6
|
+
// fingerprints / system-prompt fragments to a chat. Same shape as
|
|
7
|
+
// outboundSecret — third-party audience, no operator intervention before
|
|
8
|
+
// the leak lands. Disclosure also enables recon for later targeted
|
|
9
|
+
// prompt-injection attacks against this agent.
|
|
10
|
+
export const GUARD_SYSTEM_PROMPT_LEAK_SEVERITY: SecuritySeverity = 'high'
|
|
4
11
|
|
|
5
12
|
const FINGERPRINT_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
|
|
6
13
|
{ label: 'TypeClaw runtime preamble', pattern: /You are a general-purpose AI agent running inside TypeClaw\./ },
|
|
@@ -175,7 +175,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
175
175
|
managedHooks = registration.repos.flatMap((r) =>
|
|
176
176
|
r.action === 'created' || r.action === 'updated' ? [{ repo: r.repo, hookId: r.hookId }] : [],
|
|
177
177
|
)
|
|
178
|
-
logRegistrationOutcome(logger, registration)
|
|
178
|
+
logRegistrationOutcome(logger, registration, options.secrets.auth.type)
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
181
|
async stop(): Promise<void> {
|
|
@@ -264,14 +264,98 @@ function detectLegacyProviderHostSuffix(url: string): string | undefined {
|
|
|
264
264
|
return undefined
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
function logRegistrationOutcome(
|
|
267
|
+
function logRegistrationOutcome(
|
|
268
|
+
logger: GithubAdapterLogger,
|
|
269
|
+
result: WebhookRegistrationResult,
|
|
270
|
+
authType: 'pat' | 'app',
|
|
271
|
+
): void {
|
|
272
|
+
const permissionFailures: Array<{ repo: string; status: number }> = []
|
|
268
273
|
for (const r of result.repos) {
|
|
269
274
|
if (r.action === 'created') logger.info(`[github] registered webhook ${r.hookId} on ${r.repo}`)
|
|
270
275
|
else if (r.action === 'updated') {
|
|
271
276
|
const tail = r.stalePruned > 0 ? ` (pruned ${r.stalePruned} stale)` : ''
|
|
272
277
|
logger.info(`[github] updated webhook ${r.hookId} on ${r.repo}${tail}`)
|
|
273
|
-
} else
|
|
278
|
+
} else {
|
|
279
|
+
logger.warn(`[github] webhook register failed for ${r.repo}: ${r.error}`)
|
|
280
|
+
const status = parseListHooksPermissionStatus(r.error)
|
|
281
|
+
if (status !== null) permissionFailures.push({ repo: r.repo, status })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// One guidance block per start() (not per repo) so a 10-repo permission
|
|
285
|
+
// failure doesn't paste the same paragraph 10 times. The names below MUST
|
|
286
|
+
// match the current github.com UI labels — see comment in
|
|
287
|
+
// buildPermissionGuidance.
|
|
288
|
+
if (permissionFailures.length > 0) {
|
|
289
|
+
logger.warn(buildPermissionGuidance(authType, permissionFailures))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
|
|
294
|
+
// Returns the status code when it matches the two shapes GitHub emits for
|
|
295
|
+
// missing access on the list-hooks endpoint:
|
|
296
|
+
// - 404 Not Found: the token cannot see the repo at all (private repo
|
|
297
|
+
// gated behind missing repository access — GitHub returns 404 instead of
|
|
298
|
+
// 403 to avoid leaking the existence of private repos).
|
|
299
|
+
// - 403 Forbidden: the token sees the repo but lacks webhook-management
|
|
300
|
+
// permission, OR is blocked by an org SSO/SAML authorization gate.
|
|
301
|
+
// Returns null for any other error (network, malformed slug, create-hook
|
|
302
|
+
// failures, etc.) so the guidance only fires on the actual symptom.
|
|
303
|
+
export function parseListHooksPermissionStatus(error: string): number | null {
|
|
304
|
+
const match = error.match(/^list hooks failed: (404|403)\b/)
|
|
305
|
+
if (match === null) return null
|
|
306
|
+
return Number(match[1])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The labels below intentionally mirror github.com's current UI verbatim so a
|
|
310
|
+
// user can grep their settings page for the exact string. If GitHub renames
|
|
311
|
+
// any of these in a future redesign, update both here and the
|
|
312
|
+
// `permissionGuidance` tests in lifecycle.test.ts.
|
|
313
|
+
//
|
|
314
|
+
// Fine-grained PAT:
|
|
315
|
+
// Settings → Developer settings → Personal access tokens → Fine-grained tokens
|
|
316
|
+
// "Resource owner", "Repository access", "Repository permissions" → "Webhooks" → "Read and write", "Metadata" → "Read-only"
|
|
317
|
+
// GitHub App:
|
|
318
|
+
// Settings → Developer settings → GitHub Apps → <app> → Permissions & events
|
|
319
|
+
// "Repository permissions" → "Webhooks" → "Read and write"
|
|
320
|
+
// Install/configure on the org: <app settings> → Install App / Configure → "Repository access"
|
|
321
|
+
// Classic PAT (legacy, still supported by GitHub but we don't surface it in
|
|
322
|
+
// channel-add prompts):
|
|
323
|
+
// Settings → Developer settings → Personal access tokens (classic)
|
|
324
|
+
// Scope: "admin:repo_hook" (or full "repo" for private repositories)
|
|
325
|
+
export function buildPermissionGuidance(
|
|
326
|
+
authType: 'pat' | 'app',
|
|
327
|
+
failures: ReadonlyArray<{ repo: string; status: number }>,
|
|
328
|
+
): string {
|
|
329
|
+
const repoList = failures.map((f) => `${f.repo} (${f.status})`).join(', ')
|
|
330
|
+
const lines: string[] = [
|
|
331
|
+
`[github] webhook setup needs more access for: ${repoList}.`,
|
|
332
|
+
' - 404 from GitHub means the token cannot see the repo (GitHub hides private repos behind 404 instead of 403).',
|
|
333
|
+
' - 403 means the token sees the repo but lacks webhook permission, or is blocked by org SAML/SSO.',
|
|
334
|
+
'',
|
|
335
|
+
]
|
|
336
|
+
if (authType === 'pat') {
|
|
337
|
+
lines.push(
|
|
338
|
+
' Fix (fine-grained personal access token):',
|
|
339
|
+
' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
|
|
340
|
+
' 2. Under "Resource owner", select the org that owns the failing repos (e.g. the org in the slug above).',
|
|
341
|
+
' 3. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
|
|
342
|
+
' 4. Under "Repository permissions", set "Webhooks" to "Read and write" and "Metadata" to "Read-only".',
|
|
343
|
+
' 5. Save. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.',
|
|
344
|
+
'',
|
|
345
|
+
' Or (classic personal access token): grant the "admin:repo_hook" scope (or "repo" for private repos),',
|
|
346
|
+
' and on a SAML-protected org click "Authorize" next to the token.',
|
|
347
|
+
)
|
|
348
|
+
} else {
|
|
349
|
+
lines.push(
|
|
350
|
+
' Fix (GitHub App):',
|
|
351
|
+
' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
|
|
352
|
+
' 2. Under "Permissions & events" → "Repository permissions", set "Webhooks" to "Read and write". Save.',
|
|
353
|
+
' 3. From the app page, click "Install App" (or "Configure" if already installed) and select the org that owns the failing repos.',
|
|
354
|
+
' 4. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
|
|
355
|
+
' 5. If the app permissions changed in step 2, install owners must accept the updated permissions from the install page before the new access takes effect.',
|
|
356
|
+
)
|
|
274
357
|
}
|
|
358
|
+
return lines.join('\n')
|
|
275
359
|
}
|
|
276
360
|
|
|
277
361
|
function logDeregistrationOutcome(
|
package/src/channels/router.ts
CHANGED
|
@@ -78,6 +78,24 @@ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
|
78
78
|
export const SESSION_IDLE_MS = 30 * 60 * 1000
|
|
79
79
|
export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
80
80
|
|
|
81
|
+
// Hard cap on tool-initiated outbound sends per (chat:thread) per turn.
|
|
82
|
+
// The original loop-incident emitted ~50 sends in one turn; even
|
|
83
|
+
// legitimate split replies rarely cross 8. 10 leaves headroom for
|
|
84
|
+
// genuine multi-part answers while definitively stopping runaway loops.
|
|
85
|
+
// Enforced inside router.send for `source: 'tool'` callers; system
|
|
86
|
+
// recovery paths (`source: 'system'`) bypass.
|
|
87
|
+
export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
88
|
+
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
89
|
+
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
90
|
+
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
91
|
+
// threshold below escalates the per-send log to a warning.
|
|
92
|
+
export const SEND_RATE_WINDOW_MS = 5_000
|
|
93
|
+
// Above this in-window count, the per-send log line escalates to a
|
|
94
|
+
// `send_rate_warning` so a burst stands out in the log stream. Every
|
|
95
|
+
// send still emits a structured log line regardless of rate — this
|
|
96
|
+
// constant only controls when the warning marker appears.
|
|
97
|
+
export const SEND_RATE_WARN_THRESHOLD = 3
|
|
98
|
+
|
|
81
99
|
/**
|
|
82
100
|
* Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
|
|
83
101
|
* Set to the LLM provider's KV-cache TTL (5 min) so the new session's system prompt is
|
|
@@ -244,6 +262,26 @@ type LiveSession = {
|
|
|
244
262
|
// router.send so the hint reflects the position of the about-to-happen send
|
|
245
263
|
// (n-th in a row), nudging the model to yield without forcing it to.
|
|
246
264
|
consecutiveSends: Map<string, number>
|
|
265
|
+
// Per-(chat:thread) text of the last reserved bot send. Set
|
|
266
|
+
// SYNCHRONOUSLY inside router.send before the outbound callback awaits,
|
|
267
|
+
// so two concurrent `router.send` calls for the same target cannot both
|
|
268
|
+
// pass the duplicate guard. Cleared on every new prompt batch (same
|
|
269
|
+
// lifecycle as `consecutiveSends`). The scope is "last 1 send within
|
|
270
|
+
// this turn" so legitimate multi-part replies (different bodies) and
|
|
271
|
+
// across-turn callbacks ("yes, I'm here" twice) are not blocked. Empty
|
|
272
|
+
// strings are normalized to undefined before storage so attachments-only
|
|
273
|
+
// sends never poison the tracker. The fuzzy-match upgrade is intentionally
|
|
274
|
+
// deferred — exact-match has zero false-positive risk by construction.
|
|
275
|
+
lastSentText: Map<string, string>
|
|
276
|
+
// Per-(chat:thread) ring of send timestamps (epoch ms) within the rolling
|
|
277
|
+
// SEND_RATE_WINDOW_MS window. Append-on-send, prune-on-read. Lifecycle is
|
|
278
|
+
// wall-clock (NOT cleared on new prompt batches) because rate is a
|
|
279
|
+
// property of the channel over time, not the agent's turn structure — a
|
|
280
|
+
// burst that straddles two adjacent turns is still a burst from the chat
|
|
281
|
+
// platform's POV. Telemetry-only today; the rate is logged when count
|
|
282
|
+
// crosses SEND_RATE_LOG_THRESHOLD so production data can inform a
|
|
283
|
+
// future hard cap without picking a threshold out of thin air.
|
|
284
|
+
sendTimestamps: Map<string, number[]>
|
|
247
285
|
successfulChannelSends: number
|
|
248
286
|
// Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
|
|
249
287
|
// above. Updated in route() on every engaged peer-bot inbound, reset on
|
|
@@ -264,15 +302,35 @@ type ChannelCommandContext = {
|
|
|
264
302
|
event: InboundMessage
|
|
265
303
|
}
|
|
266
304
|
|
|
305
|
+
export type SendSource = 'tool' | 'system'
|
|
306
|
+
|
|
307
|
+
export type SendOptions = {
|
|
308
|
+
source?: SendSource
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export const DUPLICATE_SEND_ERROR =
|
|
312
|
+
'Duplicate not sent. Do not call channel_send/channel_reply again this turn. ' +
|
|
313
|
+
'End with NO_REPLY unless you have genuinely new, non-redundant information.'
|
|
314
|
+
|
|
315
|
+
export const TURN_CAP_ERROR =
|
|
316
|
+
`Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
|
|
317
|
+
'End your turn now. The user can prompt you again for more output.'
|
|
318
|
+
|
|
267
319
|
export type ChannelRouter = {
|
|
268
320
|
route: (event: InboundMessage) => Promise<void>
|
|
269
|
-
send: (msg: OutboundMessage) => Promise<SendResult>
|
|
321
|
+
send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
|
|
270
322
|
getConsecutiveSendCount: (target: {
|
|
271
323
|
adapter: ChannelKey['adapter']
|
|
272
324
|
workspace: string
|
|
273
325
|
chat: string
|
|
274
326
|
thread?: string | null
|
|
275
327
|
}) => number
|
|
328
|
+
getSendRate: (target: {
|
|
329
|
+
adapter: ChannelKey['adapter']
|
|
330
|
+
workspace: string
|
|
331
|
+
chat: string
|
|
332
|
+
thread?: string | null
|
|
333
|
+
}) => { count: number; windowMs: number }
|
|
276
334
|
registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
277
335
|
unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
278
336
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
@@ -719,6 +777,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
719
777
|
lastTurnAuthorIds: new Set(),
|
|
720
778
|
consecutiveAborts: 0,
|
|
721
779
|
consecutiveSends: new Map(),
|
|
780
|
+
lastSentText: new Map(),
|
|
781
|
+
sendTimestamps: new Map(),
|
|
722
782
|
successfulChannelSends: 0,
|
|
723
783
|
recentEngagedPeerBotTurns: [],
|
|
724
784
|
consecutiveEngagedPeerBotTurns: 0,
|
|
@@ -1011,7 +1071,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1011
1071
|
|
|
1012
1072
|
live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
|
|
1013
1073
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1014
|
-
if (batch.length > 0)
|
|
1074
|
+
if (batch.length > 0) {
|
|
1075
|
+
live.consecutiveSends.clear()
|
|
1076
|
+
live.lastSentText.clear()
|
|
1077
|
+
}
|
|
1015
1078
|
|
|
1016
1079
|
// Update the live origin holder so this turn's tool.before events
|
|
1017
1080
|
// carry the current actor's id. The DefaultResourceLoader still
|
|
@@ -1036,6 +1099,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1036
1099
|
} catch (err) {
|
|
1037
1100
|
logger.error(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
|
|
1038
1101
|
live.consecutiveSends.clear()
|
|
1102
|
+
live.lastSentText.clear()
|
|
1039
1103
|
} finally {
|
|
1040
1104
|
await fireSessionTurnEnd(live)
|
|
1041
1105
|
}
|
|
@@ -1108,13 +1172,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1108
1172
|
logger.info(
|
|
1109
1173
|
`[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
|
|
1110
1174
|
)
|
|
1111
|
-
await send(
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1175
|
+
await send(
|
|
1176
|
+
{
|
|
1177
|
+
adapter: event.adapter,
|
|
1178
|
+
workspace: event.workspace,
|
|
1179
|
+
chat: event.chat,
|
|
1180
|
+
thread: event.thread,
|
|
1181
|
+
text: outcome.reply,
|
|
1182
|
+
},
|
|
1183
|
+
{ source: 'system' },
|
|
1184
|
+
)
|
|
1118
1185
|
return
|
|
1119
1186
|
}
|
|
1120
1187
|
}
|
|
@@ -1421,10 +1488,52 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1421
1488
|
return lastError
|
|
1422
1489
|
}
|
|
1423
1490
|
|
|
1424
|
-
const send = async (msg: OutboundMessage): Promise<SendResult> => {
|
|
1491
|
+
const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
|
|
1492
|
+
const source: SendSource = opts?.source ?? 'tool'
|
|
1425
1493
|
const callbacks = outboundCallbacks.get(msg.adapter)
|
|
1426
1494
|
if (!callbacks || callbacks.size === 0) {
|
|
1427
|
-
return { ok: false, error: `no adapter registered for "${msg.adapter}"
|
|
1495
|
+
return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const keyId = channelKeyId({
|
|
1499
|
+
adapter: msg.adapter,
|
|
1500
|
+
workspace: msg.workspace,
|
|
1501
|
+
chat: msg.chat,
|
|
1502
|
+
thread: msg.thread ?? null,
|
|
1503
|
+
})
|
|
1504
|
+
const live = liveSessions.get(keyId)
|
|
1505
|
+
const sendKey = consecutiveSendKey(msg.chat, msg.thread)
|
|
1506
|
+
const text = normalizeSendText(msg.text)
|
|
1507
|
+
|
|
1508
|
+
// Central enforcement. Tool-initiated sends are subject to two policies:
|
|
1509
|
+
// a per-turn count cap (kills runaway loops regardless of content) and
|
|
1510
|
+
// an exact-duplicate guard (kills the byte-identical-spam sub-mode).
|
|
1511
|
+
// Both checks AND the state mutations they consult happen synchronously
|
|
1512
|
+
// before any `await`, so two concurrent `router.send` calls for the same
|
|
1513
|
+
// target (the parallel-tool-execution race) cannot both pass: the
|
|
1514
|
+
// second observer sees the first one's increment / lastSentText write.
|
|
1515
|
+
// System sources (validateChannelTurn recovery, role-claim reply) bypass
|
|
1516
|
+
// — those are one-shot paths the policy doesn't apply to.
|
|
1517
|
+
let priorLastSentText: string | undefined
|
|
1518
|
+
let reserved = false
|
|
1519
|
+
if (live && source === 'tool') {
|
|
1520
|
+
const currentCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1521
|
+
if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
|
|
1522
|
+
return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
|
|
1523
|
+
}
|
|
1524
|
+
if (text !== undefined && live.lastSentText.get(sendKey) === text) {
|
|
1525
|
+
return { ok: false, error: DUPLICATE_SEND_ERROR, code: 'duplicate' }
|
|
1526
|
+
}
|
|
1527
|
+
// Reserve the slot before awaiting. If the callback rejects we roll
|
|
1528
|
+
// back below; if it succeeds we keep the increment. The slot reserve
|
|
1529
|
+
// is what makes parallel tool calls safe. We also snapshot the prior
|
|
1530
|
+
// lastSentText so a transient delivery failure can be retried with
|
|
1531
|
+
// the same text — the dup-guard exists to stop runaway loops, not to
|
|
1532
|
+
// strand the model on a flaky adapter.
|
|
1533
|
+
priorLastSentText = live.lastSentText.get(sendKey)
|
|
1534
|
+
live.consecutiveSends.set(sendKey, currentCount + 1)
|
|
1535
|
+
if (text !== undefined) live.lastSentText.set(sendKey, text)
|
|
1536
|
+
reserved = true
|
|
1428
1537
|
}
|
|
1429
1538
|
|
|
1430
1539
|
// Snapshot the callbacks before iterating so a callback that mutates the
|
|
@@ -1443,16 +1552,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1443
1552
|
}
|
|
1444
1553
|
|
|
1445
1554
|
if (!delivered) {
|
|
1446
|
-
|
|
1555
|
+
// Roll back the slot reservation so a failed send doesn't burn cap
|
|
1556
|
+
// budget or poison the dup-guard. Restoring lastSentText to its
|
|
1557
|
+
// prior value (which may be undefined) lets a legitimate retry of
|
|
1558
|
+
// the same text succeed — the dup-guard is for loops, not flake.
|
|
1559
|
+
if (live && reserved) {
|
|
1560
|
+
const after = (live.consecutiveSends.get(sendKey) ?? 1) - 1
|
|
1561
|
+
if (after <= 0) live.consecutiveSends.delete(sendKey)
|
|
1562
|
+
else live.consecutiveSends.set(sendKey, after)
|
|
1563
|
+
if (priorLastSentText === undefined) live.lastSentText.delete(sendKey)
|
|
1564
|
+
else live.lastSentText.set(sendKey, priorLastSentText)
|
|
1565
|
+
}
|
|
1566
|
+
return { ok: false, error: lastError ?? 'no callback accepted the outbound', code: 'callback-rejected' }
|
|
1447
1567
|
}
|
|
1448
1568
|
|
|
1449
|
-
const keyId = channelKeyId({
|
|
1450
|
-
adapter: msg.adapter,
|
|
1451
|
-
workspace: msg.workspace,
|
|
1452
|
-
chat: msg.chat,
|
|
1453
|
-
thread: msg.thread ?? null,
|
|
1454
|
-
})
|
|
1455
|
-
const live = liveSessions.get(keyId)
|
|
1456
1569
|
if (live) {
|
|
1457
1570
|
live.successfulChannelSends++
|
|
1458
1571
|
// Don't stop the heartbeat here: the agent may still be mid-turn and
|
|
@@ -1477,8 +1590,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1477
1590
|
grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
|
|
1478
1591
|
}
|
|
1479
1592
|
}
|
|
1480
|
-
const
|
|
1481
|
-
live
|
|
1593
|
+
const turnCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1594
|
+
const rateCount = recordSendTimestamp(live, sendKey, now())
|
|
1595
|
+
const level = rateCount >= SEND_RATE_WARN_THRESHOLD ? 'warn' : 'info'
|
|
1596
|
+
const warn = rateCount >= SEND_RATE_WARN_THRESHOLD ? ' send_rate_warning' : ''
|
|
1597
|
+
const textLen = text !== undefined ? text.length : 0
|
|
1598
|
+
const fields = `source=${source} turn=${turnCount} rate=${rateCount}/${SEND_RATE_WINDOW_MS}ms text_len=${textLen}`
|
|
1599
|
+
logger[level](`[channels] ${live.keyId} send ${fields}${warn}`)
|
|
1482
1600
|
}
|
|
1483
1601
|
|
|
1484
1602
|
return { ok: true }
|
|
@@ -1498,13 +1616,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1498
1616
|
logger.warn(
|
|
1499
1617
|
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1500
1618
|
)
|
|
1501
|
-
const result = await send(
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1619
|
+
const result = await send(
|
|
1620
|
+
{
|
|
1621
|
+
adapter: live.key.adapter,
|
|
1622
|
+
workspace: live.key.workspace,
|
|
1623
|
+
chat: live.key.chat,
|
|
1624
|
+
thread: live.key.thread,
|
|
1625
|
+
text: assistantText,
|
|
1626
|
+
},
|
|
1627
|
+
{ source: 'system' },
|
|
1628
|
+
)
|
|
1508
1629
|
if (!result.ok) {
|
|
1509
1630
|
logger.warn(`[channels] ${live.keyId}: recovery send failed: ${result.error}`)
|
|
1510
1631
|
}
|
|
@@ -1527,6 +1648,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1527
1648
|
return live.consecutiveSends.get(consecutiveSendKey(target.chat, target.thread)) ?? 0
|
|
1528
1649
|
}
|
|
1529
1650
|
|
|
1651
|
+
const getSendRate = (target: {
|
|
1652
|
+
adapter: ChannelKey['adapter']
|
|
1653
|
+
workspace: string
|
|
1654
|
+
chat: string
|
|
1655
|
+
thread?: string | null
|
|
1656
|
+
}): { count: number; windowMs: number } => {
|
|
1657
|
+
const keyId = channelKeyId({
|
|
1658
|
+
adapter: target.adapter,
|
|
1659
|
+
workspace: target.workspace,
|
|
1660
|
+
chat: target.chat,
|
|
1661
|
+
thread: target.thread ?? null,
|
|
1662
|
+
})
|
|
1663
|
+
const live = liveSessions.get(keyId)
|
|
1664
|
+
if (!live) return { count: 0, windowMs: SEND_RATE_WINDOW_MS }
|
|
1665
|
+
const sendKey = consecutiveSendKey(target.chat, target.thread)
|
|
1666
|
+
const buf = live.sendTimestamps.get(sendKey)
|
|
1667
|
+
if (!buf || buf.length === 0) return { count: 0, windowMs: SEND_RATE_WINDOW_MS }
|
|
1668
|
+
const cutoff = now() - SEND_RATE_WINDOW_MS
|
|
1669
|
+
let i = 0
|
|
1670
|
+
while (i < buf.length && buf[i]! <= cutoff) i++
|
|
1671
|
+
if (i > 0) buf.splice(0, i)
|
|
1672
|
+
return { count: buf.length, windowMs: SEND_RATE_WINDOW_MS }
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1530
1675
|
const tearDownLive = async (live: LiveSession): Promise<void> => {
|
|
1531
1676
|
live.destroyed = true
|
|
1532
1677
|
if (live.debounceTimer) clearTimeout(live.debounceTimer)
|
|
@@ -1585,6 +1730,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1585
1730
|
route,
|
|
1586
1731
|
send,
|
|
1587
1732
|
getConsecutiveSendCount,
|
|
1733
|
+
getSendRate,
|
|
1588
1734
|
registerOutbound,
|
|
1589
1735
|
unregisterOutbound,
|
|
1590
1736
|
registerTyping,
|
|
@@ -1759,6 +1905,26 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
|
|
|
1759
1905
|
return `${chat}:${thread ?? ''}`
|
|
1760
1906
|
}
|
|
1761
1907
|
|
|
1908
|
+
function normalizeSendText(text: string | undefined): string | undefined {
|
|
1909
|
+
if (text === undefined) return undefined
|
|
1910
|
+
if (text === '') return undefined
|
|
1911
|
+
return text
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function recordSendTimestamp(live: LiveSession, sendKey: string, ts: number): number {
|
|
1915
|
+
const buf = live.sendTimestamps.get(sendKey)
|
|
1916
|
+
const cutoff = ts - SEND_RATE_WINDOW_MS
|
|
1917
|
+
if (!buf) {
|
|
1918
|
+
live.sendTimestamps.set(sendKey, [ts])
|
|
1919
|
+
return 1
|
|
1920
|
+
}
|
|
1921
|
+
let i = 0
|
|
1922
|
+
while (i < buf.length && buf[i]! <= cutoff) i++
|
|
1923
|
+
if (i > 0) buf.splice(0, i)
|
|
1924
|
+
buf.push(ts)
|
|
1925
|
+
return buf.length
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1762
1928
|
function dmMembership(fetchedAt: number): MembershipCount {
|
|
1763
1929
|
return { humans: 1, bots: 1, fetchedAt, truncated: false }
|
|
1764
1930
|
}
|
package/src/channels/types.ts
CHANGED
|
@@ -84,7 +84,9 @@ export type OutboundMessage = {
|
|
|
84
84
|
attachments?: OutboundAttachment[]
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export type
|
|
87
|
+
export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected'
|
|
88
|
+
|
|
89
|
+
export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
|
|
88
90
|
|
|
89
91
|
export type OutboundCallback = (msg: OutboundMessage) => Promise<SendResult>
|
|
90
92
|
|