@lef237/hagumi 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -171,7 +171,7 @@ hagumi は、会話の中で必要に応じてツールを使う。`read`・`wri
171
171
  `bash` は、保護対象の保存先を除き、実行したユーザーが読めるディレクトリ外のファイルも読める。個人文書や、作業ディレクトリ内外の `.env` などを自動で隠す仕組みはない。ツールの出力に含まれた内容は、モデルに送られ、セッションログにも残る。
172
172
 
173
173
  - 変更の前には差分が表示され、`[y]` はい / `[n]` いいえ / `[a]` このセッションでは常に許可、から選ぶ。`a` を選んだあとも、差分は表示される。
174
- - 既にあるファイルは、hagumi がそのセッションで読んだあとでないと変更できない。承認を待っている間も含め、ファイルの内容や対象が変わった場合は変更を止め、読み直しと再承認を求める。新規作成の承認を待っている間に別の人が同名のファイルを作った場合も、上書きしない。
174
+ - 既にあるファイルは、hagumi がそのセッションで読んだあとでないと変更できない。承認を待っている間にファイルの内容が変わったり、作業ディレクトリの外を指すリンクに差し替えられたりした場合は、変更を止めて読み直しを求める。新規作成の承認を待っている間に別の人が同名のファイルを作った場合も、上書きしない。
175
175
  - ハードリンクが複数あるファイルは変更しない。作業ディレクトリ外にある同じファイルまで変えてしまうのを防ぐため。
176
176
  - `.git` の中には書き込めない。
177
177
  - hagumi の保存ディレクトリには、`read`・`write`・`edit` からもアクセスできない。`HAGUMI_HOME` でプロジェクト内を指定した場合も同じ。
@@ -182,7 +182,7 @@ hagumi は、会話の中で必要に応じてツールを使う。`read`・`wri
182
182
  - 書き込めるのは、作業ディレクトリと一時ディレクトリだけ。`.git/hooks` と `.git/config` には書き込めない。
183
183
  - ネットワークにはつながらない。
184
184
  - 既知の認証情報の保存先(`~/.hagumi`、`~/.ssh`、`~/.aws` など)にはアクセスできない。`HAGUMI_HOME` で保存先を変えた場合も、その場所を保護する。保護対象は `src/sandbox.ts` の `secretPaths` にある。この一覧は、認証情報があり得るすべての場所を網羅してはいない。
185
- - 環境変数は `PATH` や `HOME` などの最小限だけを渡す。API キーやトークンは渡らない。`PATH` からは、作業・一時ディレクトリ内の項目と相対パスを外す。実行ファイルのリンク先が書き込み可能な場所にある項目も外す。そこにある実行ファイルを使うときは、`./tool` のようにパスを指定する。
185
+ - 環境変数は `PATH` や `HOME` などの最小限だけを渡す。API キーやトークンは渡らない。`PATH` からは、作業・一時ディレクトリ内の項目と相対パスを外す。そこにある実行ファイルを使うときは、`./tool` のようにパスを指定する。
186
186
  - サンドボックスが使えない環境では、`bash` そのものを使えないようにする。たとえば macOS と Linux 以外の OS や、bubblewrap が入っていない Linux、非特権ユーザー名前空間が無効な Linux がこれに当たる。
187
187
 
188
188
  ### コマンドの許可リストと禁止リスト
@@ -272,9 +272,9 @@ Responses 形式のリクエストには `store: false` を送り、応答を後
272
272
  すべて `~/.hagumi/` に置く。起動時にこのディレクトリを権限 700 にし、以前からある保存先も同じ権限に整える。環境変数 `HAGUMI_HOME` で場所を変えられる。指定した場所も 700 にするので、保存専用のディレクトリを指定する。
273
273
  相対パスを指定した場合は、起動した場所からのパスとして扱い、会話中に作業ディレクトリが変わっても保存先は変わらない。
274
274
 
