typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
channelsSchema,
|
|
3
|
+
llmCredentialSchema,
|
|
4
|
+
llmCredentialsSchema,
|
|
5
|
+
parseSecretsFile,
|
|
6
|
+
secretsFileSchema,
|
|
7
|
+
type LlmCredential,
|
|
8
|
+
type LlmCredentials,
|
|
9
|
+
type ParseSecretsResult,
|
|
10
|
+
type SecretsFile,
|
|
11
|
+
} from './schema'
|
|
12
|
+
|
|
13
|
+
export { createSecretsStoreForAgent, SecretsBackend } from './storage'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parseSecretsFile } from './schema'
|
|
5
|
+
|
|
6
|
+
const LEGACY_FILENAME = 'auth.json'
|
|
7
|
+
const TARGET_FILENAME = 'secrets.json'
|
|
8
|
+
|
|
9
|
+
// One-shot rename of an old agent folder's auth.json to secrets.json. Called
|
|
10
|
+
// from createSecretsStoreForAgent before the backend opens the file so the
|
|
11
|
+
// rest of the storage pipeline only ever sees secrets.json. The rename runs
|
|
12
|
+
// on every store construction because it's cheap (existsSync + early return
|
|
13
|
+
// in the common case) and the rename itself is the state — no flag file.
|
|
14
|
+
//
|
|
15
|
+
// Cases:
|
|
16
|
+
// 1. only auth.json exists -> renameSync to secrets.json
|
|
17
|
+
// 2. only secrets.json -> no-op
|
|
18
|
+
// 3. neither -> no-op (backend will seed secrets.json)
|
|
19
|
+
// 4. both exist, auth.json is the empty seed envelope -> unlink auth.json
|
|
20
|
+
// 5. both exist, secrets.json is the empty seed envelope -> renameSync auth.json over the empty seed
|
|
21
|
+
// 6. both exist, both carry credentials -> throw, refuse to merge
|
|
22
|
+
//
|
|
23
|
+
// The "both non-empty" hard error matters: if a user copied an old agent
|
|
24
|
+
// folder, edited auth.json by hand, AND a newer typeclaw later created
|
|
25
|
+
// secrets.json with real credentials, we don't know which is the source of
|
|
26
|
+
// truth. Loud failure beats silent merge.
|
|
27
|
+
export function migrateLegacyAuthJson(agentDir: string): void {
|
|
28
|
+
const legacyPath = join(agentDir, LEGACY_FILENAME)
|
|
29
|
+
const targetPath = join(agentDir, TARGET_FILENAME)
|
|
30
|
+
|
|
31
|
+
if (!existsSync(legacyPath)) return
|
|
32
|
+
|
|
33
|
+
if (!existsSync(targetPath)) {
|
|
34
|
+
renameWithRaceFallback(legacyPath, targetPath)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isEmptyEnvelope(legacyPath)) {
|
|
39
|
+
unlinkSync(legacyPath)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isEmptyEnvelope(targetPath)) {
|
|
44
|
+
// POSIX renameSync atomically replaces the destination; the empty
|
|
45
|
+
// secrets.json is the safer thing to lose vs an auth.json with
|
|
46
|
+
// credentials. Race-safe by the same reasoning as the no-target branch.
|
|
47
|
+
renameWithRaceFallback(legacyPath, targetPath)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Both ${LEGACY_FILENAME} and a non-empty ${TARGET_FILENAME} exist in ${agentDir}. ` +
|
|
53
|
+
`Inspect manually and remove the stale file before re-running.`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// renameSync is atomic per syscall, but two concurrent createSecretsStoreForAgent
|
|
58
|
+
// callers can both observe `auth.json` exists and `secrets.json` does not, then
|
|
59
|
+
// race on the rename. One wins; the other gets ENOENT because the legacy file
|
|
60
|
+
// is already gone. That's effectively a successful migration from the loser's
|
|
61
|
+
// POV — recheck the target and swallow the ENOENT.
|
|
62
|
+
function renameWithRaceFallback(from: string, to: string): void {
|
|
63
|
+
try {
|
|
64
|
+
renameSync(from, to)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT' && existsSync(to)) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// "Empty envelope" = no actual credentials. Parsed shape with empty llm and
|
|
74
|
+
// empty channels. We do NOT try to be clever about "approximately empty" —
|
|
75
|
+
// exact emptiness is the only safe auto-delete / auto-overwrite case.
|
|
76
|
+
function isEmptyEnvelope(path: string): boolean {
|
|
77
|
+
let raw: string
|
|
78
|
+
try {
|
|
79
|
+
raw = readFileSync(path, 'utf8')
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
if (raw.trim() === '') return true
|
|
84
|
+
|
|
85
|
+
let parsed: unknown
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(raw)
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = parseSecretsFile(parsed)
|
|
93
|
+
if (!result.ok) return false
|
|
94
|
+
return Object.keys(result.file.llm).length === 0 && Object.keys(result.file.channels).length === 0
|
|
95
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// The api_key shape exactly matches pi-coding-agent's ApiKeyCredential. We
|
|
4
|
+
// re-state it here (rather than import the upstream type into the schema)
|
|
5
|
+
// because Zod schemas are the source of truth for validation and JSON Schema
|
|
6
|
+
// generation, and pi-coding-agent does not export Zod schemas.
|
|
7
|
+
const llmApiKeyCredentialSchema = z.object({
|
|
8
|
+
type: z.literal('api_key'),
|
|
9
|
+
key: z.string().min(1),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
// pi-coding-agent's OAuth credential carries provider-specific fields
|
|
13
|
+
// (access, refresh, expires, plus arbitrary upstream additions). We accept
|
|
14
|
+
// them as a passthrough object so future upstream additions don't break parse.
|
|
15
|
+
// Upstream is the runtime authority on OAuth shape; our job here is only to
|
|
16
|
+
// route the slice safely through the file envelope.
|
|
17
|
+
const llmOAuthCredentialSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
type: z.literal('oauth'),
|
|
20
|
+
})
|
|
21
|
+
.catchall(z.unknown())
|
|
22
|
+
|
|
23
|
+
export const llmCredentialSchema = z.discriminatedUnion('type', [llmApiKeyCredentialSchema, llmOAuthCredentialSchema])
|
|
24
|
+
|
|
25
|
+
// Map keyed by provider id ("openai", "openai-codex", "fireworks", ...).
|
|
26
|
+
// Exactly the shape pi-coding-agent persists today as the entire secrets file.
|
|
27
|
+
export const llmCredentialsSchema = z.record(z.string(), llmCredentialSchema)
|
|
28
|
+
|
|
29
|
+
// Empty channels schema today; channel adapter tokens (Slack/Discord/Telegram)
|
|
30
|
+
// will move here in a follow-up plan. The catchall keeps forward compatibility
|
|
31
|
+
// when a future TypeClaw reads a file written by an even-newer TypeClaw.
|
|
32
|
+
export const channelsSchema = z.object({}).catchall(z.unknown())
|
|
33
|
+
|
|
34
|
+
export const secretsFileSchema = z.object({
|
|
35
|
+
$schema: z.string().optional(),
|
|
36
|
+
version: z.literal(1),
|
|
37
|
+
llm: llmCredentialsSchema.default({}),
|
|
38
|
+
channels: channelsSchema.default({}),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export type LlmCredential = z.infer<typeof llmCredentialSchema>
|
|
42
|
+
export type LlmCredentials = z.infer<typeof llmCredentialsSchema>
|
|
43
|
+
export type SecretsFile = z.infer<typeof secretsFileSchema>
|
|
44
|
+
|
|
45
|
+
export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
|
|
46
|
+
|
|
47
|
+
// parseSecretsFile recognises two shapes:
|
|
48
|
+
// 1. The new envelope: { version: 1, llm: {...}, channels: {...} }
|
|
49
|
+
// 2. The legacy flat shape pi-coding-agent writes today: a top-level
|
|
50
|
+
// Record<string, AuthCredential>. Treated as { version: 1, llm: <flat>,
|
|
51
|
+
// channels: {} } so existing OAuth users transparently upgrade on the
|
|
52
|
+
// next write that goes through this code path.
|
|
53
|
+
//
|
|
54
|
+
// An empty object {} is a legitimate legacy state — a freshly-created
|
|
55
|
+
// secrets file with no providers logged in yet. It upgrades cleanly to the
|
|
56
|
+
// new envelope with empty llm and channels.
|
|
57
|
+
export function parseSecretsFile(raw: unknown): ParseSecretsResult {
|
|
58
|
+
const direct = secretsFileSchema.safeParse(raw)
|
|
59
|
+
if (direct.success) return { ok: true, file: direct.data }
|
|
60
|
+
|
|
61
|
+
const legacy = llmCredentialsSchema.safeParse(raw)
|
|
62
|
+
if (legacy.success) {
|
|
63
|
+
return { ok: true, file: { version: 1, llm: legacy.data, channels: {} } }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Neither shape matched. We surface the new-shape error because that's the
|
|
67
|
+
// target the user is presumably moving toward; the legacy path is a quiet
|
|
68
|
+
// compatibility seam, not a documented format.
|
|
69
|
+
return { ok: false, reason: direct.error.issues.map(formatIssue).join('; ') }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
|
|
73
|
+
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
|
|
74
|
+
return `${path}: ${issue.message}`
|
|
75
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type AuthStorage,
|
|
6
|
+
type AuthStorageBackend,
|
|
7
|
+
AuthStorage as AuthStorageImpl,
|
|
8
|
+
} from '@mariozechner/pi-coding-agent'
|
|
9
|
+
import lockfile from 'proper-lockfile'
|
|
10
|
+
|
|
11
|
+
import { migrateLegacyAuthJson } from './migrate'
|
|
12
|
+
import { type SecretsFile, parseSecretsFile } from './schema'
|
|
13
|
+
|
|
14
|
+
const SCHEMA_REL = './node_modules/typeclaw/secrets.schema.json'
|
|
15
|
+
const FILE_MODE = 0o600
|
|
16
|
+
const DIR_MODE = 0o700
|
|
17
|
+
|
|
18
|
+
const SYNC_LOCK_RETRIES = 10
|
|
19
|
+
const SYNC_LOCK_DELAY_MS = 20
|
|
20
|
+
|
|
21
|
+
const ASYNC_LOCK_OPTIONS = {
|
|
22
|
+
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10000, randomize: true },
|
|
23
|
+
stale: 30000,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
// SecretsBackend implements pi-coding-agent's AuthStorageBackend contract
|
|
27
|
+
// while keeping TypeClaw in control of the on-disk file shape.
|
|
28
|
+
//
|
|
29
|
+
// Upstream's FileAuthStorageBackend assumes the entire file IS the
|
|
30
|
+
// AuthStorageData (a flat Record<string, AuthCredential>). TypeClaw needs the
|
|
31
|
+
// file to also carry version + channels alongside the LLM slice, so we wrap:
|
|
32
|
+
// every withLock cycle reads the full envelope, presents only file.llm to the
|
|
33
|
+
// AuthStorage instance as if it were the whole file, and merges the result
|
|
34
|
+
// back into the envelope on write.
|
|
35
|
+
//
|
|
36
|
+
// Locking and durability semantics mirror upstream's FileAuthStorageBackend:
|
|
37
|
+
// - proper-lockfile, sync version uses busy-loop retry on ELOCKED so callers
|
|
38
|
+
// stay synchronous (matching upstream's API contract)
|
|
39
|
+
// - parent directory created with 0o700, file written with 0o600
|
|
40
|
+
// - empty file is created on first access so proper-lockfile has something
|
|
41
|
+
// to lock against (it requires the target to exist)
|
|
42
|
+
//
|
|
43
|
+
// We additionally write atomically (temp + rename) for durability — upstream
|
|
44
|
+
// uses plain writeFileSync, but we own a richer envelope and a half-write
|
|
45
|
+
// would leave us with neither the old nor the new shape parseable.
|
|
46
|
+
export class SecretsBackend implements AuthStorageBackend {
|
|
47
|
+
constructor(private readonly secretsPath: string) {}
|
|
48
|
+
|
|
49
|
+
withLock<T>(fn: (current: string | undefined) => { result: T; next?: string }): T {
|
|
50
|
+
this.ensureParentDir()
|
|
51
|
+
this.ensureFileExists()
|
|
52
|
+
let release: (() => void) | undefined
|
|
53
|
+
try {
|
|
54
|
+
release = this.acquireSyncLockWithRetry()
|
|
55
|
+
const envelope = this.readEnvelope()
|
|
56
|
+
const innerCurrent = JSON.stringify(envelope.llm, null, 2)
|
|
57
|
+
|
|
58
|
+
const { result, next } = fn(innerCurrent)
|
|
59
|
+
if (next !== undefined) {
|
|
60
|
+
const merged = mergeLlmIntoEnvelope(envelope, next)
|
|
61
|
+
this.writeEnvelopeAtomic(merged)
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
} finally {
|
|
65
|
+
release?.()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async withLockAsync<T>(fn: (current: string | undefined) => Promise<{ result: T; next?: string }>): Promise<T> {
|
|
70
|
+
this.ensureParentDir()
|
|
71
|
+
this.ensureFileExists()
|
|
72
|
+
let release: (() => Promise<void>) | undefined
|
|
73
|
+
let lockCompromised = false
|
|
74
|
+
let lockCompromisedError: Error | undefined
|
|
75
|
+
const throwIfCompromised = (): void => {
|
|
76
|
+
if (lockCompromised) {
|
|
77
|
+
throw lockCompromisedError ?? new Error('Secrets store lock was compromised')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
release = await lockfile.lock(this.secretsPath, {
|
|
82
|
+
...ASYNC_LOCK_OPTIONS,
|
|
83
|
+
onCompromised: (err: Error) => {
|
|
84
|
+
lockCompromised = true
|
|
85
|
+
lockCompromisedError = err
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
throwIfCompromised()
|
|
89
|
+
const envelope = this.readEnvelope()
|
|
90
|
+
const innerCurrent = JSON.stringify(envelope.llm, null, 2)
|
|
91
|
+
|
|
92
|
+
const { result, next } = await fn(innerCurrent)
|
|
93
|
+
throwIfCompromised()
|
|
94
|
+
if (next !== undefined) {
|
|
95
|
+
const merged = mergeLlmIntoEnvelope(envelope, next)
|
|
96
|
+
this.writeEnvelopeAtomic(merged)
|
|
97
|
+
}
|
|
98
|
+
throwIfCompromised()
|
|
99
|
+
return result
|
|
100
|
+
} finally {
|
|
101
|
+
if (release) {
|
|
102
|
+
try {
|
|
103
|
+
await release()
|
|
104
|
+
} catch {
|
|
105
|
+
// Ignore unlock errors when the lock is compromised — there is
|
|
106
|
+
// nothing useful we can do, and surfacing the secondary error would
|
|
107
|
+
// mask the primary failure (mirrors upstream behaviour).
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private ensureParentDir(): void {
|
|
114
|
+
const dir = dirname(this.secretsPath)
|
|
115
|
+
if (!existsSync(dir)) {
|
|
116
|
+
mkdirSync(dir, { recursive: true, mode: DIR_MODE })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// proper-lockfile requires the target to exist before locking. We seed an
|
|
121
|
+
// empty new-shape envelope so the very first call has something to lock,
|
|
122
|
+
// and so the file is parseable by a third-party reader even before the
|
|
123
|
+
// first credential is written.
|
|
124
|
+
private ensureFileExists(): void {
|
|
125
|
+
if (existsSync(this.secretsPath)) return
|
|
126
|
+
const seed = newEmptyEnvelope()
|
|
127
|
+
writeFileSync(this.secretsPath, stringifyEnvelope(seed), 'utf8')
|
|
128
|
+
chmodSync(this.secretsPath, FILE_MODE)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private acquireSyncLockWithRetry(): () => void {
|
|
132
|
+
let lastError: unknown
|
|
133
|
+
for (let attempt = 1; attempt <= SYNC_LOCK_RETRIES; attempt++) {
|
|
134
|
+
try {
|
|
135
|
+
return lockfile.lockSync(this.secretsPath, { realpath: false })
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const code =
|
|
138
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
139
|
+
? String((error as { code: unknown }).code)
|
|
140
|
+
: undefined
|
|
141
|
+
if (code !== 'ELOCKED' || attempt === SYNC_LOCK_RETRIES) throw error
|
|
142
|
+
lastError = error
|
|
143
|
+
// Busy-wait so the call stays synchronous. Matches upstream's
|
|
144
|
+
// FileAuthStorageBackend.acquireLockSyncWithRetry.
|
|
145
|
+
const start = Date.now()
|
|
146
|
+
while (Date.now() - start < SYNC_LOCK_DELAY_MS) {
|
|
147
|
+
// intentionally empty
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw (lastError as Error | undefined) ?? new Error('Failed to acquire secrets store lock')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private readEnvelope(): SecretsFile {
|
|
155
|
+
const raw = existsSync(this.secretsPath) ? readFileSync(this.secretsPath, 'utf8') : ''
|
|
156
|
+
if (!raw.trim()) return newEmptyEnvelope()
|
|
157
|
+
let parsed: unknown
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(raw)
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new Error(`secrets file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`)
|
|
162
|
+
}
|
|
163
|
+
const result = parseSecretsFile(parsed)
|
|
164
|
+
if (!result.ok) {
|
|
165
|
+
throw new Error(`secrets file is not a valid TypeClaw secrets file: ${result.reason}`)
|
|
166
|
+
}
|
|
167
|
+
return result.file
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Atomic temp+rename, same pattern as src/hostd/daemon.ts:persistRegistration.
|
|
171
|
+
// The temp file lives in the same directory so rename is intra-filesystem.
|
|
172
|
+
private writeEnvelopeAtomic(envelope: SecretsFile): void {
|
|
173
|
+
const tmp = `${this.secretsPath}.${process.pid}.${Date.now()}.tmp`
|
|
174
|
+
writeFileSync(tmp, stringifyEnvelope(envelope), { encoding: 'utf8', mode: FILE_MODE })
|
|
175
|
+
try {
|
|
176
|
+
renameSync(tmp, this.secretsPath)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
try {
|
|
179
|
+
unlinkSync(tmp)
|
|
180
|
+
} catch {
|
|
181
|
+
// best-effort cleanup of the temp file when rename fails
|
|
182
|
+
}
|
|
183
|
+
throw err
|
|
184
|
+
}
|
|
185
|
+
chmodSync(this.secretsPath, FILE_MODE)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// createSecretsStoreForAgent is the single seam every TypeClaw caller should
|
|
190
|
+
// use to obtain an AuthStorage tied to an agent folder's secrets file. Keeps
|
|
191
|
+
// the upstream constructor (AuthStorage.fromStorage) usage isolated to one
|
|
192
|
+
// module so a future change to upstream wiring only touches this file.
|
|
193
|
+
//
|
|
194
|
+
// Performs the one-shot auth.json -> secrets.json rename before opening the
|
|
195
|
+
// backend, so callers never observe the legacy filename even on agents that
|
|
196
|
+
// pre-date the rename.
|
|
197
|
+
export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
|
|
198
|
+
migrateLegacyAuthJson(dirname(secretsPath))
|
|
199
|
+
return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function newEmptyEnvelope(): SecretsFile {
|
|
203
|
+
return { $schema: SCHEMA_REL, version: 1, llm: {}, channels: {} }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stringifyEnvelope(envelope: SecretsFile): string {
|
|
207
|
+
return `${JSON.stringify(envelope, null, 2)}\n`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function mergeLlmIntoEnvelope(envelope: SecretsFile, nextLlmJson: string): SecretsFile {
|
|
211
|
+
let parsed: unknown
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(nextLlmJson)
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`AuthStorage produced invalid JSON for the llm slice: ${err instanceof Error ? err.message : String(err)}`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
if (!isPlainObject(parsed)) {
|
|
220
|
+
throw new Error('AuthStorage produced a non-object llm slice')
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
...envelope,
|
|
224
|
+
$schema: envelope.$schema ?? SCHEMA_REL,
|
|
225
|
+
llm: parsed as SecretsFile['llm'],
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
230
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
231
|
+
}
|