typeclaw 0.27.0 → 0.28.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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +103 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +2 -0
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/config/config.ts +19 -288
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
package/src/config/config.ts
CHANGED
|
@@ -249,8 +249,7 @@ export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObje
|
|
|
249
249
|
export type GitignoreConfig = z.infer<typeof gitignoreSchema>
|
|
250
250
|
|
|
251
251
|
// Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
|
|
252
|
-
// and leaves room for future siblings (e.g. `git.attributes`).
|
|
253
|
-
// migration also handles the rename of legacy top-level `gitignore`.
|
|
252
|
+
// and leaves room for future siblings (e.g. `git.attributes`).
|
|
254
253
|
export const gitSchema = z
|
|
255
254
|
.object({
|
|
256
255
|
ignore: gitignoreSchema,
|
|
@@ -783,34 +782,17 @@ export function loadConfigSync(cwd: string): Config {
|
|
|
783
782
|
return result.data
|
|
784
783
|
}
|
|
785
784
|
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
// `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
|
|
791
|
-
//
|
|
792
|
-
// Precedence when both legacy and new keys coexist: the new shape wins and
|
|
793
|
-
// the legacy key is dropped silently. Two ways this happens in practice:
|
|
794
|
-
// 1. User hand-edited the new shape after auto-migration but forgot to
|
|
795
|
-
// delete the legacy key.
|
|
796
|
-
// 2. Two `typeclaw start` invocations raced on a stale checkout.
|
|
797
|
-
// Either way, the new shape is the source of truth — losing the legacy
|
|
798
|
-
// duplicate is the right call because it would otherwise be shadowed at
|
|
799
|
-
// parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
|
|
785
|
+
// Strips a `channels.github.eventAllowlist` that deep-equals a value `channel
|
|
786
|
+
// add` / `init` previously seeded verbatim, so the config re-tracks the shipped
|
|
787
|
+
// default. Called from every entry point that reads `typeclaw.json` so the rest
|
|
788
|
+
// of the pipeline only ever sees the canonical shape.
|
|
800
789
|
//
|
|
801
790
|
// The returned `applied` array names each migration step that fired, so
|
|
802
|
-
// callers in `typeclaw start` can build a meaningful git commit message
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
export type MigrationStep =
|
|
807
|
-
| { kind: 'dockerfile-to-docker-file' }
|
|
808
|
-
| { kind: 'gitignore-to-git-ignore' }
|
|
809
|
-
| { kind: 'channels-allow-to-roles-member-match'; rules: string[]; dropped: string[] }
|
|
810
|
-
| { kind: 'strip-permissions-gate-channel-respond' }
|
|
811
|
-
| { kind: 'model-to-models'; ref: string }
|
|
812
|
-
| { kind: 'drop-stale-model'; ref: string }
|
|
813
|
-
| { kind: 'drop-github-seeded-event-allowlist' }
|
|
791
|
+
// callers in `typeclaw start` can build a meaningful git commit message.
|
|
792
|
+
// `changed` is the boolean equivalent of `applied.length > 0` and is preserved
|
|
793
|
+
// for back-compat with the many call sites that only care whether ANY rewrite
|
|
794
|
+
// happened.
|
|
795
|
+
export type MigrationStep = { kind: 'drop-github-seeded-event-allowlist' }
|
|
814
796
|
|
|
815
797
|
export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
|
|
816
798
|
|
|
@@ -820,86 +802,13 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
|
|
|
820
802
|
}
|
|
821
803
|
|
|
822
804
|
const obj = json as Record<string, unknown>
|
|
823
|
-
const hasLegacyDockerfile = 'dockerfile' in obj
|
|
824
|
-
const hasLegacyGitignore = 'gitignore' in obj
|
|
825
|
-
const channelsAllowMigration = collectChannelsAllowMigration(obj)
|
|
826
|
-
const hasLegacyGateChannelRespond = isPlainObject(obj.permissions) && 'gateChannelRespond' in obj.permissions
|
|
827
|
-
// The pre-multi-model schema had a top-level `model: KnownModelRef` and no
|
|
828
|
-
// `models` key. Detecting the legacy shape requires both: `model` present
|
|
829
|
-
// AND `models` absent. If both coexist (user hand-edited after auto-migrate
|
|
830
|
-
// but didn't delete the legacy key), `models` wins and `model` is dropped
|
|
831
|
-
// silently — same precedence rule as the dockerfile/gitignore migrations.
|
|
832
|
-
const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
|
|
833
|
-
const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
|
|
834
805
|
const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
|
|
835
|
-
if (
|
|
836
|
-
!hasLegacyDockerfile &&
|
|
837
|
-
!hasLegacyGitignore &&
|
|
838
|
-
!channelsAllowMigration.found &&
|
|
839
|
-
!hasLegacyGateChannelRespond &&
|
|
840
|
-
!hasLegacyModel &&
|
|
841
|
-
!hasStaleModelAlongsideModels &&
|
|
842
|
-
!hasSeededGithubEventAllowlist
|
|
843
|
-
) {
|
|
806
|
+
if (!hasSeededGithubEventAllowlist) {
|
|
844
807
|
return { json, changed: false, applied: [] }
|
|
845
808
|
}
|
|
846
809
|
|
|
847
810
|
const applied: MigrationStep[] = []
|
|
848
811
|
const next: Record<string, unknown> = { ...obj }
|
|
849
|
-
if (hasLegacyDockerfile) {
|
|
850
|
-
const legacy = next.dockerfile
|
|
851
|
-
delete next.dockerfile
|
|
852
|
-
if (!('docker' in next)) {
|
|
853
|
-
next.docker = { file: legacy }
|
|
854
|
-
} else if (isPlainObject(next.docker) && !('file' in next.docker)) {
|
|
855
|
-
next.docker = { ...next.docker, file: legacy }
|
|
856
|
-
}
|
|
857
|
-
applied.push({ kind: 'dockerfile-to-docker-file' })
|
|
858
|
-
}
|
|
859
|
-
if (hasLegacyGitignore) {
|
|
860
|
-
const legacy = next.gitignore
|
|
861
|
-
delete next.gitignore
|
|
862
|
-
if (!('git' in next)) {
|
|
863
|
-
next.git = { ignore: legacy }
|
|
864
|
-
} else if (isPlainObject(next.git) && !('ignore' in next.git)) {
|
|
865
|
-
next.git = { ...next.git, ignore: legacy }
|
|
866
|
-
}
|
|
867
|
-
applied.push({ kind: 'gitignore-to-git-ignore' })
|
|
868
|
-
}
|
|
869
|
-
if (channelsAllowMigration.found) {
|
|
870
|
-
applyChannelsAllowMigration(next, channelsAllowMigration)
|
|
871
|
-
applied.push({
|
|
872
|
-
kind: 'channels-allow-to-roles-member-match',
|
|
873
|
-
rules: channelsAllowMigration.rules,
|
|
874
|
-
dropped: channelsAllowMigration.warnings,
|
|
875
|
-
})
|
|
876
|
-
}
|
|
877
|
-
if (hasLegacyGateChannelRespond) {
|
|
878
|
-
const perms = { ...(next.permissions as Record<string, unknown>) }
|
|
879
|
-
delete perms.gateChannelRespond
|
|
880
|
-
if (Object.keys(perms).length === 0) {
|
|
881
|
-
delete next.permissions
|
|
882
|
-
} else {
|
|
883
|
-
next.permissions = perms
|
|
884
|
-
}
|
|
885
|
-
applied.push({ kind: 'strip-permissions-gate-channel-respond' })
|
|
886
|
-
}
|
|
887
|
-
if (hasLegacyModel) {
|
|
888
|
-
const ref = next.model as string
|
|
889
|
-
delete next.model
|
|
890
|
-
next.models = { default: ref }
|
|
891
|
-
applied.push({ kind: 'model-to-models', ref })
|
|
892
|
-
} else if (hasStaleModelAlongsideModels) {
|
|
893
|
-
// `models` wins (per the same precedence rule as dockerfile/gitignore), but
|
|
894
|
-
// the drop is still a tracked migration step so the disk rewrite gets a
|
|
895
|
-
// commit instead of silently dirtying the worktree. Without this, the
|
|
896
|
-
// file would be rewritten by persistMigratedConfig and no commit would
|
|
897
|
-
// fire (buildConfigMigrationCommitMessage returns null for empty applied
|
|
898
|
-
// lists), contradicting the invariant in persistMigratedConfig's comment.
|
|
899
|
-
const ref = typeof next.model === 'string' ? next.model : ''
|
|
900
|
-
delete next.model
|
|
901
|
-
applied.push({ kind: 'drop-stale-model', ref })
|
|
902
|
-
}
|
|
903
812
|
if (hasSeededGithubEventAllowlist) {
|
|
904
813
|
dropSeededGithubEventAllowlist(next)
|
|
905
814
|
applied.push({ kind: 'drop-github-seeded-event-allowlist' })
|
|
@@ -937,12 +846,6 @@ function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
|
|
|
937
846
|
return true
|
|
938
847
|
}
|
|
939
848
|
|
|
940
|
-
// Builds a meaningful one-line git commit subject for a typeclaw.json
|
|
941
|
-
// migration. Single-step migrations get a specific subject; multi-step ones
|
|
942
|
-
// fall back to a stable summary subject with the count. The body (after the
|
|
943
|
-
// blank line) enumerates each step so `git log -p typeclaw.json` is an
|
|
944
|
-
// auditable trail of what legacy shapes the agent has graduated from.
|
|
945
|
-
//
|
|
946
849
|
// Returns null when no steps were applied — callers should not commit in
|
|
947
850
|
// that case. Keeping the null branch here (vs an empty string) makes the
|
|
948
851
|
// "nothing happened" case impossible to misuse at the call site.
|
|
@@ -950,42 +853,13 @@ export function buildConfigMigrationCommitMessage(applied: readonly MigrationSte
|
|
|
950
853
|
const first = applied[0]
|
|
951
854
|
if (first === undefined) return null
|
|
952
855
|
|
|
953
|
-
const subject =
|
|
954
|
-
applied.length === 1
|
|
955
|
-
? `typeclaw.json: ${shortStepLabel(first)}`
|
|
956
|
-
: `typeclaw.json: migrate legacy shape (${applied.length} steps)`
|
|
957
|
-
|
|
856
|
+
const subject = `typeclaw.json: ${shortStepLabel(first)}`
|
|
958
857
|
const bodyLines: string[] = applied.map((step) => `- ${describeStep(step)}`)
|
|
959
|
-
|
|
960
|
-
// Surface dropped rules in the commit body so a user inspecting `git log -p`
|
|
961
|
-
// sees exactly which legacy entries had to be hand-re-added (the lossy
|
|
962
|
-
// `channel:<id>` case). Without this, the silent-drop is invisible after
|
|
963
|
-
// the fact.
|
|
964
|
-
for (const step of applied) {
|
|
965
|
-
if (step.kind === 'channels-allow-to-roles-member-match' && step.dropped.length > 0) {
|
|
966
|
-
for (const warning of step.dropped) {
|
|
967
|
-
bodyLines.push(` warning: ${warning}`)
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
858
|
return `${subject}\n\n${bodyLines.join('\n')}\n`
|
|
973
859
|
}
|
|
974
860
|
|
|
975
861
|
function shortStepLabel(step: MigrationStep): string {
|
|
976
862
|
switch (step.kind) {
|
|
977
|
-
case 'dockerfile-to-docker-file':
|
|
978
|
-
return 'lift dockerfile → docker.file'
|
|
979
|
-
case 'gitignore-to-git-ignore':
|
|
980
|
-
return 'lift gitignore → git.ignore'
|
|
981
|
-
case 'channels-allow-to-roles-member-match':
|
|
982
|
-
return 'lift channels.<adapter>.allow[] → roles.member.match[]'
|
|
983
|
-
case 'strip-permissions-gate-channel-respond':
|
|
984
|
-
return 'drop permissions.gateChannelRespond'
|
|
985
|
-
case 'model-to-models':
|
|
986
|
-
return 'lift model → models.default'
|
|
987
|
-
case 'drop-stale-model':
|
|
988
|
-
return 'drop stale legacy model alongside models'
|
|
989
863
|
case 'drop-github-seeded-event-allowlist':
|
|
990
864
|
return 'drop seeded channels.github.eventAllowlist'
|
|
991
865
|
}
|
|
@@ -993,152 +867,11 @@ function shortStepLabel(step: MigrationStep): string {
|
|
|
993
867
|
|
|
994
868
|
function describeStep(step: MigrationStep): string {
|
|
995
869
|
switch (step.kind) {
|
|
996
|
-
case 'dockerfile-to-docker-file':
|
|
997
|
-
return 'lift top-level dockerfile into docker.file'
|
|
998
|
-
case 'gitignore-to-git-ignore':
|
|
999
|
-
return 'lift top-level gitignore into git.ignore'
|
|
1000
|
-
case 'channels-allow-to-roles-member-match': {
|
|
1001
|
-
if (step.rules.length === 0) {
|
|
1002
|
-
return 'strip channels.<adapter>.allow[] (no translatable rules)'
|
|
1003
|
-
}
|
|
1004
|
-
return `lift channels.<adapter>.allow[] → roles.member.match[]: ${step.rules.join(', ')}`
|
|
1005
|
-
}
|
|
1006
|
-
case 'strip-permissions-gate-channel-respond':
|
|
1007
|
-
return 'drop permissions.gateChannelRespond (removed key)'
|
|
1008
|
-
case 'model-to-models':
|
|
1009
|
-
return `lift top-level model into models.default: ${step.ref}`
|
|
1010
|
-
case 'drop-stale-model':
|
|
1011
|
-
return step.ref !== ''
|
|
1012
|
-
? `drop stale top-level model (${step.ref}) — models block takes precedence`
|
|
1013
|
-
: 'drop stale top-level model — models block takes precedence'
|
|
1014
870
|
case 'drop-github-seeded-event-allowlist':
|
|
1015
871
|
return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
|
|
1016
872
|
}
|
|
1017
873
|
}
|
|
1018
874
|
|
|
1019
|
-
// Channels.<adapter>.allow[] → roles.member.match[] migration.
|
|
1020
|
-
//
|
|
1021
|
-
// Phase 3 removes the per-adapter allow-list and unifies wake-up gating
|
|
1022
|
-
// through `roles.member.match[]` + the `channel.respond` permission. This
|
|
1023
|
-
// helper translates legacy `allow` entries into canonical match-rule DSL
|
|
1024
|
-
// strings and appends them (deduplicated, preserving declaration order)
|
|
1025
|
-
// to `roles.member.match[]`. The `allow` field is then stripped from each
|
|
1026
|
-
// adapter block; the block survives — only the field is gone.
|
|
1027
|
-
//
|
|
1028
|
-
// `channel:<id>` rules cannot round-trip (the DSL forbids
|
|
1029
|
-
// wildcard-workspace + specific-chat) and are dropped with a warning. All
|
|
1030
|
-
// other shapes translate losslessly per the table in match-rule.ts.
|
|
1031
|
-
type ChannelsAllowMigration = {
|
|
1032
|
-
found: boolean
|
|
1033
|
-
rules: string[]
|
|
1034
|
-
warnings: string[]
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function collectChannelsAllowMigration(obj: Record<string, unknown>): ChannelsAllowMigration {
|
|
1038
|
-
const out: ChannelsAllowMigration = { found: false, rules: [], warnings: [] }
|
|
1039
|
-
const channels = obj.channels
|
|
1040
|
-
if (!isPlainObject(channels)) return out
|
|
1041
|
-
for (const [adapter, value] of Object.entries(channels)) {
|
|
1042
|
-
if (!isPlainObject(value)) continue
|
|
1043
|
-
if (!('allow' in value)) continue
|
|
1044
|
-
out.found = true
|
|
1045
|
-
const allow = value.allow
|
|
1046
|
-
if (!Array.isArray(allow)) continue
|
|
1047
|
-
for (const entry of allow) {
|
|
1048
|
-
if (typeof entry !== 'string') continue
|
|
1049
|
-
const translated = translateLegacyAllowRule(entry)
|
|
1050
|
-
if (translated.kind === 'rule') {
|
|
1051
|
-
out.rules.push(translated.value)
|
|
1052
|
-
} else {
|
|
1053
|
-
out.warnings.push(`channels.${adapter}.allow[]: dropped '${entry}' (${translated.reason})`)
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
return out
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function applyChannelsAllowMigration(next: Record<string, unknown>, migration: ChannelsAllowMigration): void {
|
|
1061
|
-
const channels = next.channels
|
|
1062
|
-
if (isPlainObject(channels)) {
|
|
1063
|
-
const updated: Record<string, unknown> = {}
|
|
1064
|
-
for (const [adapter, value] of Object.entries(channels)) {
|
|
1065
|
-
if (isPlainObject(value) && 'allow' in value) {
|
|
1066
|
-
const { allow: _allow, ...rest } = value
|
|
1067
|
-
updated[adapter] = rest
|
|
1068
|
-
} else {
|
|
1069
|
-
updated[adapter] = value
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
next.channels = updated
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
if (migration.rules.length === 0) {
|
|
1076
|
-
for (const warning of migration.warnings) {
|
|
1077
|
-
console.warn(`[config] ${warning}`)
|
|
1078
|
-
}
|
|
1079
|
-
return
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
const roles = isPlainObject(next.roles) ? { ...next.roles } : {}
|
|
1083
|
-
const member = isPlainObject(roles.member) ? { ...roles.member } : {}
|
|
1084
|
-
const existingMatch = Array.isArray(member.match)
|
|
1085
|
-
? (member.match as unknown[]).filter((m) => typeof m === 'string')
|
|
1086
|
-
: []
|
|
1087
|
-
const seen = new Set<string>(existingMatch as string[])
|
|
1088
|
-
const merged = [...(existingMatch as string[])]
|
|
1089
|
-
for (const rule of migration.rules) {
|
|
1090
|
-
if (!seen.has(rule)) {
|
|
1091
|
-
seen.add(rule)
|
|
1092
|
-
merged.push(rule)
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
member.match = merged
|
|
1096
|
-
roles.member = member
|
|
1097
|
-
next.roles = roles
|
|
1098
|
-
|
|
1099
|
-
console.warn(`[config] migrated channels.<adapter>.allow[] -> roles.member.match[]: ${migration.rules.join(', ')}`)
|
|
1100
|
-
for (const warning of migration.warnings) {
|
|
1101
|
-
console.warn(`[config] ${warning}`)
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
type TranslatedRule = { kind: 'rule'; value: string } | { kind: 'drop'; reason: string }
|
|
1106
|
-
|
|
1107
|
-
function translateLegacyAllowRule(rule: string): TranslatedRule {
|
|
1108
|
-
// Already canonical / cross-platform.
|
|
1109
|
-
if (rule === '*') return { kind: 'rule', value: '*' }
|
|
1110
|
-
if (rule.startsWith('kakao:')) return { kind: 'rule', value: rule }
|
|
1111
|
-
|
|
1112
|
-
// Discord: guild → discord, dm → discord:dm.
|
|
1113
|
-
if (rule === 'guild:*') return { kind: 'rule', value: 'discord:*' }
|
|
1114
|
-
if (rule.startsWith('guild:')) return { kind: 'rule', value: `discord:${rule.slice('guild:'.length)}` }
|
|
1115
|
-
if (rule === 'dm:*') return { kind: 'rule', value: 'discord:dm/*' }
|
|
1116
|
-
if (rule.startsWith('dm:')) return { kind: 'rule', value: `discord:dm/${rule.slice('dm:'.length)}` }
|
|
1117
|
-
|
|
1118
|
-
// Slack: team → slack, im → slack:dm.
|
|
1119
|
-
if (rule === 'team:*') return { kind: 'rule', value: 'slack:*' }
|
|
1120
|
-
if (rule.startsWith('team:')) return { kind: 'rule', value: `slack:${rule.slice('team:'.length)}` }
|
|
1121
|
-
if (rule === 'im:*') return { kind: 'rule', value: 'slack:dm/*' }
|
|
1122
|
-
if (rule.startsWith('im:')) return { kind: 'rule', value: `slack:dm/${rule.slice('im:'.length)}` }
|
|
1123
|
-
|
|
1124
|
-
// Telegram: tg → telegram.
|
|
1125
|
-
if (rule === 'tg:*') return { kind: 'rule', value: 'telegram:*' }
|
|
1126
|
-
if (rule.startsWith('tg:')) return { kind: 'rule', value: `telegram:${rule.slice('tg:'.length)}` }
|
|
1127
|
-
|
|
1128
|
-
// `channel:<id>` had no workspace; canonical DSL rejects wildcard
|
|
1129
|
-
// workspace + specific chat. Drop with a warning so the operator knows
|
|
1130
|
-
// to re-add the rule explicitly with a workspace coordinate.
|
|
1131
|
-
if (rule.startsWith('channel:')) {
|
|
1132
|
-
return {
|
|
1133
|
-
kind: 'drop',
|
|
1134
|
-
reason:
|
|
1135
|
-
'channel:<id> rules require an explicit workspace under the new DSL; re-add as discord:<guild>/<id> or slack:<team>/<id>',
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
return { kind: 'drop', reason: `unrecognized legacy allow shape '${rule}'` }
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
875
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1143
876
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1144
877
|
}
|
|
@@ -1156,18 +889,16 @@ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly Mig
|
|
|
1156
889
|
}
|
|
1157
890
|
|
|
1158
891
|
// Pair the disk rewrite with a git commit so the agent folder is never
|
|
1159
|
-
// silently dirty after a
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
892
|
+
// silently dirty after a migration. typeclaw.json is in git's "tracked"
|
|
893
|
+
// category (unlike Dockerfile, which is regenerated on every start and
|
|
894
|
+
// intentionally gitignored), so an uncommitted rewrite gets mixed into
|
|
895
|
+
// unrelated commits the moment any other tool touches the repo.
|
|
896
|
+
// commitSystemFileSync no-ops on non-git folders, missing Bun, and clean
|
|
897
|
+
// files, so canonical-shape reads pay zero cost.
|
|
1165
898
|
//
|
|
1166
899
|
// Called from every entry point that reads typeclaw.json (host CLI,
|
|
1167
900
|
// hostd daemon, container runtime) so the commit follows the rewrite
|
|
1168
|
-
// wherever it happens — not only from `typeclaw start`.
|
|
1169
|
-
// design that committed only in start() missed the long-running hostd
|
|
1170
|
-
// daemon, doctor, tui, reload, and compose paths.
|
|
901
|
+
// wherever it happens — not only from `typeclaw start`.
|
|
1171
902
|
const message = buildConfigMigrationCommitMessage(applied)
|
|
1172
903
|
if (message !== null) {
|
|
1173
904
|
commitSystemFileSync(cwd, CONFIG_FILE, message)
|
package/src/container/start.ts
CHANGED
|
@@ -22,7 +22,6 @@ import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
|
|
|
22
22
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
23
23
|
import { refreshPackageJson } from '@/init/packagejson'
|
|
24
24
|
import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
|
|
25
|
-
import { migrateKakaotalkCredentials } from '@/secrets'
|
|
26
25
|
|
|
27
26
|
import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
|
|
28
27
|
import {
|
|
@@ -258,7 +257,6 @@ export async function start({
|
|
|
258
257
|
// ensures the base image's CLI version matches the runtime the
|
|
259
258
|
// container will actually load.
|
|
260
259
|
const dockerfileRefresh = await refreshDockerfile(cwd)
|
|
261
|
-
await migrateKakaotalkCredentials(cwd)
|
|
262
260
|
|
|
263
261
|
if (state.exists) {
|
|
264
262
|
// Container holds the name but is not running. Without `--rm`, this is
|
package/src/cron/index.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { readFile
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import type { SubagentRegistry } from '@/agent/subagents'
|
|
6
|
-
import { commitSystemFile } from '@/git/system-commit'
|
|
7
6
|
|
|
8
|
-
import {
|
|
9
|
-
buildCronMigrationCommitMessage,
|
|
10
|
-
type CronFile,
|
|
11
|
-
type CronMigrationStep,
|
|
12
|
-
migrateLegacyCronShape,
|
|
13
|
-
parseCronFile,
|
|
14
|
-
} from './schema'
|
|
7
|
+
import { type CronFile, parseCronFile } from './schema'
|
|
15
8
|
|
|
16
9
|
export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
|
|
17
10
|
export {
|
|
@@ -31,16 +24,12 @@ export {
|
|
|
31
24
|
} from './scheduler'
|
|
32
25
|
export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
|
|
33
26
|
export {
|
|
34
|
-
buildCronMigrationCommitMessage,
|
|
35
27
|
cronFileSchema,
|
|
36
28
|
cronJobSchema,
|
|
37
29
|
type CronFile,
|
|
38
30
|
type CronJob,
|
|
39
|
-
type CronMigrationResult,
|
|
40
|
-
type CronMigrationStep,
|
|
41
31
|
type ExecJob,
|
|
42
32
|
type HandlerJob,
|
|
43
|
-
migrateLegacyCronShape,
|
|
44
33
|
parseCronJson,
|
|
45
34
|
type ParseCronJsonOptions,
|
|
46
35
|
type ParseCronResult,
|
|
@@ -54,12 +43,6 @@ export type LoadCronResult = { ok: true; file: CronFile | null } | { ok: false;
|
|
|
54
43
|
|
|
55
44
|
export type LoadCronOptions = {
|
|
56
45
|
subagents?: SubagentRegistry
|
|
57
|
-
// When true (the default), legacy-shape migrations are written back
|
|
58
|
-
// to cron.json on disk and committed by the system-commit helper.
|
|
59
|
-
// Read-only inspection callers must pass `false` so an unaware
|
|
60
|
-
// `typeclaw cron list` against a legacy file does not produce a
|
|
61
|
-
// commit on whatever branch the user happens to be on.
|
|
62
|
-
persistMigrations?: boolean
|
|
63
46
|
}
|
|
64
47
|
|
|
65
48
|
export async function loadCron(agentDir: string, options: LoadCronOptions = {}): Promise<LoadCronResult> {
|
|
@@ -80,36 +63,12 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
|
|
|
80
63
|
return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
|
|
81
64
|
}
|
|
82
65
|
|
|
83
|
-
const
|
|
84
|
-
const persistMigrations = options.persistMigrations ?? true
|
|
85
|
-
if (migrated.changed && persistMigrations) {
|
|
86
|
-
await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
66
|
+
const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
90
67
|
if (!result.ok) return { ok: false, reason: result.reason }
|
|
91
68
|
|
|
92
69
|
return { ok: true, file: result.file }
|
|
93
70
|
}
|
|
94
71
|
|
|
95
|
-
async function persistMigratedCron(
|
|
96
|
-
path: string,
|
|
97
|
-
json: unknown,
|
|
98
|
-
agentDir: string,
|
|
99
|
-
applied: readonly CronMigrationStep[],
|
|
100
|
-
): Promise<void> {
|
|
101
|
-
try {
|
|
102
|
-
await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
|
|
103
|
-
} catch {
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const message = buildCronMigrationCommitMessage(applied)
|
|
108
|
-
if (message !== null) {
|
|
109
|
-
await commitSystemFile(agentDir, CRON_FILE, message)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
72
|
function errorMessage(err: unknown): string {
|
|
114
73
|
return err instanceof Error ? err.message : String(err)
|
|
115
74
|
}
|
package/src/cron/schema.ts
CHANGED
|
@@ -64,99 +64,7 @@ export type ParseCronOptions = {
|
|
|
64
64
|
subagents?: SubagentRegistry
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
// `scheduledByRole` became mandatory on every job. The schema gate
|
|
69
|
-
// (`parseCronFile`) rejects legacy entries with a precise remediation
|
|
70
|
-
// message, but rejecting on every container boot is a stuck state for
|
|
71
|
-
// the user — the agent crashes in a tight restart loop with no path
|
|
72
|
-
// forward except hand-editing cron.json.
|
|
73
|
-
//
|
|
74
|
-
// The migration stamps `scheduledByRole: 'owner'` on every job that's
|
|
75
|
-
// missing it. `owner` is the right default for two reasons:
|
|
76
|
-
// 1. Before #171 there was no role concept; every cron job ran with
|
|
77
|
-
// the same (effectively-owner) privileges the agent had.
|
|
78
|
-
// 2. The schema gate's own error message tells users to add
|
|
79
|
-
// `"scheduledByRole": "owner"` for manually-authored entries —
|
|
80
|
-
// we just do it for them.
|
|
81
|
-
//
|
|
82
|
-
// Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
|
|
83
|
-
// function, returns the rewritten JSON plus an `applied` array so
|
|
84
|
-
// callers can build a meaningful commit message. Returns `changed:
|
|
85
|
-
// false` on canonical input so the persist + commit path stays
|
|
86
|
-
// untouched on the happy path.
|
|
87
|
-
export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
|
|
88
|
-
|
|
89
|
-
export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
|
|
90
|
-
|
|
91
|
-
export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
|
|
92
|
-
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
93
|
-
return { json, changed: false, applied: [] }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const obj = json as Record<string, unknown>
|
|
97
|
-
const jobs = obj.jobs
|
|
98
|
-
if (!Array.isArray(jobs)) {
|
|
99
|
-
return { json, changed: false, applied: [] }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const stampedIds: string[] = []
|
|
103
|
-
const nextJobs = jobs.map((job) => {
|
|
104
|
-
if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
|
|
105
|
-
const record = job as Record<string, unknown>
|
|
106
|
-
if ('scheduledByRole' in record) return job
|
|
107
|
-
const id = typeof record.id === 'string' ? record.id : '<unknown>'
|
|
108
|
-
stampedIds.push(id)
|
|
109
|
-
return { ...record, scheduledByRole: 'owner' }
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
if (stampedIds.length === 0) {
|
|
113
|
-
return { json, changed: false, applied: [] }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
json: { ...obj, jobs: nextJobs },
|
|
118
|
-
changed: true,
|
|
119
|
-
applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Builds a one-line git commit subject (plus enumerating body) for a
|
|
124
|
-
// cron.json migration. Returns null when no steps were applied — callers
|
|
125
|
-
// should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
|
|
126
|
-
// in src/config/config.ts.
|
|
127
|
-
export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
|
|
128
|
-
const first = applied[0]
|
|
129
|
-
if (first === undefined) return null
|
|
130
|
-
|
|
131
|
-
const subject =
|
|
132
|
-
applied.length === 1
|
|
133
|
-
? `cron.json: ${shortCronStepLabel(first)}`
|
|
134
|
-
: `cron.json: migrate legacy shape (${applied.length} steps)`
|
|
135
|
-
|
|
136
|
-
const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
|
|
137
|
-
return `${subject}\n\n${bodyLines.join('\n')}\n`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function shortCronStepLabel(step: CronMigrationStep): string {
|
|
141
|
-
switch (step.kind) {
|
|
142
|
-
case 'stamp-scheduled-by-role-owner':
|
|
143
|
-
return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function describeCronStep(step: CronMigrationStep): string {
|
|
148
|
-
switch (step.kind) {
|
|
149
|
-
case 'stamp-scheduled-by-role-owner':
|
|
150
|
-
return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export type ParseCronJsonOptions = ParseCronOptions & {
|
|
155
|
-
// Apply `migrateLegacyCronShape` before schema validation. Defaults to true
|
|
156
|
-
// so the guard accepts the same legacy shapes `loadCron` would auto-migrate
|
|
157
|
-
// on disk; pass false to validate the exact bytes (used in tests).
|
|
158
|
-
migrate?: boolean
|
|
159
|
-
}
|
|
67
|
+
export type ParseCronJsonOptions = ParseCronOptions
|
|
160
68
|
|
|
161
69
|
export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}): ParseCronResult {
|
|
162
70
|
let json: unknown
|
|
@@ -166,9 +74,7 @@ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}):
|
|
|
166
74
|
return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
|
|
167
75
|
}
|
|
168
76
|
|
|
169
|
-
|
|
170
|
-
const migrated = shouldMigrate ? migrateLegacyCronShape(json) : { json, changed: false, applied: [] }
|
|
171
|
-
return parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
77
|
+
return parseCronFile(json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
172
78
|
}
|
|
173
79
|
|
|
174
80
|
export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
|
package/src/init/gitignore.ts
CHANGED
|
@@ -38,8 +38,7 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
|
|
|
38
38
|
#
|
|
39
39
|
# auth.json is the pre-rename name for secrets.json; kept here permanently
|
|
40
40
|
# as a safety net so an agent folder cloned from a pre-rename machine never
|
|
41
|
-
# stages credentials by accident
|
|
42
|
-
# auth.json -> secrets.json migration.
|
|
41
|
+
# stages credentials by accident.
|
|
43
42
|
#
|
|
44
43
|
# .typeclaw/home/ is the persistent-$HOME overlay populated by the
|
|
45
44
|
# entrypoint shim's \`link_persistent_home_files\` (see
|
package/src/secrets/defaults.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
|
|
|
2
2
|
|
|
3
3
|
// DEFAULT_ENV_NAMES is the single source of truth for the env-var name each
|
|
4
4
|
// secret-bearing field uses when the user does not override it via the `env`
|
|
5
|
-
// field of a `Secret` object.
|
|
5
|
+
// field of a `Secret` object. Two layers depend on it:
|
|
6
6
|
//
|
|
7
7
|
// 1. resolveSecret (src/secrets/resolve.ts) — when the on-disk Secret has
|
|
8
8
|
// no explicit `env`, it falls back to this table to know which env var
|
|
@@ -11,10 +11,6 @@ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
|
|
|
11
11
|
// resolved channel field values into `process.env`, it uses these names
|
|
12
12
|
// so that `src/channels/manager.ts` (which reads `env.DISCORD_BOT_TOKEN`
|
|
13
13
|
// etc. directly) keeps working without per-adapter refactoring.
|
|
14
|
-
// 3. parseSecretsFile legacy upgrade — when reading a v1 file with the old
|
|
15
|
-
// `{ ENV_NAME: value }` channel shape, it inverts this table to rename
|
|
16
|
-
// the keys to the new per-adapter field names.
|
|
17
|
-
//
|
|
18
14
|
// Providers come from `KNOWN_PROVIDERS[id].apiKeyEnv` — derived, not duplicated.
|
|
19
15
|
// OAuth-only providers are intentionally absent: OAuth credentials are not
|
|
20
16
|
// env-injectable (refresh tokens are stateful).
|
|
@@ -31,19 +27,6 @@ export function isKnownAdapterId(id: string): id is KnownAdapterId {
|
|
|
31
27
|
return id in CHANNEL_FIELD_ENV
|
|
32
28
|
}
|
|
33
29
|
|
|
34
|
-
// Reverse map: env-var name -> { adapterId, fieldName }. Built from
|
|
35
|
-
// CHANNEL_FIELD_ENV so adding a new adapter field updates both directions
|
|
36
|
-
// automatically. Used exclusively by the legacy v1 channels-shape upgrade.
|
|
37
|
-
export const CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = (() => {
|
|
38
|
-
const out: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = {}
|
|
39
|
-
for (const [adapterId, fields] of Object.entries(CHANNEL_FIELD_ENV)) {
|
|
40
|
-
for (const [fieldName, envName] of Object.entries(fields)) {
|
|
41
|
-
out[envName] = { adapterId: adapterId as KnownAdapterId, fieldName }
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return out
|
|
45
|
-
})()
|
|
46
|
-
|
|
47
30
|
// Returns the default env-var name for a known channel field, or undefined
|
|
48
31
|
// when the adapter or field is not in CHANNEL_FIELD_ENV (forward-compat: a
|
|
49
32
|
// future adapter contributed via plugin would not appear in this table).
|
package/src/secrets/index.ts
CHANGED