275
- - `auth/<provider>.json`: サインインの情報と API キー。`auth` ディレクトリは権限 700、ファイルは 600 にする。一時ファイルに書き終えてから置き換え、更新途中のデータで以前の認証情報を上書きしない。シンボリックリンクや、ハードリンクが複数ある認証ファイルは使わない。ChatGPT では検証済みのアカウント識別子も保存し、次回以降の再サインインで照合する。検証に失敗した場合、認証情報を置き換えない。
275
+ - `auth/<provider>.json`: サインインの情報と API キー。`auth` ディレクトリは権限 700 で作り、ファイルは前からあるものも含めて 600 にする。検証に失敗した場合、認証情報を置き換えない。
276
276
  - `memory/memory.md`: hagumi が覚えていること([記憶](#記憶))。
277
- - `sessions/<id>.jsonl`: やり取りの記録。発言、モデルに送ったリクエスト、受け取ったイベントを、そのまま残す。キーを含むヘッダの値は `<redacted>` に置き換える。`sessions` ディレクトリは権限 700、ログは 600 にし、再開時にも権限を直す。シンボリックリンクや、ハードリンクが複数あるログは使わない。
277
+ - `sessions/<id>.jsonl`: やり取りの記録。発言、モデルに送ったリクエスト、受け取ったイベントを、そのまま残す。キーを含むヘッダの値は `<redacted>` に置き換える。`sessions` ディレクトリとログは、新しく作るときに権限 700 と 600 にする。
278
278
 
279
279
  ## 開発
280
280
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lef237/hagumi",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A small, playful coding partner that grows with you.",
5
5
  "license": "MIT",
6
6
  "author": "lef237",
package/src/chatgpt.ts CHANGED
@@ -13,7 +13,6 @@ const tokenSharing = "chatgpt.tokens.use.direct"
13
13
  export type ChatGPTAuth = {
14
14
  hostId: string
15
15
  clientId: string
16
- subject?: string
17
16
  accessToken: string
18
17
  refreshToken: string
19
18
  expiresAt: number
@@ -96,10 +95,9 @@ export async function signIn(showUrl: (url: string) => void): Promise<ChatGPTAut
96
95
  redirect_uri: redirect,
97
96
  })
98
97
  if (!tokens.scope?.split(" ").includes(tokenSharing)) throw new Problem("noTokenSharing")
99
- const subject = await checkIdToken(tokens.id_token, clientId, nonce)
100
- if (saved?.subject && subject !== saved.subject) throw new Error("ChatGPT returned a different account")
98
+ await checkIdToken(tokens.id_token, clientId, nonce)
101
99
 
102
- const auth = toAuth(tokens, { hostId, clientId, subject })
100
+ const auth = toAuth(tokens, { hostId, clientId })
103
101
  await writeSecret(paths.auth("chatgpt"), auth)
104
102
  return auth
105
103
  } finally {
@@ -144,68 +142,37 @@ async function requestToken(params: Record<string, string>): Promise<TokenRespon
144
142
  return (await response.json()) as TokenResponse
145
143
  }
146
144
 
147
- // This one provider uses RS256. Fetch its public keys fresh at each sign-in; never trust a token's key URL.
145
+ // Checked as OpenAI's guide for open-source apps asks: the signature against OpenAI's published keys, fetched
146
+ // fresh at each sign-in so that no cache is needed, then who issued the token, for whom, for this sign-in, and when.
148
147
  // https://developers.openai.com/siwc/token-sharing-open-source/sign-in
