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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +11 -0
  3. package/src/agent/plugin-tools.ts +43 -21
  4. package/src/agent/restart/index.ts +6 -0
  5. package/src/agent/restart-handoff/index.ts +10 -0
  6. package/src/agent/system-prompt.ts +6 -0
  7. package/src/agent/tools/restart.ts +9 -0
  8. package/src/bundled-plugins/backup/README.md +11 -2
  9. package/src/bundled-plugins/backup/git-auth.ts +58 -0
  10. package/src/bundled-plugins/backup/index.ts +54 -0
  11. package/src/bundled-plugins/backup/runner.ts +82 -12
  12. package/src/channels/adapters/discord-bot-reactions.ts +1 -0
  13. package/src/channels/adapters/line-attachment.ts +97 -0
  14. package/src/channels/adapters/line-classify.ts +14 -3
  15. package/src/channels/adapters/line.ts +5 -1
  16. package/src/channels/manager.ts +15 -3
  17. package/src/channels/router.ts +67 -16
  18. package/src/cli/hostd.ts +37 -4
  19. package/src/cli/reload.ts +26 -5
  20. package/src/cli/ui.ts +6 -0
  21. package/src/container/index.ts +1 -0
  22. package/src/container/start.ts +6 -0
  23. package/src/init/reconcile-plugin-deps.ts +45 -15
  24. package/src/init/restart-deps-preflight.ts +155 -0
  25. package/src/permissions/permissions.ts +24 -4
  26. package/src/plugin/loader.ts +16 -4
  27. package/src/plugin/manager.ts +175 -71
  28. package/src/reload/client.ts +14 -3
  29. package/src/reload/docker-exec-client.ts +109 -0
  30. package/src/reload/index.ts +7 -1
  31. package/src/reload/recover.ts +38 -0
  32. package/src/run/codex-fetch-observer.ts +57 -5
  33. package/src/run/index.ts +5 -0
  34. package/src/sandbox/availability.ts +58 -15
  35. package/src/sandbox/errors.ts +26 -0
  36. package/src/sandbox/index.ts +6 -1
  37. package/src/sandbox/policy.ts +11 -0
  38. package/src/skills/typeclaw-config/SKILL.md +2 -2
  39. package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
  40. 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.1",
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.1",
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",
@@ -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
- type SandboxProcStrategy,
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. The
651
- // SandboxDegradedProcError message tells it this is an environment limit, not
652
- // the command's fault. Guarded on the command so non-bun bash still runs in the
653
- // degraded mode (it does not touch /proc/self/{fd,maps}).
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
- async function resolveProcStrategy(): Promise<SandboxProcStrategy> {
702
- if (config.sandbox.realProc && (await canMountRealProc())) return 'real-proc'
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
- if (
707
- await resolveProcBindSafetyWithRetry(
708
- () => getProcBindSafetyVerdict(),
709
- (ms) => Bun.sleep(ms),
710
- )
726
+ const verdict = await resolveProcBindSafetyWithRetry(
727
+ () => getProcBindSafetyVerdict(),
728
+ (ms) => Bun.sleep(ms),
711
729
  )
712
- return 'proc-bind'
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. Warn once so an operator on such an exotic
716
- // host (no usable user namespaces at all) gets a diagnostic instead of the bare
717
- // Bun error. Not gated on parsing the command that heuristic is fragile (see
718
- // PR #696); this is a strategy-level notice, fail-closed and command-agnostic.
719
- warnTmpfsProcFallbackOnce()
720
- return 'tmpfs'
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` and the current branch has an upstream (`git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` succeeds), the runner runs `git push`. On non-fast-forward rejection, it runs `git fetch` then `git rebase <upstream>` then `git push` again.
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-only-with-upstream, rebase-on-non-fast-forward, diagnose-on-rebase-conflict, advisory-throw isolation, sanitize-commit-message).
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 = (args: readonly string[], opts: { cwd: string; timeoutMs: number }) => Promise<GitSpawnResult>
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 !== 0) return { ok: true, kind: 'committed' }
148
+ if (upstream.exitCode === 0 && upstream.stdout.trim().length > 0) {
149
+ return { kind: 'upstream', upstreamRef: upstream.stdout.trim() }
150
+ }
121
151
 
122
- const upstreamRef = upstream.stdout.trim()
123
- if (upstreamRef.length === 0) return { ok: true, kind: 'committed' }
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
- const push = await deps.gitSpawn(['push'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
126
- if (push.exitCode === 0) return { ok: true, kind: 'pushed' }
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(['fetch'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
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', upstreamRef], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
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(['push'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
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
@@ -75,6 +75,7 @@ const EMOJI_UNICODE: Record<string, string> = {
75
75
  fire: '🔥',
76
76
  eye: '👁️',
77
77
  raised_hands: '🙌',
78
+ zipper_mouth_face: '🤐',
78
79
  }
79
80
 
80
81
  function resolveEmoji(emoji: string): string | null {