typeclaw 0.36.1 → 0.36.3
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 +2 -2
- package/src/agent/index.ts +11 -0
- package/src/agent/plugin-tools.ts +43 -21
- package/src/agent/restart/index.ts +6 -0
- package/src/agent/restart-handoff/index.ts +10 -0
- package/src/agent/system-prompt.ts +6 -0
- package/src/agent/tools/restart.ts +9 -0
- package/src/bundled-plugins/backup/README.md +11 -2
- package/src/bundled-plugins/backup/git-auth.ts +58 -0
- package/src/bundled-plugins/backup/index.ts +54 -0
- package/src/bundled-plugins/backup/runner.ts +82 -12
- package/src/channels/adapters/discord-bot-reactions.ts +1 -0
- package/src/channels/adapters/line-attachment.ts +97 -0
- package/src/channels/adapters/line-classify.ts +14 -3
- package/src/channels/adapters/line.ts +5 -1
- package/src/channels/manager.ts +15 -3
- package/src/channels/router.ts +67 -16
- package/src/cli/hostd.ts +37 -4
- package/src/cli/reload.ts +26 -5
- package/src/cli/ui.ts +6 -0
- package/src/container/index.ts +1 -0
- package/src/container/start.ts +6 -0
- package/src/init/reconcile-plugin-deps.ts +45 -15
- package/src/init/restart-deps-preflight.ts +155 -0
- package/src/permissions/permissions.ts +24 -4
- package/src/plugin/loader.ts +16 -4
- package/src/plugin/manager.ts +175 -71
- package/src/reload/client.ts +14 -3
- package/src/reload/docker-exec-client.ts +109 -0
- package/src/reload/index.ts +7 -1
- package/src/reload/recover.ts +38 -0
- package/src/run/codex-fetch-observer.ts +57 -5
- package/src/run/index.ts +5 -0
- package/src/sandbox/availability.ts +58 -15
- package/src/sandbox/errors.ts +26 -0
- package/src/sandbox/index.ts +6 -1
- package/src/sandbox/policy.ts +11 -0
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
- package/src/skills/typeclaw-plugins/SKILL.md +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.3",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"@mozilla/readability": "^0.6.0",
|
|
51
|
-
"agent-messenger": "2.19.
|
|
51
|
+
"agent-messenger": "2.19.3",
|
|
52
52
|
"cheerio": "^1.2.0",
|
|
53
53
|
"citty": "^0.2.2",
|
|
54
54
|
"cron-parser": "^5.5.0",
|
package/src/agent/index.ts
CHANGED
|
@@ -383,6 +383,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
383
383
|
originatingSessionId: sessionManager.getSessionId(),
|
|
384
384
|
...(options.stream ? { stream: options.stream } : {}),
|
|
385
385
|
...buildRestartHandoffWiring(options, sessionManager),
|
|
386
|
+
triggeringAuthorIdProvider: () => currentChannelAuthor(getOrigin),
|
|
386
387
|
}),
|
|
387
388
|
]
|
|
388
389
|
: []),
|
|
@@ -523,6 +524,16 @@ export function buildRestartHandoffWiring(
|
|
|
523
524
|
return { agentDir, originatingSessionFile: sessionFile, handoffOrigin }
|
|
524
525
|
}
|
|
525
526
|
|
|
527
|
+
// Reads the LIVE turn author at restart time, not the session-creation
|
|
528
|
+
// snapshot. A channel session is long-lived and multi-principal: the
|
|
529
|
+
// self-restart tool fires on whatever turn triggered it, so the handoff must
|
|
530
|
+
// carry that turn's author (originRef.current), not whoever first opened the
|
|
531
|
+
// session. Returns undefined for non-channel/no-author origins.
|
|
532
|
+
export function currentChannelAuthor(getOrigin: () => SessionOrigin | undefined): string | undefined {
|
|
533
|
+
const origin = getOrigin()
|
|
534
|
+
return origin?.kind === 'channel' ? origin.lastInboundAuthorId : undefined
|
|
535
|
+
}
|
|
536
|
+
|
|
526
537
|
function restartHandoffOriginFor(origin: SessionOrigin): RestartHandoffOrigin | null {
|
|
527
538
|
if (origin.kind === 'tui') return { kind: 'tui' }
|
|
528
539
|
if (origin.kind === 'channel') {
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
resolveSandboxSymlinks,
|
|
54
54
|
resolveWritableZones,
|
|
55
55
|
SandboxDegradedProcError,
|
|
56
|
-
|
|
56
|
+
SandboxProcProbeUnverifiedError,
|
|
57
57
|
subtractMasked,
|
|
58
58
|
} from '@/sandbox'
|
|
59
59
|
|
|
@@ -644,15 +644,19 @@ async function applyBashSandbox(
|
|
|
644
644
|
// bwrap does --clearenv, so the overlay must be re-introduced via env.set or
|
|
645
645
|
// it would never reach the sandboxed process (the non-sandboxed spawnHook
|
|
646
646
|
// path does not run when the command is rewritten to a bwrap invocation).
|
|
647
|
-
const proc = await resolveProcStrategy()
|
|
647
|
+
const { strategy: proc, degradeReason } = await resolveProcStrategy()
|
|
648
648
|
// Fail fast with an actionable error when /proc degraded to tmpfs AND the
|
|
649
649
|
// command needs a real /proc: under tmpfs Bun would otherwise abort deep in its
|
|
650
|
-
// pipeline with the opaque "NotDir", which the model retries forever.
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
650
|
+
// pipeline with the opaque "NotDir", which the model retries forever. Which
|
|
651
|
+
// error depends on WHY it degraded: a 'definitive' degrade (a real leak / an
|
|
652
|
+
// incapable host) is permanent → SandboxDegradedProcError ("retrying won't
|
|
653
|
+
// help"); an 'unverified' degrade (the safety probe stayed inconclusive through
|
|
654
|
+
// its retry budget, e.g. a boot-time load spike) is transient and re-probes on
|
|
655
|
+
// the next call → SandboxProcProbeUnverifiedError ("retry the same command").
|
|
656
|
+
// Guarded on the command so non-bun bash still runs in the degraded mode (it
|
|
657
|
+
// does not touch /proc/self/{fd,maps}).
|
|
654
658
|
if (proc === 'tmpfs' && commandNeedsRealProc(command)) {
|
|
655
|
-
throw new SandboxDegradedProcError()
|
|
659
|
+
throw degradeReason === 'unverified' ? new SandboxProcProbeUnverifiedError() : new SandboxDegradedProcError()
|
|
656
660
|
}
|
|
657
661
|
const { commandString } = buildSandboxedCommand(command, {
|
|
658
662
|
mounts: [
|
|
@@ -698,26 +702,44 @@ function subtractMaskedProtected(
|
|
|
698
702
|
// --mount-proc` in a container booted WITHOUT the cap (or vice versa). Both
|
|
699
703
|
// probes are cached process-globally, so this resolves to one spawn per
|
|
700
704
|
// container lifetime regardless of how many bash calls hit it.
|
|
701
|
-
|
|
702
|
-
|
|
705
|
+
// A tmpfs degrade carries WHY it happened so the caller can pick a permanent vs
|
|
706
|
+
// retryable error. 'definitive': the probe returned a real cross-userns leak
|
|
707
|
+
// ('unsafe') — the ONLY verdict proven permanent, so it fails closed for good.
|
|
708
|
+
// 'unverified': the safety probe never reached a definitive verdict within its
|
|
709
|
+
// retry budget. That covers BOTH a transient load spike AND a durable
|
|
710
|
+
// incapability (no usable namespaces, a bwrap that starts but cannot set up its
|
|
711
|
+
// sandbox): the probe cannot prove a NEGATIVE capability — only a leak is
|
|
712
|
+
// definitive — so a genuinely incapable host also lands here and simply keeps
|
|
713
|
+
// re-degrading on each call. Since 'inconclusive' is never cached, that costs a
|
|
714
|
+
// re-probe but is correct: the only false case is "capable but briefly
|
|
715
|
+
// saturated", which recovers; an incapable host stays degraded either way.
|
|
716
|
+
// Absent when the strategy is not tmpfs.
|
|
717
|
+
type ProcStrategyResolution =
|
|
718
|
+
| { strategy: 'real-proc' | 'proc-bind'; degradeReason?: undefined }
|
|
719
|
+
| { strategy: 'tmpfs'; degradeReason: 'definitive' | 'unverified' }
|
|
720
|
+
|
|
721
|
+
async function resolveProcStrategy(): Promise<ProcStrategyResolution> {
|
|
722
|
+
if (config.sandbox.realProc && (await canMountRealProc())) return { strategy: 'real-proc' }
|
|
703
723
|
// Retry an 'inconclusive' proc-bind probe (transient under load) before
|
|
704
724
|
// degrading — a single such hiccup must not break external-package runs on a
|
|
705
725
|
// capable host. 'unsafe' still fails closed with no retry.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
(ms) => Bun.sleep(ms),
|
|
710
|
-
)
|
|
726
|
+
const verdict = await resolveProcBindSafetyWithRetry(
|
|
727
|
+
() => getProcBindSafetyVerdict(),
|
|
728
|
+
(ms) => Bun.sleep(ms),
|
|
711
729
|
)
|
|
712
|
-
|
|
730
|
+
if (verdict === 'safe') return { strategy: 'proc-bind' }
|
|
713
731
|
// Degraded last resort: no working /proc strategy. External package runners
|
|
714
732
|
// (bunx/bun add/bun run <pkg-bin>) will fail with Bun's opaque "NotDir" because
|
|
715
|
-
// /proc/self/{fd,maps} are absent.
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
733
|
+
// /proc/self/{fd,maps} are absent. Only a proven 'unsafe' (a real cross-userns
|
|
734
|
+
// leak) is DEFINITIVE — warn once (a real operator-facing limit). An
|
|
735
|
+
// 'inconclusive' is reported as retryable upstream and NOT warned (it would cry
|
|
736
|
+
// wolf every boot storm); a durably-incapable host re-degrades quietly here,
|
|
737
|
+
// since the probe cannot distinguish it from transient load.
|
|
738
|
+
if (verdict === 'unsafe') {
|
|
739
|
+
warnTmpfsProcFallbackOnce()
|
|
740
|
+
return { strategy: 'tmpfs', degradeReason: 'definitive' }
|
|
741
|
+
}
|
|
742
|
+
return { strategy: 'tmpfs', degradeReason: 'unverified' }
|
|
721
743
|
}
|
|
722
744
|
|
|
723
745
|
let tmpfsProcFallbackWarned = false
|
|
@@ -35,6 +35,10 @@ export type RequestContainerRestartOptions = {
|
|
|
35
35
|
// startup). Required alongside agentDir + originatingSessionFile for the
|
|
36
36
|
// handoff to be written; omitting it skips the handoff entirely.
|
|
37
37
|
handoffOrigin?: RestartHandoffOrigin
|
|
38
|
+
// Author of the inbound that owned the restarting session, carried so the
|
|
39
|
+
// reopened channel session re-seeds the requester's identity on its resume
|
|
40
|
+
// turn (see RestartHandoff.triggeringAuthorId).
|
|
41
|
+
triggeringAuthorId?: string
|
|
38
42
|
restartedAt?: string
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -54,6 +58,7 @@ export async function requestContainerRestart({
|
|
|
54
58
|
originatingSessionId,
|
|
55
59
|
originatingSessionFile,
|
|
56
60
|
handoffOrigin,
|
|
61
|
+
triggeringAuthorId,
|
|
57
62
|
restartedAt,
|
|
58
63
|
}: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
|
|
59
64
|
const request = { kind: 'restart' as const, containerName, build: build === true }
|
|
@@ -103,6 +108,7 @@ export async function requestContainerRestart({
|
|
|
103
108
|
originatingSessionId,
|
|
104
109
|
originatingSessionFile: basename(originatingSessionFile),
|
|
105
110
|
origin: handoffOrigin,
|
|
111
|
+
...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
|
|
106
112
|
})
|
|
107
113
|
} catch {
|
|
108
114
|
// intentional swallow — see the post-ACK rationale above
|
|
@@ -35,6 +35,13 @@ export type RestartHandoff = {
|
|
|
35
35
|
originatingSessionId: string
|
|
36
36
|
originatingSessionFile: string
|
|
37
37
|
origin: RestartHandoffOrigin
|
|
38
|
+
// Author of the inbound that owned the originating session at restart time.
|
|
39
|
+
// The synthetic resume turn has no inbound of its own, so without this a
|
|
40
|
+
// multi-principal channel session re-seeds its turn author from nothing and
|
|
41
|
+
// an author-scoped role (`discord:* author:U_OWNER`) silently demotes to
|
|
42
|
+
// whatever bare-channel rule matches on every "I'm back" turn. Optional and
|
|
43
|
+
// additive: pre-field v2 handoffs and tui handoffs omit it.
|
|
44
|
+
triggeringAuthorId?: string
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
// Atomic write via `.tmp` + rename so a crash mid-write never leaves the
|
|
@@ -158,6 +165,9 @@ function parseHandoff(raw: string): RestartHandoff | null {
|
|
|
158
165
|
originatingSessionId: obj.originatingSessionId,
|
|
159
166
|
originatingSessionFile: obj.originatingSessionFile,
|
|
160
167
|
origin,
|
|
168
|
+
...(typeof obj.triggeringAuthorId === 'string' && obj.triggeringAuthorId !== ''
|
|
169
|
+
? { triggeringAuthorId: obj.triggeringAuthorId }
|
|
170
|
+
: {}),
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from '@/shared'
|
|
2
2
|
|
|
3
|
+
const PACKAGE_JSON_INSTALL_RULE =
|
|
4
|
+
"After editing `package.json` (adding, removing, or bumping dependencies/plugins), run the project's package manager to update the lockfile and installed dependency state — e.g. `bun install`, `npm install`, `pnpm install`, or `yarn install`, matching the existing lockfile. Commit the lockfile change alongside the `package.json` edit."
|
|
5
|
+
|
|
3
6
|
// The orchestration roster (the `Briefly: ...` enumeration of public subagents)
|
|
4
7
|
// is GENERATED from the registry by `renderPublicSubagentRoster` and threaded in
|
|
5
8
|
// here, so a newly-registered public subagent can never be silently missing from
|
|
@@ -83,6 +86,7 @@ Your agent folder is a git repository.
|
|
|
83
86
|
- Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
|
|
84
87
|
- Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
|
|
85
88
|
- Never commit \`secrets.json\`, \`.env\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
|
|
89
|
+
- ${PACKAGE_JSON_INSTALL_RULE}
|
|
86
90
|
- Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
|
|
87
91
|
|
|
88
92
|
## How to behave
|
|
@@ -251,6 +255,8 @@ Never suppress errors to make things "work", and never fabricate results. If som
|
|
|
251
255
|
|
|
252
256
|
Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
|
|
253
257
|
|
|
258
|
+
${PACKAGE_JSON_INSTALL_RULE}
|
|
259
|
+
|
|
254
260
|
Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. \`public/\` is the guest-visible zone — write there anything meant to be shared with an untrusted caller (a \`guest\`-role turn cannot read \`workspace/\` but can read \`public/\`). Do not edit \`memory/topics/\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`. Never stage or commit \`secrets.json\`, \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
|
|
255
261
|
|
|
256
262
|
See the session-origin block below for what kind of session this is and what's expected of you.`
|
|
@@ -57,6 +57,12 @@ export type CreateRestartToolOptions = {
|
|
|
57
57
|
// alongside `originatingSessionFile` for the handoff to be written; omit to
|
|
58
58
|
// skip the handoff. See buildRestartHandoffWiring in src/agent/index.ts.
|
|
59
59
|
handoffOrigin?: RestartHandoffOrigin
|
|
60
|
+
// Resolves the LIVE turn author at execute time (not session-creation), so a
|
|
61
|
+
// channel self-restart in a long-lived multi-principal session resumes under
|
|
62
|
+
// the author of the turn that triggered the restart — not whoever opened the
|
|
63
|
+
// session. Called inside execute(); returns undefined for tui/no-author
|
|
64
|
+
// origins. See RestartHandoff.triggeringAuthorId.
|
|
65
|
+
triggeringAuthorIdProvider?: () => string | undefined
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
@@ -75,6 +81,7 @@ export function createRestartTool({
|
|
|
75
81
|
agentDir,
|
|
76
82
|
originatingSessionFile,
|
|
77
83
|
handoffOrigin,
|
|
84
|
+
triggeringAuthorIdProvider,
|
|
78
85
|
}: CreateRestartToolOptions) {
|
|
79
86
|
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
80
87
|
|
|
@@ -103,6 +110,7 @@ export function createRestartTool({
|
|
|
103
110
|
}),
|
|
104
111
|
async execute(_toolCallId, params) {
|
|
105
112
|
const build = params.build === true
|
|
113
|
+
const triggeringAuthorId = triggeringAuthorIdProvider?.()
|
|
106
114
|
// requestContainerRestart owns the post-ACK broadcast->handoff ordering:
|
|
107
115
|
// on a successful ACK it publishes the container-restarting notice (which
|
|
108
116
|
// every live session's subscribeRestartNotice turns into a transcript
|
|
@@ -121,6 +129,7 @@ export function createRestartTool({
|
|
|
121
129
|
...(agentDir !== undefined ? { agentDir } : {}),
|
|
122
130
|
...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
|
|
123
131
|
...(handoffOrigin !== undefined ? { handoffOrigin } : {}),
|
|
132
|
+
...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
|
|
124
133
|
})
|
|
125
134
|
if (!result.ok) {
|
|
126
135
|
const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
|
|
@@ -45,7 +45,15 @@ Commit message comes from the `backup-message` subagent, which sees a truncated
|
|
|
45
45
|
|
|
46
46
|
## What it pushes
|
|
47
47
|
|
|
48
|
-
When `pushToOrigin: true
|
|
48
|
+
When `pushToOrigin: true`, the runner picks a push plan after committing:
|
|
49
|
+
|
|
50
|
+
- **Branch has an upstream** (`git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` succeeds): run `git push`.
|
|
51
|
+
- **No upstream, but `origin` exists and `HEAD` is a real branch**: run `git push -u origin HEAD:<branch>`, which pushes _and_ establishes tracking in one shot. This is the common case for a fresh agent folder nobody ran `git push -u` on by hand — previously the runner committed forever and never pushed here. Every later cycle then takes the plain-upstream path.
|
|
52
|
+
- **No `origin`, or detached `HEAD`**: commit only (a legitimate offline / no-remote state). No push is attempted and no diagnostics are written.
|
|
53
|
+
|
|
54
|
+
On non-fast-forward rejection (either push shape), the runner runs `git fetch` then `git rebase <remote-branch>` then re-pushes with the same args. Only `origin` is ever acted on for the set-upstream case — the runner never guesses a destination the operator didn't configure.
|
|
55
|
+
|
|
56
|
+
**Credentials.** The runner spawns `git` directly (not via the `bash` tool), so the `github-cli-auth` plugin's `tool.before` credential injection does **not** fire for it. For **GitHub App auth** the backup plugin mints a per-repo installation token for `origin`'s github.com slug and injects it into the runner's git env via the same `GIT_ASKPASS` helper the bash path uses (token in `TYPECLAW_GIT_TOKEN`, never in argv/config; ssh remotes rewritten to https via `insteadOf`). Classic/fine-grained PATs, SSH-key, and credential-helper setups are left untouched — the runner uses its inherited process env. Non-github origins are never minted for.
|
|
49
57
|
|
|
50
58
|
If any network step fails (rebase conflict, auth failure, network timeout), the runner aborts cleanly and spawns the `backup-diagnose` subagent. That subagent has `bash`, `read`, and `write` tools and writes a short human-readable report to `<agentDir>/sessions/backup-diagnostics.log`. The diagnose subagent is explicitly forbidden from force-pushing or resolving merge conflicts itself.
|
|
51
59
|
|
|
@@ -77,5 +85,6 @@ This feature came up as: "periodically check for dirty files and commit; LLM pic
|
|
|
77
85
|
|
|
78
86
|
## Tests
|
|
79
87
|
|
|
80
|
-
- `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-
|
|
88
|
+
- `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-with-upstream, push-and-set-upstream when origin exists but tracking is absent, commit-only on no-origin / detached-HEAD, rebase-on-non-fast-forward for both push shapes, diagnose-on-rebase-conflict, advisory-throw isolation, sanitize-commit-message).
|
|
89
|
+
- `git-auth.test.ts` — credential-env resolution (App-auth mints for the origin slug; PAT/SSH/non-github/unavailable-token all fall back to inherited env).
|
|
81
90
|
- `index.test.ts` — plugin composition tests (subagent/hook surface, config schema defaults and validation, debounce, active-turn gating, self-induced-turn exclusion, coalescing).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { GithubTokenResolveResult } from '@/channels/github-token-bridge'
|
|
2
|
+
|
|
3
|
+
import { ensureGitAskPassHelper } from '../github-cli-auth/git-askpass'
|
|
4
|
+
import { parseGithubRepoFromGitUrl } from '../github-cli-auth/git-command'
|
|
5
|
+
import { shouldMintAppToken } from '../github-cli-auth/token-class'
|
|
6
|
+
|
|
7
|
+
export type BackupGitAuthEnv = Record<string, string>
|
|
8
|
+
|
|
9
|
+
export type BackupPushAuthDeps = {
|
|
10
|
+
hasAppTokenResolver: () => boolean
|
|
11
|
+
ghToken: string | undefined
|
|
12
|
+
resolveTokenForRepo: (repoSlug: string) => Promise<GithubTokenResolveResult>
|
|
13
|
+
resolveOriginPushUrl: (cwd: string) => Promise<string | null>
|
|
14
|
+
ensureAskPassHelper: () => Promise<string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The backup runner spawns git directly (not via the bash tool), so the
|
|
18
|
+
// `github-cli-auth` plugin's `tool.before` credential injection never fires for
|
|
19
|
+
// its push. Without this, App-auth agents push with no credentials and fail.
|
|
20
|
+
// We mirror that plugin exactly: only mint for App auth, only for a github.com
|
|
21
|
+
// origin, scoped to the origin's own repo slug. PAT/SSH/credential-helper setups
|
|
22
|
+
// return null and keep using the runner's inherited process env.
|
|
23
|
+
export async function resolveBackupPushAuthEnv(
|
|
24
|
+
cwd: string,
|
|
25
|
+
deps: BackupPushAuthDeps,
|
|
26
|
+
): Promise<BackupGitAuthEnv | null> {
|
|
27
|
+
if (!shouldMintAppToken(deps.ghToken, deps.hasAppTokenResolver())) return null
|
|
28
|
+
|
|
29
|
+
const originUrl = await deps.resolveOriginPushUrl(cwd)
|
|
30
|
+
if (originUrl === null) return null
|
|
31
|
+
|
|
32
|
+
const slug = parseGithubRepoFromGitUrl(originUrl)
|
|
33
|
+
if (slug === null) return null
|
|
34
|
+
|
|
35
|
+
const token = await deps.resolveTokenForRepo(slug)
|
|
36
|
+
if (token.kind !== 'token') return null
|
|
37
|
+
|
|
38
|
+
const askpass = await deps.ensureAskPassHelper()
|
|
39
|
+
|
|
40
|
+
// Token rides in TYPECLAW_GIT_TOKEN (read by the askpass helper), never in
|
|
41
|
+
// argv/config. The insteadOf rewrites map ssh/scp github remotes to https so
|
|
42
|
+
// the askpass credential applies; GIT_TERMINAL_PROMPT=0 fails fast instead of
|
|
43
|
+
// hanging on a prompt. Mirrors github-cli-auth/index.ts.
|
|
44
|
+
return {
|
|
45
|
+
GIT_ASKPASS: askpass,
|
|
46
|
+
TYPECLAW_GIT_TOKEN: token.token,
|
|
47
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
48
|
+
GIT_CONFIG_COUNT: '2',
|
|
49
|
+
GIT_CONFIG_KEY_0: 'url.https://github.com/.insteadOf',
|
|
50
|
+
GIT_CONFIG_VALUE_0: 'git@github.com:',
|
|
51
|
+
GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf',
|
|
52
|
+
GIT_CONFIG_VALUE_1: 'ssh://git@github.com/',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function makeDefaultAskPassEnsurer(): () => Promise<string> {
|
|
57
|
+
return () => ensureGitAskPassHelper()
|
|
58
|
+
}
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { withGitLock } from '@/git/mutex'
|
|
4
4
|
import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
|
|
5
5
|
|
|
6
|
+
import { type BackupPushAuthDeps, makeDefaultAskPassEnsurer, resolveBackupPushAuthEnv } from './git-auth'
|
|
6
7
|
import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
|
|
7
8
|
import {
|
|
8
9
|
cleanupMessageFile,
|
|
@@ -163,6 +164,7 @@ async function runBackupOnce(
|
|
|
163
164
|
agentDir: string
|
|
164
165
|
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
165
166
|
spawnSubagent: PluginContext['spawnSubagent']
|
|
167
|
+
github: PluginContext['github']
|
|
166
168
|
},
|
|
167
169
|
): Promise<BackupResult> {
|
|
168
170
|
const messagePath = messageFilePath(payload.agentDir)
|
|
@@ -175,11 +177,21 @@ async function runBackupOnce(
|
|
|
175
177
|
spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// App-auth agents need a minted per-repo token for the runner's push (it
|
|
181
|
+
// bypasses the bash tool's credential hook). Only computed when we'll push;
|
|
182
|
+
// PAT/SSH/non-github fall back to null. Passed as `pushEnv` so the runner
|
|
183
|
+
// applies it to push/fetch ONLY — never to local commands like `git commit`,
|
|
184
|
+
// which can run repo-controlled hooks that would otherwise see the token.
|
|
185
|
+
const pushEnv = payload.pushToOrigin
|
|
186
|
+
? ((await resolveBackupAuthEnv(payload.agentDir, ctx.github, ctx.logger)) ?? undefined)
|
|
187
|
+
: undefined
|
|
188
|
+
|
|
178
189
|
const result = await withGitLock(payload.agentDir, () =>
|
|
179
190
|
runBackup(
|
|
180
191
|
{ cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
|
|
181
192
|
{
|
|
182
193
|
gitSpawn: makeDefaultGitSpawn(),
|
|
194
|
+
pushEnv,
|
|
183
195
|
pickCommitMessage: async ({ status, diffstat }) => {
|
|
184
196
|
await cleanupMessageFile(messagePath)
|
|
185
197
|
const messagePayload: CommitMessagePayload = {
|
|
@@ -221,6 +233,48 @@ async function runBackupOnce(
|
|
|
221
233
|
return result
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
async function resolveBackupAuthEnv(
|
|
237
|
+
agentDir: string,
|
|
238
|
+
github: PluginContext['github'],
|
|
239
|
+
logger: { warn: (m: string) => void },
|
|
240
|
+
): Promise<Record<string, string> | null> {
|
|
241
|
+
const deps: BackupPushAuthDeps = {
|
|
242
|
+
hasAppTokenResolver: github.hasAppTokenResolver,
|
|
243
|
+
ghToken: process.env.GH_TOKEN,
|
|
244
|
+
resolveTokenForRepo: github.resolveTokenForRepo,
|
|
245
|
+
resolveOriginPushUrl,
|
|
246
|
+
ensureAskPassHelper: makeDefaultAskPassEnsurer(),
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
return await resolveBackupPushAuthEnv(agentDir, deps)
|
|
250
|
+
} catch {
|
|
251
|
+
// Credential prep is best-effort: a resolver failure must not abort the
|
|
252
|
+
// backup. Falls back to the runner's inherited env (commit still happens),
|
|
253
|
+
// and the push's own failure path diagnoses if it then can't authenticate.
|
|
254
|
+
// No slug/token/error detail is logged — those are credential-adjacent.
|
|
255
|
+
logger.warn('GitHub backup auth unavailable; continuing with inherited git credentials')
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function resolveOriginPushUrl(cwd: string): Promise<string | null> {
|
|
261
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
262
|
+
if (!bun) return null
|
|
263
|
+
try {
|
|
264
|
+
const proc = bun.spawn({
|
|
265
|
+
cmd: ['git', '-C', cwd, 'remote', 'get-url', '--push', 'origin'],
|
|
266
|
+
stdout: 'pipe',
|
|
267
|
+
stderr: 'ignore',
|
|
268
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
|
|
269
|
+
})
|
|
270
|
+
if ((await proc.exited) !== 0) return null
|
|
271
|
+
const out = (await new Response(proc.stdout).text()).trim()
|
|
272
|
+
return out === '' ? null : out
|
|
273
|
+
} catch {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
224
278
|
function describeResult(r: BackupResult): string {
|
|
225
279
|
if (r.ok) return r.kind
|
|
226
280
|
return `failed (${r.kind}): ${r.reason}`
|
|
@@ -21,12 +21,20 @@ export type GitSpawnResult = {
|
|
|
21
21
|
timedOut: boolean
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export type GitSpawn = (
|
|
24
|
+
export type GitSpawn = (
|
|
25
|
+
args: readonly string[],
|
|
26
|
+
opts: { cwd: string; timeoutMs: number; env?: Record<string, string> },
|
|
27
|
+
) => Promise<GitSpawnResult>
|
|
25
28
|
|
|
26
29
|
export type BackupRunnerDeps = {
|
|
27
30
|
gitSpawn: GitSpawn
|
|
28
31
|
pickCommitMessage: (input: { status: string; diffstat: string }) => Promise<string>
|
|
29
32
|
diagnoseFailure?: (input: BackupFailureInput) => Promise<void>
|
|
33
|
+
// Credential env (GIT_ASKPASS/TYPECLAW_GIT_TOKEN/insteadOf) applied ONLY to
|
|
34
|
+
// network git invocations (push/fetch). It is deliberately NOT given to local
|
|
35
|
+
// commands — `git commit` can run repo-controlled hooks, which must never see
|
|
36
|
+
// the minted token.
|
|
37
|
+
pushEnv?: Record<string, string>
|
|
30
38
|
now?: () => number
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -44,9 +52,15 @@ export type BackupFailureInput = {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export type BackupResult =
|
|
47
|
-
| { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'rebased-and-pushed' }
|
|
55
|
+
| { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'pushed-set-upstream' | 'rebased-and-pushed' }
|
|
48
56
|
| { ok: false; kind: 'commit-failed' | 'push-failed' | 'rebase-failed' | 'aborted'; reason: string }
|
|
49
57
|
|
|
58
|
+
type ActivePushPlan =
|
|
59
|
+
| { kind: 'upstream'; upstreamRef: string }
|
|
60
|
+
| { kind: 'set-upstream'; remote: string; branch: string }
|
|
61
|
+
|
|
62
|
+
type PushPlan = ActivePushPlan | { kind: 'skip' }
|
|
63
|
+
|
|
50
64
|
export async function runBackup(options: BackupRunnerOptions, deps: BackupRunnerDeps): Promise<BackupResult> {
|
|
51
65
|
const { cwd, pushToOrigin } = options
|
|
52
66
|
|
|
@@ -113,24 +127,70 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
113
127
|
|
|
114
128
|
if (!pushToOrigin) return { ok: true, kind: 'committed' }
|
|
115
129
|
|
|
130
|
+
const plan = await resolvePushPlan(cwd, deps)
|
|
131
|
+
if (plan.kind === 'skip') return { ok: true, kind: 'committed' }
|
|
132
|
+
|
|
133
|
+
return pushWithRecovery(cwd, deps, plan)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `@{upstream}` resolution failing was previously treated as "no push" — but a
|
|
137
|
+
// fresh agent repo that nobody ran `git push -u` on has a configured `origin`
|
|
138
|
+
// and no tracking ref, so the runner committed forever and never pushed. The
|
|
139
|
+
// correct gate when `pushToOrigin` is on is "origin exists and HEAD is a real
|
|
140
|
+
// branch": then we push AND set the upstream in one shot, and every later run
|
|
141
|
+
// takes the plain-upstream path. No remote / detached HEAD stays commit-only
|
|
142
|
+
// (a legitimate offline state), so it returns `skip` rather than diagnosing.
|
|
143
|
+
async function resolvePushPlan(cwd: string, deps: BackupRunnerDeps): Promise<PushPlan> {
|
|
116
144
|
const upstream = await deps.gitSpawn(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
|
|
117
145
|
cwd,
|
|
118
146
|
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
119
147
|
})
|
|
120
|
-
if (upstream.exitCode
|
|
148
|
+
if (upstream.exitCode === 0 && upstream.stdout.trim().length > 0) {
|
|
149
|
+
return { kind: 'upstream', upstreamRef: upstream.stdout.trim() }
|
|
150
|
+
}
|
|
121
151
|
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
// Only `origin` is acted on: picking "the first remote" when origin is absent
|
|
153
|
+
// would guess a destination the operator never configured. `get-url` (not
|
|
154
|
+
// `get-url --push`) is enough here — we only need to know origin EXISTS and
|
|
155
|
+
// is named `origin`; the push targets `origin` by name regardless of pushurl.
|
|
156
|
+
const origin = await deps.gitSpawn(['remote', 'get-url', 'origin'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
157
|
+
if (origin.exitCode !== 0 || origin.stdout.trim().length === 0) return { kind: 'skip' }
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
// `symbolic-ref --short HEAD` fails on a detached HEAD (no branch to set an
|
|
160
|
+
// upstream for); `rev-parse --abbrev-ref HEAD` would have returned the literal
|
|
161
|
+
// "HEAD" and we'd have tried to push a branch named HEAD. Skip cleanly.
|
|
162
|
+
const branch = await deps.gitSpawn(['symbolic-ref', '--short', 'HEAD'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
163
|
+
if (branch.exitCode !== 0 || branch.stdout.trim().length === 0) return { kind: 'skip' }
|
|
164
|
+
|
|
165
|
+
return { kind: 'set-upstream', remote: 'origin', branch: branch.stdout.trim() }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Both entry points (plain push and first-time set-upstream push) share the
|
|
169
|
+
// non-fast-forward recovery: fetch, rebase onto the intended remote branch,
|
|
170
|
+
// re-push. Keeping one helper stops the set-upstream path from silently becoming
|
|
171
|
+
// a weaker duplicate that skips recovery.
|
|
172
|
+
async function pushWithRecovery(cwd: string, deps: BackupRunnerDeps, plan: ActivePushPlan): Promise<BackupResult> {
|
|
173
|
+
const pushArgs = pushArgsFor(plan)
|
|
174
|
+
const rebaseRef = plan.kind === 'upstream' ? plan.upstreamRef : `${plan.remote}/${plan.branch}`
|
|
175
|
+
// In the set-upstream case there is no tracking ref yet, so a bare `git fetch`
|
|
176
|
+
// has no configured remote to default to — fetch the same remote we rebase
|
|
177
|
+
// onto. The upstream case keeps bare `fetch` (its tracking config resolves it).
|
|
178
|
+
const fetchArgs = plan.kind === 'upstream' ? ['fetch'] : ['fetch', plan.remote]
|
|
179
|
+
const pushedKind: BackupResult = { ok: true, kind: plan.kind === 'upstream' ? 'pushed' : 'pushed-set-upstream' }
|
|
180
|
+
// Credentials ride ONLY on the network calls (push/fetch). The rebase is
|
|
181
|
+
// local (it replays onto an already-fetched remote-tracking ref), so it runs
|
|
182
|
+
// token-free like every other local command.
|
|
183
|
+
const net = { cwd, timeoutMs: NETWORK_TIMEOUT_MS, env: deps.pushEnv }
|
|
184
|
+
|
|
185
|
+
const push = await deps.gitSpawn(pushArgs, net)
|
|
186
|
+
if (push.exitCode === 0) return pushedKind
|
|
127
187
|
|
|
128
188
|
if (!isNonFastForward(push)) {
|
|
129
189
|
await maybeDiagnose(deps, { cwd, stage: 'push', exitCode: push.exitCode, stderr: push.stderr, stdout: push.stdout })
|
|
130
190
|
return { ok: false, kind: 'push-failed', reason: shortErr(push) }
|
|
131
191
|
}
|
|
132
192
|
|
|
133
|
-
const fetch = await deps.gitSpawn(
|
|
193
|
+
const fetch = await deps.gitSpawn(fetchArgs, net)
|
|
134
194
|
if (fetch.exitCode !== 0) {
|
|
135
195
|
await maybeDiagnose(deps, {
|
|
136
196
|
cwd,
|
|
@@ -142,7 +202,7 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
142
202
|
return { ok: false, kind: 'push-failed', reason: `git fetch failed: ${shortErr(fetch)}` }
|
|
143
203
|
}
|
|
144
204
|
|
|
145
|
-
const rebase = await deps.gitSpawn(['rebase',
|
|
205
|
+
const rebase = await deps.gitSpawn(['rebase', rebaseRef], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
|
|
146
206
|
if (rebase.exitCode !== 0) {
|
|
147
207
|
await deps.gitSpawn(['rebase', '--abort'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
148
208
|
await maybeDiagnose(deps, {
|
|
@@ -155,7 +215,7 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
155
215
|
return { ok: false, kind: 'rebase-failed', reason: `git rebase failed: ${shortErr(rebase)}` }
|
|
156
216
|
}
|
|
157
217
|
|
|
158
|
-
const push2 = await deps.gitSpawn(
|
|
218
|
+
const push2 = await deps.gitSpawn(pushArgs, net)
|
|
159
219
|
if (push2.exitCode !== 0) {
|
|
160
220
|
await maybeDiagnose(deps, {
|
|
161
221
|
cwd,
|
|
@@ -169,6 +229,13 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
169
229
|
return { ok: true, kind: 'rebased-and-pushed' }
|
|
170
230
|
}
|
|
171
231
|
|
|
232
|
+
function pushArgsFor(plan: ActivePushPlan): string[] {
|
|
233
|
+
if (plan.kind === 'upstream') return ['push']
|
|
234
|
+
// `HEAD:<branch>` is explicit about pushing the current commit to the named
|
|
235
|
+
// remote branch, avoiding any reliance on local refspec defaults.
|
|
236
|
+
return ['push', '-u', plan.remote, `HEAD:${plan.branch}`]
|
|
237
|
+
}
|
|
238
|
+
|
|
172
239
|
async function maybeDiagnose(deps: BackupRunnerDeps, input: BackupFailureInput): Promise<void> {
|
|
173
240
|
if (!deps.diagnoseFailure) return
|
|
174
241
|
try {
|
|
@@ -217,7 +284,7 @@ function sanitizeCommitMessage(raw: string): string {
|
|
|
217
284
|
}
|
|
218
285
|
|
|
219
286
|
export function makeDefaultGitSpawn(): GitSpawn {
|
|
220
|
-
return withIndexLockRetry(async (args, { cwd, timeoutMs }) => {
|
|
287
|
+
return withIndexLockRetry(async (args, { cwd, timeoutMs, env }) => {
|
|
221
288
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
222
289
|
if (!bun) {
|
|
223
290
|
return { exitCode: 127, stdout: '', stderr: 'Bun runtime not available', timedOut: false }
|
|
@@ -225,12 +292,15 @@ export function makeDefaultGitSpawn(): GitSpawn {
|
|
|
225
292
|
const controller = new AbortController()
|
|
226
293
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
227
294
|
try {
|
|
295
|
+
// Per-call `env` (credentials for push/fetch) is applied LAST so its
|
|
296
|
+
// GIT_TERMINAL_PROMPT wins; NONINTERACTIVE_ENV's pager/GCM settings still
|
|
297
|
+
// apply to every call. Local commands pass no `env` and stay token-free.
|
|
228
298
|
const proc = bun.spawn({
|
|
229
299
|
cmd: ['git', ...args],
|
|
230
300
|
cwd,
|
|
231
301
|
stdout: 'pipe',
|
|
232
302
|
stderr: 'pipe',
|
|
233
|
-
env: { ...process.env, ...NONINTERACTIVE_ENV },
|
|
303
|
+
env: { ...process.env, ...NONINTERACTIVE_ENV, ...env },
|
|
234
304
|
signal: controller.signal,
|
|
235
305
|
})
|
|
236
306
|
const exitCode = await proc.exited
|