149
- export async function checkIdToken(idToken: string | undefined, clientId: string, nonce: string): Promise<string> {
150
- try {
151
- if (typeof idToken !== "string") throw new Error()
152
- const parts = idToken.split(".")
153
- if (parts.length !== 3 || parts.some((part) =>
154
- !/^[A-Za-z0-9_-]+$/.test(part) || Buffer.from(part, "base64url").toString("base64url") !== part)) throw new Error()
155
- const [head = "", body = "", signature = ""] = parts
156
- const decode = (part: string): Record<string, unknown> => {
157
- const value: unknown = JSON.parse(Buffer.from(part, "base64url").toString("utf8"))
158
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error()
159
- return value as Record<string, unknown>
160
- }
161
- const header = decode(head)
162
- if (header.alg !== "RS256" || typeof header.kid !== "string" || !header.kid ||
163
- header.crit !== undefined || header.b64 !== undefined) throw new Error()
164
- const response = await fetch(`${issuer}/.well-known/jwks.json`, {
165
- redirect: "error", signal: AbortSignal.timeout(10_000), headers: { "User-Agent": userAgent },
166
- })
167
- if (!response.ok) throw new Error()
168
- const { keys } = await response.json() as { keys: (webcrypto.JsonWebKey & { kid?: string })[] }
169
- if (!Array.isArray(keys)) throw new Error()
170
- const matching = keys.filter((key) => key?.kid === header.kid)
171
- const jwk = matching[0]
172
- if (matching.length !== 1 || !jwk || jwk.kty !== "RSA" || jwk.d !== undefined ||
173
- (jwk.alg !== undefined && jwk.alg !== "RS256") || (jwk.use !== undefined && jwk.use !== "sig")) throw new Error()
174
- const rsa = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
175
- const key = await crypto.subtle.importKey("jwk", jwk, rsa, false, ["verify"])
176
- if ((key.algorithm as webcrypto.RsaKeyAlgorithm).modulusLength < 2048 ||
177
- !await crypto.subtle.verify(rsa, key, Buffer.from(signature, "base64url"), new TextEncoder().encode(`${head}.${body}`))) throw new Error()
178
- const claims = decode(body)
179
- const audience = typeof claims.aud === "string" ? [claims.aud] : claims.aud
180
- const numeric = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value)
181
- const now = Date.now() / 1000
182
- if (claims.iss !== issuer || claims.nonce !== nonce || typeof claims.sub !== "string" || !claims.sub ||
183
- !Array.isArray(audience) || !audience.every((value) => typeof value === "string" && value.length > 0) ||
184
- !audience.includes(clientId) ||
185
- ((audience.length > 1 || claims.azp !== undefined) && claims.azp !== clientId) ||
186
- !numeric(claims.exp) || !numeric(claims.iat) || claims.exp <= now - 5 ||
187
- claims.iat > now + 5 || claims.iat >= claims.exp ||
188
- (claims.nbf !== undefined && (!numeric(claims.nbf) || claims.nbf > now + 5))) throw new Error()
189
- return claims.sub
190
- } catch {
191
- // Do not expose token contents or key-server response bodies through errors.
192
- throw new Error("ChatGPT ID token verification failed")
148
+ export async function checkIdToken(idToken: string | undefined, clientId: string, nonce: string): Promise<void> {
149
+ const [head = "", body = "", signature = ""] = (idToken ?? "").split(".")
150
+ const decode = (part: string) => { try { return JSON.parse(Buffer.from(part, "base64url").toString()) } catch { return {} } }
151
+ const header = decode(head)
152
+ const response = await fetch(`${issuer}/.well-known/jwks.json`, { signal: AbortSignal.timeout(10_000) })
153
+ if (!response.ok) throw new Error(`Could not get OpenAI's signing keys: ${response.status}`)
154
+ const { keys } = (await response.json()) as { keys: (webcrypto.JsonWebKey & { kid?: string })[] }
155
+ const jwk = keys.find((key) => key.kid === header.kid)
156
+ const rsa = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
157
+ const key = header.alg === "RS256" && jwk && (await crypto.subtle.importKey("jwk", jwk, rsa, false, ["verify"]))
158
+ const signed = key && (await crypto.subtle.verify(rsa, key, Buffer.from(signature, "base64url"), Buffer.from(`${head}.${body}`)))
159
+ const claims = decode(body)
160
+ const now = Date.now() / 1000
161
+ if (!signed || claims.iss !== issuer || ![claims.aud].flat().includes(clientId) || claims.nonce !== nonce ||
162
+ !(claims.exp > now - 5) || !(claims.iat < now + 5) || !claims.sub) {
163
+ throw new Error("ChatGPT returned an invalid ID token")
193
164
  }
194
165
  }
195
166
 
196
- function toAuth(tokens: TokenResponse, previous: Pick<ChatGPTAuth, "hostId" | "clientId" | "subject"> & { refreshToken?: string }) {
197
- const refreshToken = tokens.refresh_token === undefined ? previous.refreshToken : tokens.refresh_token
198
- const expiresIn = tokens.expires_in === undefined ? 3600 : tokens.expires_in
199
- const expiresAt = Date.now() + expiresIn * 1000
200
- if (typeof tokens.access_token !== "string" || !tokens.access_token || typeof refreshToken !== "string" || !refreshToken ||
201
- typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || !Number.isFinite(expiresAt)) throw new Error("ChatGPT returned invalid credentials")
167
+ function toAuth(tokens: TokenResponse, previous: Pick<ChatGPTAuth, "hostId" | "clientId"> & { refreshToken?: string }) {
168
+ const refreshToken = tokens.refresh_token ?? previous.refreshToken
169
+ if (!refreshToken) throw new Error("ChatGPT returned no refresh token")
202
170
  return {
203
171
  hostId: previous.hostId,
204
172
  clientId: previous.clientId,
205
- subject: previous.subject,
206
173
  accessToken: tokens.access_token,
207
174
  refreshToken,
208
- expiresAt,
175
+ expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
209
176
  } satisfies ChatGPTAuth
210
177
  }
211
178
 
package/src/log.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { constants } from "node:fs"
2
- import { type FileHandle, mkdir, open } from "node:fs/promises"
1
+ import { appendFile, mkdir } from "node:fs/promises"
3
2
  import { dirname } from "node:path"
4
3
  import { paths } from "./app"
5
4
 
@@ -13,60 +12,24 @@ export type SessionLog = {
13
12
  }
14
13
 
15
14
  // Append-only JSONL: every message, every request sent to a model, and every event received, as they were.
16
- // Opening an existing session continues it.
15
+ // Opening an existing session continues it. Logs hold conversations and code, so only the user can read new ones.
17
16
  export async function openSessionLog(id: string = crypto.randomUUID()): Promise<SessionLog> {
17
+ // The id names a file, so one given with --session must not reach outside the sessions directory.
18
18
  if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("Invalid session id")
19
19
  const path = paths.session(id)
20
- const existing = await privateLog(path, constants.O_RDONLY)
21
- await existing?.close()
20
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
22
21
  return {
23
22
  id,
24
23
  path,
25
- async append(entry) {
26
- const file = (await privateLog(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT))!
27
- try {
28
- await file.appendFile(`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`)
29
- } finally {
30
- await file.close()
31
- }
32
- },
24
+ append: (entry) => appendFile(path, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { mode: 0o600 }),
33
25
  async read() {
34
- const file = await privateLog(path, constants.O_RDONLY)
35
- if (!file) return []
36
- try {
37
- const text = await file.readFile("utf8")
38
- return text.split("\n").filter(Boolean).map((line) => JSON.parse(line) as LogEntry)
39
- } finally {
40
- await file.close()
41
- }
26
+ const file = Bun.file(path)
27
+ if (!(await file.exists())) return []
28
+ const text = await file.text()
29
+ return text
30
+ .split("\n")
31
+ .filter(Boolean)
32
+ .map((line) => JSON.parse(line) as LogEntry)
42
33
  },
43
34
  }
44
35
  }
45
-
46
- // Tighten an existing log before reading or appending. Work on the opened file, never a link's target.
47
- async function privateLog(path: string, flags: number): Promise<FileHandle | undefined> {
48
- const dir = dirname(path)
49
- await mkdir(dir, { recursive: true, mode: 0o700 })
50
- const directory = await open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
51
- try {
52
- await directory.chmod(0o700)
53
- } finally {
54
- await directory.close()
55
- }
56
- let file: FileHandle
57
- try {
58
- file = await open(path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
59
- } catch (error) {
60
- if (!(flags & constants.O_CREAT) && (error as NodeJS.ErrnoException).code === "ENOENT") return
61
- throw error
62
- }
63
- try {
64
- const stat = await file.stat()
65
- if (!stat.isFile() || stat.nlink !== 1) throw new Error("Session log must be a regular file with one link")
66
- await file.chmod(0o600)
67
- return file
68
- } catch (error) {
69
- await file.close()
70
- throw error
71
- }
72
- }
package/src/sandbox.ts CHANGED
@@ -7,7 +7,6 @@ import { realpath } from "node:fs/promises"
7
7
  import { homedir, tmpdir } from "node:os"
8
8
  import { delimiter, isAbsolute, join, relative, sep } from "node:path"
9
9
  import { storageHome } from "./app"
10
- import { analyze } from "./shell"
11
10
 
12
11
  // What a command may touch, decided once for every platform.
13
12
  export type Limits = {
@@ -78,7 +77,7 @@ export async function runSandboxed(options: {
78
77
  cwd: workspace,
79
78
  env: {
80
79
  ...Object.fromEntries(passedEnvironment.flatMap((name) => (process.env[name] ? [[name, process.env[name]]] : []))),
81
- PATH: await searchPath([...limits.writable, "/private/tmp", "/dev"], options.command),
80
+ PATH: await searchPath([...limits.writable, "/private/tmp", "/dev"]),
82
81
  },
83
82
  stdin: "ignore",
84
83
  stdout: "pipe",
@@ -93,32 +92,22 @@ export async function runSandboxed(options: {
93
92
  return { exitCode, stdout, stderr, timedOut: child.signalCode !== null }
94
93
  }
95
94
 
96
- // Bare command names must not select executables the model can replace. Check directories and executable links;
97
- // relative entries would also change their meaning after cd. Local tools can still run by an explicit path.
98
- async function searchPath(writable: string[], command: string): Promise<string> {
95
+ // Bare command names must not pick a program the model could have put in place: directories it can write to leave
96
+ // PATH, and so do relative entries, whose meaning changes with cd. Programs there still run by their path.
97
+ async function searchPath(writable: string[]): Promise<string> {
99
98
  const outside = (target: string) => writable.every((root) => {
100
99
  const fromRoot = relative(root, target)
101
100
  return fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)
102
101
  })
103
- const programs = [...new Set(analyze(command).commands.flatMap(([program]) => program && !program.includes("/") ? [program] : []))]
104
102
  const entries = await Promise.all((process.env.PATH ?? "/usr/bin:/bin").split(delimiter).map(async (entry) => {
105
- if (!isAbsolute(entry)) return undefined
106
- const target = await realpath(entry).catch(() => undefined)
107
- if (!target || !outside(target)) return undefined
108
- const executables = await Promise.all(programs.map((program) => existingPath(join(target, program)))).catch(() => undefined)
109
- return executables?.every((executable) => executable === undefined || outside(executable)) ? target : undefined
103
+ const target = isAbsolute(entry) ? await realpath(entry).catch(() => undefined) : undefined
104
+ return target && outside(target) ? target : undefined
110
105
  }))
111
106
  const path = [...new Set(entries.filter((entry) => entry !== undefined))].join(delimiter)
112
107
  if (!path) throw new Error("PATH has no command directories outside the writable locations")
113
108
  return path
114
109
  }
115
110
 
116
- // A dangling executable link is unsafe too: its target could be created inside the workspace before execution.
117
- async function existingPath(path: string): Promise<string | undefined> {
118
- try { lstatSync(path) } catch { return undefined }
119
- return realpath(path)
120
- }
121
-
122
111
  // The real path where it exists; a path that does not exist yet has nothing to resolve.
123
112
  function real(path: string): Promise<string> {
124
113
  return realpath(path).catch(() => path)
package/src/store.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { constants } from "node:fs"
2
- import { lstat, mkdir, open, rename, unlink, writeFile } from "node:fs/promises"
3
- import { basename, dirname, join } from "node:path"
1
+ import { chmod, mkdir, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
4
3
 
5
4
  export async function readJson<T>(path: string): Promise<T | undefined> {
6
5
  const file = Bun.file(path)
@@ -12,35 +11,9 @@ export async function writeJson(path: string, value: unknown): Promise<void> {
12
11
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
13
12
  }
14
13
 
15
- // Finish and sync a private file before atomically replacing the old credentials.
14
+ // Secrets are readable by the user only, including files that already existed.
16
15
  export async function writeSecret(path: string, value: unknown): Promise<void> {
17
- const text = JSON.stringify(value, null, 2)
18
- if (text === undefined) throw new Error("Credentials must be JSON data")
19
- const dir = dirname(path)
20
- await mkdir(dir, { recursive: true, mode: 0o700 })
21
- const directory = await open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
22
- try {
23
- await directory.chmod(0o700)
24
- } finally {
25
- await directory.close()
26
- }
27
- const existing = await lstat(path).catch((error) => {
28
- if (error.code === "ENOENT") return undefined
29
- throw error
30
- })
31
- if (existing && (!existing.isFile() || existing.nlink !== 1)) throw new Error("Credentials must be a regular file with one link")
32
- const temporary = join(dir, `.${basename(path)}.${crypto.randomUUID()}.tmp`)
33
- const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
34
- try {
35
- try {
36
- await file.chmod(0o600)
37
- await file.writeFile(`${text}\n`)
38
- await file.sync()
39
- } finally {
40
- await file.close()
41
- }
42
- await rename(temporary, path)
43
- } finally {
44
- await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error })
45
- }
16
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
17
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
18
+ await chmod(path, 0o600)
46
19
  }
@@ -1,18 +1,12 @@
1
1
  // Changes to files: planned by a tool, approved by the user, then written here.
2
- import { type BigIntStats, constants, fstatSync, ftruncateSync, readFileSync, statSync, writeSync } from "node:fs"
3
- import { type FileHandle, mkdir, open, realpath } from "node:fs/promises"
2
+ import { constants } from "node:fs"
3
+ import { mkdir, open, realpath, stat } from "node:fs/promises"
4
4
  import { dirname, relative, sep } from "node:path"
5
5
  import type { ToolContext } from "."
6
6
  import { fingerprint, resolveInWorkspace } from "./workspace"
7
7
 
8
8
  // `before` is undefined for a new file.
9
- export type Change = { path: string; target: string; before: string | undefined; version?: string; after: string }
10
-
11
- // A file's identity and revision, without losing precision for inode numbers or timestamps.
12
- function version(state: BigIntStats): string {
13
- if (!state.isFile() || state.nlink !== 1n) throw new Error("Changes require a regular file with only one hard link")
14
- return `${state.dev}:${state.ino}:${state.size}:${state.mtimeNs}:${state.ctimeNs}`
15
- }
9
+ export type Change = { path: string; target: string; before: string | undefined; after: string }
16
10
 
17
11
  // Resolves where a change would land, and what is there now.
18
12
  export async function current(path: string, context: ToolContext): Promise<Omit<Change, "after">> {
@@ -20,58 +14,34 @@ export async function current(path: string, context: ToolContext): Promise<Omit<
20
14
  // Git runs hooks from here; nothing the model writes should land in it.
21
15
  const fromRoot = relative(await realpath(context.workspace), target)
22
16
  if (fromRoot.split(sep).includes(".git")) throw new Error(`${path} is inside .git`)
23
- const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch((error) => {
24
- if (error.code === "ENOENT") return undefined
25
- throw error
26
- })
27
- if (!file) return { path, target, before: undefined }
17
+ const file = Bun.file(target)
18
+ if (!(await file.exists())) return { path, target, before: undefined }
19
+ // Another hard link to the file may live outside the workspace, and would change with it.
20
+ if ((await stat(target)).nlink > 1) throw new Error(`${path} has more than one hard link`)
28
21
 
29
22
  // A file is changed only as the model last read it, so that no one's edits are lost.
30
- try {
31
- const stamp = version(await file.stat({ bigint: true }))
32
- const before = await file.readFile("utf8")
33
- if (target !== await resolveInWorkspace(path, context.workspace) || version(statSync(target, { bigint: true })) !== stamp ||
34
- version(await file.stat({ bigint: true })) !== stamp || context.seen.get(target) !== fingerprint(before)) {
35
- throw new Error(`Read ${path} first: it has not been read in this session, or it changed since`)
36
- }
37
- return { path, target, before, version: stamp }
38
- } finally {
39
- await file.close()
23
+ const before = await file.text()
24
+ if (context.seen.get(target) !== fingerprint(before)) {
25
+ throw new Error(`Read ${path} first: it has not been read in this session, or it changed since`)
40
26
  }
27
+ return { path, target, before }
41
28
  }
42
29
 
43
30
  export async function apply(change: Change, context: ToolContext): Promise<string> {
44
- const changed = () => new Error(`Read ${change.path} again: it changed while awaiting approval`)
45
- let file: FileHandle | undefined
31
+ // Approval can take a while. Look again, so that edits made meanwhile are kept,
32
+ // and a file swapped for a link meanwhile is not followed outside the workspace.
33
+ const latest = await current(change.path, context)
34
+ if (latest.target !== change.target || latest.before !== change.before) {
35
+ throw new Error(`Read ${change.path} again: it changed while awaiting approval`)
36
+ }
37
+ await mkdir(dirname(change.target), { recursive: true })
38
+ // Never through a link put in place at the last moment, and a new file must still be new.
39
+ const flags = constants.O_WRONLY | constants.O_NOFOLLOW | (change.before === undefined ? constants.O_CREAT | constants.O_EXCL : constants.O_TRUNC)
40
+ const file = await open(change.target, flags)
46
41
  try {
47
- const latest = await current(change.path, context)
48
- if (latest.target !== change.target || latest.before !== change.before || latest.version !== change.version) throw changed()
49
- await mkdir(dirname(change.target), { recursive: true })
50
- if (await resolveInWorkspace(change.path, context.workspace) !== change.target) throw changed()
51
-
52
- // Never truncate on open. A new file must not exist; an existing file must still be the approved inode.
53
- const flags = constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK |
54
- (change.before === undefined ? constants.O_CREAT | constants.O_EXCL : 0)
55
- file = await open(change.target, flags)
56
- if (await resolveInWorkspace(change.path, context.workspace) !== change.target) throw changed()
57
- const state = fstatSync(file.fd, { bigint: true })
58
- const stamp = version(state)
59
- if (change.before === undefined ? state.size !== 0n : stamp !== change.version || readFileSync(file.fd, "utf8") !== change.before) throw changed()
60
- if (version(fstatSync(file.fd, { bigint: true })) !== stamp || version(statSync(change.target, { bigint: true })) !== stamp) throw changed()
61
-
62
- // Write to the checked descriptor at explicit offsets, with no async gap after the final checks.
63
- const content = Buffer.from(change.after)
64
- for (let offset = 0; offset < content.length;) {
65
- const written = writeSync(file.fd, content, offset, content.length - offset, offset)
66
- if (written === 0) throw new Error(`Could not finish writing ${change.path}`)
67
- offset += written
68
- }
69
- ftruncateSync(file.fd, content.length)
70
- } catch (error) {
71
- context.seen.delete(change.target)
72
- throw error
42
+ await file.writeFile(change.after)
73
43
  } finally {
74
- await file?.close()
44
+ await file.close()
75
45
  }
76
46
  context.seen.set(change.target, fingerprint(change.after))
77
47
  const lines = change.after.split("\n").length