mthds 0.6.4 → 0.7.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/dist/agent/binaries.js +2 -2
- package/dist/agent/commands/bootstrap.js +2 -10
- package/dist/agent/commands/bootstrap.js.map +1 -1
- package/dist/agent/commands/codex-config.d.ts +29 -15
- package/dist/agent/commands/codex-config.js +214 -124
- package/dist/agent/commands/codex-config.js.map +1 -1
- package/dist/agent/commands/codex-hook.d.ts +4 -6
- package/dist/agent/commands/codex-hook.js +6 -8
- package/dist/agent/commands/codex-hook.js.map +1 -1
- package/dist/agent/commands/codex.d.ts +32 -19
- package/dist/agent/commands/codex.js +87 -134
- package/dist/agent/commands/codex.js.map +1 -1
- package/dist/agent/commands/doctor.js +49 -13
- package/dist/agent/commands/doctor.js.map +1 -1
- package/dist/agent/commands/update-check.js +1 -30
- package/dist/agent/commands/update-check.js.map +1 -1
- package/dist/agent/commands/upgrade.js +3 -12
- package/dist/agent/commands/upgrade.js.map +1 -1
- package/dist/agent/plugin-version.d.ts +1 -1
- package/dist/agent/plugin-version.js +1 -1
- package/dist/agent/snooze.d.ts +32 -4
- package/dist/agent/snooze.js +98 -37
- package/dist/agent/snooze.js.map +1 -1
- package/dist/agent/update-cache.d.ts +103 -12
- package/dist/agent/update-cache.js +403 -39
- package/dist/agent/update-cache.js.map +1 -1
- package/dist/agent-cli.js +2 -10
- package/dist/agent-cli.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cache for update-check results.
|
|
2
|
+
* Cache for update-check results, plus the just-upgraded marker.
|
|
3
3
|
*
|
|
4
|
-
* Primary location: ~/.mthds/state
|
|
5
|
-
* Fallback location: $TMPDIR/mthds-agent
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Primary location: ~/.mthds/state/.
|
|
5
|
+
* Fallback location: $TMPDIR/mthds-agent-<uid>/ — used when the primary
|
|
6
|
+
* location is not writable (e.g. Codex's workspaceWrite sandbox permits writes
|
|
7
|
+
* only under cwd / configured roots / $TMPDIR, not under the user's home dir).
|
|
8
|
+
* The fallback path is predictable, so it is validated against symlink/TOCTOU
|
|
9
|
+
* tampering before use — see `ensureFallbackDir`.
|
|
9
10
|
*
|
|
10
|
-
* Two
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Two files live in each location:
|
|
12
|
+
*
|
|
13
|
+
* 1. `last-update-check` — TTL'd cache of update-check results.
|
|
14
|
+
* Two-line format: aggregate status, then JSON payload of per-binary results.
|
|
15
|
+
* Split TTL: 60 min for UP_TO_DATE, 720 min for UPGRADE_AVAILABLE.
|
|
16
|
+
*
|
|
17
|
+
* 2. `just-upgraded-from` — one-shot marker written by `mthds-agent upgrade`
|
|
18
|
+
* (and bootstrap) so the next update-check can announce what was upgraded.
|
|
19
|
+
* Consumed within the same skill flow, so a short TTL is enough; older
|
|
20
|
+
* markers are treated as stuck (sandbox blocked cleanup last time) and
|
|
21
|
+
* ignored to stop the announcement from replaying forever.
|
|
13
22
|
*
|
|
14
23
|
* TTL is based on file mtime (like gstack), not an embedded timestamp.
|
|
15
|
-
* Split TTL: 60 min for UP_TO_DATE, 720 min for UPGRADE_AVAILABLE.
|
|
16
24
|
*/
|
|
17
25
|
import type { VersionStatus } from "../installer/runtime/version-check.js";
|
|
18
26
|
export type AggregateStatus = "UP_TO_DATE" | "UPGRADE_AVAILABLE";
|
|
@@ -34,11 +42,38 @@ export interface CacheResult {
|
|
|
34
42
|
aggregate: AggregateStatus;
|
|
35
43
|
payload: CachePayload;
|
|
36
44
|
}
|
|
45
|
+
/** Why the fallback directory is or isn't usable. The `symlink`,
|
|
46
|
+
* `foreign-owner`, `not-a-dir`, and `insecure-tmp` reasons are *suspicious*
|
|
47
|
+
* (possible tampering) and trigger a one-shot warning; `absent`/`error` are
|
|
48
|
+
* benign (the dir simply isn't there yet, or a transient FS error). */
|
|
49
|
+
export type FallbackDirReason = "ok" | "absent" | "symlink" | "foreign-owner" | "not-a-dir" | "insecure-tmp" | "error";
|
|
50
|
+
export interface FallbackDirStatus {
|
|
51
|
+
usable: boolean;
|
|
52
|
+
reason: FallbackDirReason;
|
|
53
|
+
}
|
|
54
|
+
/** One minute in milliseconds. Base unit for the TTLs below and for the
|
|
55
|
+
* clock-skew tolerance applied when comparing file mtimes to the wall clock. */
|
|
56
|
+
export declare const MS_PER_MINUTE = 60000;
|
|
57
|
+
/** Primary state directory (~/.mthds/state). Exported so snooze.ts shares the
|
|
58
|
+
* same dual-path layout from a single source of truth. */
|
|
37
59
|
export declare const STATE_DIR: string;
|
|
38
|
-
/**
|
|
39
|
-
|
|
60
|
+
/** Fallback state directory, used when STATE_DIR is not writable (Codex's
|
|
61
|
+
* workspaceWrite sandbox). The name carries the current uid so that multiple
|
|
62
|
+
* users on a shared host never collide on ownership — an unsuffixed name
|
|
63
|
+
* created by user A would fail user B's ownership check and permanently deny
|
|
64
|
+
* them the fallback. Exported for snooze.ts. */
|
|
65
|
+
export declare const FALLBACK_DIR: string;
|
|
66
|
+
export declare const SANDBOX_WRITE_ERRORS: ReadonlySet<string>;
|
|
40
67
|
/** Compute aggregate status from a payload. */
|
|
41
68
|
export declare function computeAggregate(payload: CachePayload): AggregateStatus;
|
|
69
|
+
/**
|
|
70
|
+
* Validate — and, when `create` is true, create — the per-uid fallback
|
|
71
|
+
* directory. Returns whether it is safe to read or write state files inside
|
|
72
|
+
* it; callers skip the fallback path entirely on `{usable: false}`.
|
|
73
|
+
*
|
|
74
|
+
* Exported so snooze.ts gates its own fallback access through the same check.
|
|
75
|
+
*/
|
|
76
|
+
export declare function ensureFallbackDir(create: boolean): FallbackDirStatus;
|
|
42
77
|
/**
|
|
43
78
|
* Read the update-check cache.
|
|
44
79
|
*
|
|
@@ -49,6 +84,34 @@ export declare function computeAggregate(payload: CachePayload): AggregateStatus
|
|
|
49
84
|
* older one is a stale snapshot from before the redirect happened.
|
|
50
85
|
*/
|
|
51
86
|
export declare function readCache(): CacheResult | null;
|
|
87
|
+
export interface WriteAttempt {
|
|
88
|
+
ok: boolean;
|
|
89
|
+
code?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort `mkdir -p` + `writeFile` for the PRIMARY state directory
|
|
93
|
+
* (~/.mthds/state, under $HOME — not a shared-/tmp attack surface). Returns
|
|
94
|
+
* `{ok: true}` on success, or `{ok: false, code}` on any failure (errno code
|
|
95
|
+
* if available, else stringified error). Callers decide whether to retry on
|
|
96
|
+
* the fallback path based on `code` — see `SANDBOX_WRITE_ERRORS`. The fallback
|
|
97
|
+
* directory has its own hardened path; see `ensureFallbackDir`.
|
|
98
|
+
*/
|
|
99
|
+
export declare function writeFileAt(dir: string, file: string, content: string): WriteAttempt;
|
|
100
|
+
/**
|
|
101
|
+
* Write `content` to `primaryPath`; on a sandbox/permission failure (see
|
|
102
|
+
* `SANDBOX_WRITE_ERRORS`) retry once at `fallbackPath` inside the hardened
|
|
103
|
+
* fallback directory. Other failure classes (ENOSPC, IO errors) are not
|
|
104
|
+
* improved by retrying elsewhere, so they are reported without a fallback
|
|
105
|
+
* attempt. The single owner of the try-primary-then-fallback policy shared by
|
|
106
|
+
* the cache, marker, and snooze writers — callers only supply their own
|
|
107
|
+
* one-shot warning text.
|
|
108
|
+
*
|
|
109
|
+
* `fallbackPath` must be a file inside `FALLBACK_DIR`; the directory is
|
|
110
|
+
* created/validated here via `ensureFallbackDir`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function writeWithFallback(primaryDir: string, primaryPath: string, fallbackPath: string, content: string): WriteAttempt & {
|
|
113
|
+
fallbackCode?: string;
|
|
114
|
+
};
|
|
52
115
|
/**
|
|
53
116
|
* Write cache. Tries the primary path first; on sandbox / permission failures
|
|
54
117
|
* falls back to $TMPDIR. Both failing emits at most one warning per process.
|
|
@@ -56,3 +119,31 @@ export declare function readCache(): CacheResult | null;
|
|
|
56
119
|
export declare function writeCache(result: CacheResult): void;
|
|
57
120
|
/** Delete cache files (used by --force and after upgrade). */
|
|
58
121
|
export declare function clearCache(): void;
|
|
122
|
+
/**
|
|
123
|
+
* Write the just-upgraded marker. Sandbox-aware: falls back to $TMPDIR when
|
|
124
|
+
* ~/.mthds/state/ is not writable. The marker is best-effort — a write
|
|
125
|
+
* failure is warned (once per process) but never thrown.
|
|
126
|
+
*/
|
|
127
|
+
export declare function writeUpgradeMarker(data: Record<string, unknown>): void;
|
|
128
|
+
/**
|
|
129
|
+
* Best-effort invalidation. unlinkSync first (the desired outcome); if that
|
|
130
|
+
* fails — typically EPERM under the sandbox — overwrite with empty content so
|
|
131
|
+
* the next read parses as invalid (empty content fails JSON.parse and is also
|
|
132
|
+
* rejected by the single-line snooze parser). The overwrite goes through
|
|
133
|
+
* `writeFileNoFollow`, so a symlink swapped in for the file is rejected
|
|
134
|
+
* (`ELOOP`) and reported as a failed invalidation rather than followed.
|
|
135
|
+
* Returns true when the file is either gone or guaranteed unparseable.
|
|
136
|
+
*/
|
|
137
|
+
export declare function invalidateFileAt(path: string): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Read and consume the just-upgraded marker. Returns null when no marker is
|
|
140
|
+
* present, when the most recent marker is older than MARKER_TTL_MS (stale —
|
|
141
|
+
* likely stuck from a session where the sandbox blocked cleanup), or when
|
|
142
|
+
* the content cannot be parsed.
|
|
143
|
+
*
|
|
144
|
+
* Sandbox-aware: inspects both the primary and the fallback path, prefers the
|
|
145
|
+
* newer one, and best-effort cleans up both regardless of whether the marker
|
|
146
|
+
* was honored or rejected — so a stuck marker stops replaying as soon as we
|
|
147
|
+
* regain write access to its directory.
|
|
148
|
+
*/
|
|
149
|
+
export declare function readAndClearUpgradeMarker(): Record<string, unknown> | null;
|
|
@@ -1,38 +1,84 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cache for update-check results.
|
|
2
|
+
* Cache for update-check results, plus the just-upgraded marker.
|
|
3
3
|
*
|
|
4
|
-
* Primary location: ~/.mthds/state
|
|
5
|
-
* Fallback location: $TMPDIR/mthds-agent
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Primary location: ~/.mthds/state/.
|
|
5
|
+
* Fallback location: $TMPDIR/mthds-agent-<uid>/ — used when the primary
|
|
6
|
+
* location is not writable (e.g. Codex's workspaceWrite sandbox permits writes
|
|
7
|
+
* only under cwd / configured roots / $TMPDIR, not under the user's home dir).
|
|
8
|
+
* The fallback path is predictable, so it is validated against symlink/TOCTOU
|
|
9
|
+
* tampering before use — see `ensureFallbackDir`.
|
|
9
10
|
*
|
|
10
|
-
* Two
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Two files live in each location:
|
|
12
|
+
*
|
|
13
|
+
* 1. `last-update-check` — TTL'd cache of update-check results.
|
|
14
|
+
* Two-line format: aggregate status, then JSON payload of per-binary results.
|
|
15
|
+
* Split TTL: 60 min for UP_TO_DATE, 720 min for UPGRADE_AVAILABLE.
|
|
16
|
+
*
|
|
17
|
+
* 2. `just-upgraded-from` — one-shot marker written by `mthds-agent upgrade`
|
|
18
|
+
* (and bootstrap) so the next update-check can announce what was upgraded.
|
|
19
|
+
* Consumed within the same skill flow, so a short TTL is enough; older
|
|
20
|
+
* markers are treated as stuck (sandbox blocked cleanup last time) and
|
|
21
|
+
* ignored to stop the announcement from replaying forever.
|
|
13
22
|
*
|
|
14
23
|
* TTL is based on file mtime (like gstack), not an embedded timestamp.
|
|
15
|
-
* Split TTL: 60 min for UP_TO_DATE, 720 min for UPGRADE_AVAILABLE.
|
|
16
24
|
*/
|
|
17
25
|
import { join } from "node:path";
|
|
18
26
|
import { homedir, tmpdir } from "node:os";
|
|
19
|
-
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, } from "node:fs";
|
|
27
|
+
import { mkdirSync, chmodSync, readFileSync, writeFileSync, unlinkSync, statSync, lstatSync, openSync, closeSync, constants as fsConstants, } from "node:fs";
|
|
20
28
|
// ── Constants ──────────────────────────────────────────────────────
|
|
29
|
+
/** One minute in milliseconds. Base unit for the TTLs below and for the
|
|
30
|
+
* clock-skew tolerance applied when comparing file mtimes to the wall clock. */
|
|
31
|
+
export const MS_PER_MINUTE = 60_000;
|
|
32
|
+
/** Primary state directory (~/.mthds/state). Exported so snooze.ts shares the
|
|
33
|
+
* same dual-path layout from a single source of truth. */
|
|
21
34
|
export const STATE_DIR = join(homedir(), ".mthds", "state");
|
|
35
|
+
/** uid of the current process, or null on Windows where `process.getuid` is
|
|
36
|
+
* absent. The /tmp symlink/TOCTOU hardening is POSIX-specific; on Windows the
|
|
37
|
+
* fallback keeps the legacy unsuffixed name and skips the strict checks. */
|
|
38
|
+
const FALLBACK_UID = typeof process.getuid === "function" ? process.getuid() : null;
|
|
39
|
+
/** Fallback state directory, used when STATE_DIR is not writable (Codex's
|
|
40
|
+
* workspaceWrite sandbox). The name carries the current uid so that multiple
|
|
41
|
+
* users on a shared host never collide on ownership — an unsuffixed name
|
|
42
|
+
* created by user A would fail user B's ownership check and permanently deny
|
|
43
|
+
* them the fallback. Exported for snooze.ts. */
|
|
44
|
+
export const FALLBACK_DIR = join(tmpdir(), FALLBACK_UID === null ? "mthds-agent" : `mthds-agent-${FALLBACK_UID}`);
|
|
45
|
+
/** O_NOFOLLOW makes a write refuse a symlink as the final path component
|
|
46
|
+
* (throws ELOOP) rather than following it. Undefined on Windows — `?? 0`
|
|
47
|
+
* makes it a no-op there. */
|
|
48
|
+
const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
49
|
+
/** Flags for a create-or-truncate write — equivalent to the default `"w"`
|
|
50
|
+
* (O_WRONLY|O_CREAT|O_TRUNC) plus O_NOFOLLOW, so a hijacked leaf symlink is
|
|
51
|
+
* rejected instead of followed. */
|
|
52
|
+
const WRITE_FLAGS = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | O_NOFOLLOW;
|
|
22
53
|
const PRIMARY_CACHE_PATH = join(STATE_DIR, "last-update-check");
|
|
23
|
-
const
|
|
54
|
+
const PRIMARY_MARKER_PATH = join(STATE_DIR, "just-upgraded-from");
|
|
24
55
|
const FALLBACK_CACHE_PATH = join(FALLBACK_DIR, "last-update-check");
|
|
25
|
-
const
|
|
26
|
-
const
|
|
56
|
+
const FALLBACK_MARKER_PATH = join(FALLBACK_DIR, "just-upgraded-from");
|
|
57
|
+
const TTL_UP_TO_DATE_MS = 60 * MS_PER_MINUTE; // 60 min
|
|
58
|
+
const TTL_UPGRADE_AVAILABLE_MS = 720 * MS_PER_MINUTE; // 720 min (12 hours)
|
|
59
|
+
// Real markers are consumed within seconds (skill flow re-runs preamble
|
|
60
|
+
// immediately after upgrade). Anything markedly older is almost certainly
|
|
61
|
+
// stuck because the sandbox blocked cleanup last time — ignore it instead of
|
|
62
|
+
// replaying the announcement on every update-check.
|
|
63
|
+
const MARKER_TTL_MS = 60 * MS_PER_MINUTE; // 60 min
|
|
27
64
|
const VALID_AGGREGATES = new Set([
|
|
28
65
|
"UP_TO_DATE",
|
|
29
66
|
"UPGRADE_AVAILABLE",
|
|
30
67
|
]);
|
|
31
|
-
const SANDBOX_WRITE_ERRORS = new Set([
|
|
68
|
+
export const SANDBOX_WRITE_ERRORS = new Set([
|
|
32
69
|
"EPERM",
|
|
33
70
|
"EACCES",
|
|
34
71
|
"EROFS",
|
|
35
72
|
]);
|
|
73
|
+
/** Fallback-dir refusal reasons that indicate possible tampering (as opposed
|
|
74
|
+
* to a benign "not there yet" / transient error). These get a one-shot
|
|
75
|
+
* warning and a `unsafe:` prefix in the write-failure detail. */
|
|
76
|
+
const SUSPICIOUS_REASONS = new Set([
|
|
77
|
+
"symlink",
|
|
78
|
+
"foreign-owner",
|
|
79
|
+
"not-a-dir",
|
|
80
|
+
"insecure-tmp",
|
|
81
|
+
]);
|
|
36
82
|
// ── Validation ──────────────────────────────────────────────────────
|
|
37
83
|
function isValidPayload(p) {
|
|
38
84
|
if (!p || typeof p !== "object")
|
|
@@ -64,13 +110,12 @@ function isValidPayload(p) {
|
|
|
64
110
|
}
|
|
65
111
|
return true;
|
|
66
112
|
}
|
|
67
|
-
// ── Per-process warning
|
|
113
|
+
// ── Per-process warning latches ────────────────────────────────────
|
|
68
114
|
let warnedAboutCacheWrite = false;
|
|
115
|
+
let warnedAboutMarkerWrite = false;
|
|
116
|
+
let warnedAboutMarkerClear = false;
|
|
117
|
+
let warnedAboutFallbackUnsafe = false;
|
|
69
118
|
// ── Functions ──────────────────────────────────────────────────────
|
|
70
|
-
/** Ensure the state directory exists. */
|
|
71
|
-
export function ensureStateDir() {
|
|
72
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
73
|
-
}
|
|
74
119
|
/** Compute aggregate status from a payload. */
|
|
75
120
|
export function computeAggregate(payload) {
|
|
76
121
|
const entries = [payload.mthds_agent, payload.plxt];
|
|
@@ -85,6 +130,145 @@ export function computeAggregate(payload) {
|
|
|
85
130
|
? "UP_TO_DATE"
|
|
86
131
|
: "UPGRADE_AVAILABLE";
|
|
87
132
|
}
|
|
133
|
+
// ── Fallback directory hardening ────────────────────────────────────
|
|
134
|
+
//
|
|
135
|
+
// $TMPDIR/mthds-agent-<uid> is a predictable path on a shared, world-writable
|
|
136
|
+
// /tmp. A *different-uid* local attacker could pre-create it as a symlink, or
|
|
137
|
+
// as a directory they own, so that our writes land somewhere they control or
|
|
138
|
+
// our chmod follows a symlink. `ensureFallbackDir` closes this: once we hold a
|
|
139
|
+
// real directory we own at mode 0o700 under a sticky parent, that attacker can
|
|
140
|
+
// neither rename/delete/swap it (sticky bit) nor enter it (0o700) — so it
|
|
141
|
+
// stays safe for the rest of the process, and the result is memoized.
|
|
142
|
+
//
|
|
143
|
+
// NOT defended: a *same-uid* attacker (a compromised sibling process). POSIX
|
|
144
|
+
// permissions cannot defend a uid against itself. O_NOFOLLOW on every fallback
|
|
145
|
+
// file write (see `writeFileNoFollow`) is the per-operation backstop there.
|
|
146
|
+
/** Memoized sticky outcome: a validated-safe directory or a suspicious
|
|
147
|
+
* refusal. A benign `absent`/`error` is never memoized — a later call may
|
|
148
|
+
* legitimately create the directory, or the transient error may clear. */
|
|
149
|
+
let fallbackDirMemo = null;
|
|
150
|
+
function memoizeFallback(status) {
|
|
151
|
+
fallbackDirMemo = status;
|
|
152
|
+
return status;
|
|
153
|
+
}
|
|
154
|
+
function refuseFallback(reason) {
|
|
155
|
+
emitFallbackUnsafeWarning(reason);
|
|
156
|
+
return memoizeFallback({ usable: false, reason });
|
|
157
|
+
}
|
|
158
|
+
function emitFallbackUnsafeWarning(reason) {
|
|
159
|
+
if (warnedAboutFallbackUnsafe)
|
|
160
|
+
return;
|
|
161
|
+
warnedAboutFallbackUnsafe = true;
|
|
162
|
+
const why = {
|
|
163
|
+
symlink: "it is a symlink (possible tampering)",
|
|
164
|
+
"foreign-owner": "it is owned by another user (possible tampering)",
|
|
165
|
+
"not-a-dir": "it exists but is not a directory",
|
|
166
|
+
"insecure-tmp": `its parent ${tmpdir()} is world-writable without the sticky bit`,
|
|
167
|
+
};
|
|
168
|
+
process.stderr.write(`Warning: refusing fallback state directory ${FALLBACK_DIR} — ${why[reason] ?? reason}. Update state will not be cached this run.\n`);
|
|
169
|
+
}
|
|
170
|
+
/** Validate an already-existing FALLBACK_DIR via `lstatSync` (which does not
|
|
171
|
+
* follow a symlinked leaf). Owned-but-loose permissions are corrected in
|
|
172
|
+
* place; anything else suspicious is refused. */
|
|
173
|
+
function validateExistingFallbackDir() {
|
|
174
|
+
let st;
|
|
175
|
+
try {
|
|
176
|
+
st = lstatSync(FALLBACK_DIR);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// ENOENT on a read-only (create:false) probe just means "nothing cached".
|
|
180
|
+
if (err.code === "ENOENT") {
|
|
181
|
+
return { usable: false, reason: "absent" };
|
|
182
|
+
}
|
|
183
|
+
return { usable: false, reason: "error" };
|
|
184
|
+
}
|
|
185
|
+
if (st.isSymbolicLink())
|
|
186
|
+
return refuseFallback("symlink");
|
|
187
|
+
if (!st.isDirectory())
|
|
188
|
+
return refuseFallback("not-a-dir");
|
|
189
|
+
if (st.uid !== FALLBACK_UID)
|
|
190
|
+
return refuseFallback("foreign-owner");
|
|
191
|
+
// Owned by us but group/other have access (e.g. created by a version before
|
|
192
|
+
// the 0o700 hardening). chmod it back and re-check rather than refuse.
|
|
193
|
+
if ((st.mode & 0o077) !== 0) {
|
|
194
|
+
try {
|
|
195
|
+
chmodSync(FALLBACK_DIR, 0o700);
|
|
196
|
+
if ((lstatSync(FALLBACK_DIR).mode & 0o077) !== 0) {
|
|
197
|
+
return { usable: false, reason: "error" };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return { usable: false, reason: "error" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return memoizeFallback({ usable: true, reason: "ok" });
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Validate — and, when `create` is true, create — the per-uid fallback
|
|
208
|
+
* directory. Returns whether it is safe to read or write state files inside
|
|
209
|
+
* it; callers skip the fallback path entirely on `{usable: false}`.
|
|
210
|
+
*
|
|
211
|
+
* Exported so snooze.ts gates its own fallback access through the same check.
|
|
212
|
+
*/
|
|
213
|
+
export function ensureFallbackDir(create) {
|
|
214
|
+
if (fallbackDirMemo)
|
|
215
|
+
return fallbackDirMemo;
|
|
216
|
+
// Windows: process.getuid is absent and the /tmp symlink class of attack is
|
|
217
|
+
// POSIX-specific (%TEMP% is per-user). Keep the legacy behavior — create on
|
|
218
|
+
// demand, no strict checks — and do not memoize, so a later create:true
|
|
219
|
+
// still makes the directory after an earlier create:false probe.
|
|
220
|
+
if (FALLBACK_UID === null) {
|
|
221
|
+
if (create) {
|
|
222
|
+
try {
|
|
223
|
+
mkdirSync(FALLBACK_DIR, { recursive: true, mode: 0o700 });
|
|
224
|
+
return { usable: true, reason: "ok" };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return { usable: false, reason: "error" };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
lstatSync(FALLBACK_DIR);
|
|
232
|
+
return { usable: true, reason: "ok" };
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { usable: false, reason: "absent" };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Parent check: a world-writable $TMPDIR without the sticky bit lets a
|
|
239
|
+
// different-uid attacker rename our directory out from under us; a symlinked
|
|
240
|
+
// $TMPDIR redirects everything. lstatSync (not statSync) so a symlinked
|
|
241
|
+
// tmpdir is seen as a symlink, not as its target.
|
|
242
|
+
try {
|
|
243
|
+
const parent = lstatSync(tmpdir());
|
|
244
|
+
if (parent.isSymbolicLink() || !parent.isDirectory()) {
|
|
245
|
+
return refuseFallback("insecure-tmp");
|
|
246
|
+
}
|
|
247
|
+
const worldWritable = (parent.mode & 0o002) !== 0;
|
|
248
|
+
const sticky = (parent.mode & 0o1000) !== 0;
|
|
249
|
+
if (worldWritable && !sticky)
|
|
250
|
+
return refuseFallback("insecure-tmp");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return { usable: false, reason: "error" };
|
|
254
|
+
}
|
|
255
|
+
// Atomic create: a non-recursive mkdir either makes a fresh 0o700 directory
|
|
256
|
+
// we own, or throws EEXIST for ANY pre-existing entry (real dir, symlink, or
|
|
257
|
+
// file alike — so an lstat must follow to tell them apart).
|
|
258
|
+
if (create) {
|
|
259
|
+
try {
|
|
260
|
+
mkdirSync(FALLBACK_DIR, { mode: 0o700 });
|
|
261
|
+
return memoizeFallback({ usable: true, reason: "ok" });
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (err.code !== "EEXIST") {
|
|
265
|
+
return { usable: false, reason: "error" };
|
|
266
|
+
}
|
|
267
|
+
// EEXIST — fall through to validate what is already there.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return validateExistingFallbackDir();
|
|
271
|
+
}
|
|
88
272
|
/** Read a cache file at the given path. Returns null on any failure. */
|
|
89
273
|
function readCacheAt(path) {
|
|
90
274
|
let mtimeMs;
|
|
@@ -115,7 +299,7 @@ function readCacheAt(path) {
|
|
|
115
299
|
const ttl = aggregate === "UP_TO_DATE" ? TTL_UP_TO_DATE_MS : TTL_UPGRADE_AVAILABLE_MS;
|
|
116
300
|
const age = Date.now() - mtimeMs;
|
|
117
301
|
// Negative age beyond 1 minute means clock skew — treat as expired
|
|
118
|
-
if (age < -
|
|
302
|
+
if (age < -MS_PER_MINUTE || age > ttl)
|
|
119
303
|
return null;
|
|
120
304
|
return {
|
|
121
305
|
result: { aggregate: aggregate, payload },
|
|
@@ -133,7 +317,9 @@ function readCacheAt(path) {
|
|
|
133
317
|
*/
|
|
134
318
|
export function readCache() {
|
|
135
319
|
const primary = readCacheAt(PRIMARY_CACHE_PATH);
|
|
136
|
-
const fallback =
|
|
320
|
+
const fallback = ensureFallbackDir(false).usable
|
|
321
|
+
? readCacheAt(FALLBACK_CACHE_PATH)
|
|
322
|
+
: null;
|
|
137
323
|
if (!primary)
|
|
138
324
|
return fallback?.result ?? null;
|
|
139
325
|
if (!fallback)
|
|
@@ -142,10 +328,38 @@ export function readCache() {
|
|
|
142
328
|
? fallback.result
|
|
143
329
|
: primary.result;
|
|
144
330
|
}
|
|
145
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Write `content` to `file` without following a symlinked leaf. The file is
|
|
333
|
+
* opened with O_NOFOLLOW (so a symlink planted as the final path component
|
|
334
|
+
* throws `ELOOP` instead of redirecting the write) at an owner-only mode; the
|
|
335
|
+
* write goes through the fd via `writeFileSync` (which handles partial
|
|
336
|
+
* writes), and the fd is always closed.
|
|
337
|
+
*/
|
|
338
|
+
function writeFileNoFollow(file, content) {
|
|
339
|
+
const fd = openSync(file, WRITE_FLAGS, 0o600);
|
|
146
340
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
341
|
+
writeFileSync(fd, content);
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
closeSync(fd);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Best-effort `mkdir -p` + `writeFile` for the PRIMARY state directory
|
|
349
|
+
* (~/.mthds/state, under $HOME — not a shared-/tmp attack surface). Returns
|
|
350
|
+
* `{ok: true}` on success, or `{ok: false, code}` on any failure (errno code
|
|
351
|
+
* if available, else stringified error). Callers decide whether to retry on
|
|
352
|
+
* the fallback path based on `code` — see `SANDBOX_WRITE_ERRORS`. The fallback
|
|
353
|
+
* directory has its own hardened path; see `ensureFallbackDir`.
|
|
354
|
+
*/
|
|
355
|
+
export function writeFileAt(dir, file, content) {
|
|
356
|
+
try {
|
|
357
|
+
// mode 0o700 is honored only when mkdirSync creates the directory, so
|
|
358
|
+
// chmodSync re-asserts it on every write — correcting a directory left
|
|
359
|
+
// loose by an older version.
|
|
360
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
361
|
+
chmodSync(dir, 0o700);
|
|
362
|
+
writeFileNoFollow(file, content);
|
|
149
363
|
return { ok: true };
|
|
150
364
|
}
|
|
151
365
|
catch (err) {
|
|
@@ -153,26 +367,51 @@ function writeCacheAt(dir, file, content) {
|
|
|
153
367
|
return { ok: false, code };
|
|
154
368
|
}
|
|
155
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Write `content` to `primaryPath`; on a sandbox/permission failure (see
|
|
372
|
+
* `SANDBOX_WRITE_ERRORS`) retry once at `fallbackPath` inside the hardened
|
|
373
|
+
* fallback directory. Other failure classes (ENOSPC, IO errors) are not
|
|
374
|
+
* improved by retrying elsewhere, so they are reported without a fallback
|
|
375
|
+
* attempt. The single owner of the try-primary-then-fallback policy shared by
|
|
376
|
+
* the cache, marker, and snooze writers — callers only supply their own
|
|
377
|
+
* one-shot warning text.
|
|
378
|
+
*
|
|
379
|
+
* `fallbackPath` must be a file inside `FALLBACK_DIR`; the directory is
|
|
380
|
+
* created/validated here via `ensureFallbackDir`.
|
|
381
|
+
*/
|
|
382
|
+
export function writeWithFallback(primaryDir, primaryPath, fallbackPath, content) {
|
|
383
|
+
const primary = writeFileAt(primaryDir, primaryPath, content);
|
|
384
|
+
if (primary.ok)
|
|
385
|
+
return { ok: true };
|
|
386
|
+
if (primary.code && SANDBOX_WRITE_ERRORS.has(primary.code)) {
|
|
387
|
+
const dir = ensureFallbackDir(true);
|
|
388
|
+
if (!dir.usable) {
|
|
389
|
+
const fallbackCode = SUSPICIOUS_REASONS.has(dir.reason)
|
|
390
|
+
? `unsafe:${dir.reason}`
|
|
391
|
+
: dir.reason;
|
|
392
|
+
return { ok: false, code: primary.code, fallbackCode };
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
writeFileNoFollow(fallbackPath, content);
|
|
396
|
+
return { ok: true };
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
const fallbackCode = err.code ?? String(err);
|
|
400
|
+
return { ok: false, code: primary.code, fallbackCode };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { ok: false, code: primary.code };
|
|
404
|
+
}
|
|
156
405
|
/**
|
|
157
406
|
* Write cache. Tries the primary path first; on sandbox / permission failures
|
|
158
407
|
* falls back to $TMPDIR. Both failing emits at most one warning per process.
|
|
159
408
|
*/
|
|
160
409
|
export function writeCache(result) {
|
|
161
410
|
const content = result.aggregate + "\n" + JSON.stringify(result.payload) + "\n";
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
return;
|
|
165
|
-
// Only fall back for the sandbox/perm family of errors. Other failures
|
|
166
|
-
// (ENOSPC, IO errors, ...) are not improved by retrying in $TMPDIR, so we
|
|
167
|
-
// surface them via the same one-shot warning path.
|
|
168
|
-
if (primary.code && SANDBOX_WRITE_ERRORS.has(primary.code)) {
|
|
169
|
-
const fallback = writeCacheAt(FALLBACK_DIR, FALLBACK_CACHE_PATH, content);
|
|
170
|
-
if (fallback.ok)
|
|
171
|
-
return;
|
|
172
|
-
emitWriteWarning(primary.code, fallback.code);
|
|
411
|
+
const res = writeWithFallback(STATE_DIR, PRIMARY_CACHE_PATH, FALLBACK_CACHE_PATH, content);
|
|
412
|
+
if (res.ok)
|
|
173
413
|
return;
|
|
174
|
-
|
|
175
|
-
emitWriteWarning(primary.code);
|
|
414
|
+
emitWriteWarning(res.code, res.fallbackCode);
|
|
176
415
|
}
|
|
177
416
|
function emitWriteWarning(primaryCode, fallbackCode) {
|
|
178
417
|
if (warnedAboutCacheWrite)
|
|
@@ -185,7 +424,12 @@ function emitWriteWarning(primaryCode, fallbackCode) {
|
|
|
185
424
|
}
|
|
186
425
|
/** Delete cache files (used by --force and after upgrade). */
|
|
187
426
|
export function clearCache() {
|
|
188
|
-
|
|
427
|
+
const paths = [PRIMARY_CACHE_PATH];
|
|
428
|
+
// Only touch the fallback path when its directory passes the hardening
|
|
429
|
+
// check — never unlink through a symlinked or foreign-owned directory.
|
|
430
|
+
if (ensureFallbackDir(false).usable)
|
|
431
|
+
paths.push(FALLBACK_CACHE_PATH);
|
|
432
|
+
for (const p of paths) {
|
|
189
433
|
try {
|
|
190
434
|
unlinkSync(p);
|
|
191
435
|
}
|
|
@@ -196,4 +440,124 @@ export function clearCache() {
|
|
|
196
440
|
}
|
|
197
441
|
}
|
|
198
442
|
}
|
|
443
|
+
// ── Upgrade marker ──────────────────────────────────────────────────
|
|
444
|
+
//
|
|
445
|
+
// The marker is a one-shot hand-off from `mthds-agent upgrade` / `bootstrap`
|
|
446
|
+
// to the next `update-check`, so the skill preamble can announce what just
|
|
447
|
+
// changed. It uses the same primary/fallback layout as the cache, for the
|
|
448
|
+
// same reason: ~/.mthds/state/ is not writable under Codex's workspaceWrite
|
|
449
|
+
// sandbox, and the bug we are fixing here is exactly the case where the
|
|
450
|
+
// marker was written successfully in a non-sandboxed context and then could
|
|
451
|
+
// not be cleaned up later from a sandboxed one, replaying the announcement
|
|
452
|
+
// every update-check.
|
|
453
|
+
/**
|
|
454
|
+
* Write the just-upgraded marker. Sandbox-aware: falls back to $TMPDIR when
|
|
455
|
+
* ~/.mthds/state/ is not writable. The marker is best-effort — a write
|
|
456
|
+
* failure is warned (once per process) but never thrown.
|
|
457
|
+
*/
|
|
458
|
+
export function writeUpgradeMarker(data) {
|
|
459
|
+
const res = writeWithFallback(STATE_DIR, PRIMARY_MARKER_PATH, FALLBACK_MARKER_PATH, JSON.stringify(data));
|
|
460
|
+
if (res.ok)
|
|
461
|
+
return;
|
|
462
|
+
emitMarkerWriteWarning(res.code, res.fallbackCode);
|
|
463
|
+
}
|
|
464
|
+
function emitMarkerWriteWarning(primaryCode, fallbackCode) {
|
|
465
|
+
if (warnedAboutMarkerWrite)
|
|
466
|
+
return;
|
|
467
|
+
warnedAboutMarkerWrite = true;
|
|
468
|
+
const detail = fallbackCode
|
|
469
|
+
? `primary=${primaryCode ?? "?"}, fallback=${fallbackCode}`
|
|
470
|
+
: (primaryCode ?? "?");
|
|
471
|
+
process.stderr.write(`Warning: could not write upgrade marker (${detail}). The next update-check may not announce the upgrade.\n`);
|
|
472
|
+
}
|
|
473
|
+
function readMarkerAt(path) {
|
|
474
|
+
let mtimeMs;
|
|
475
|
+
let content;
|
|
476
|
+
try {
|
|
477
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
478
|
+
content = readFileSync(path, "utf-8");
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
let parsed;
|
|
484
|
+
try {
|
|
485
|
+
parsed = JSON.parse(content);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
return { data: parsed, mtimeMs };
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Best-effort invalidation. unlinkSync first (the desired outcome); if that
|
|
497
|
+
* fails — typically EPERM under the sandbox — overwrite with empty content so
|
|
498
|
+
* the next read parses as invalid (empty content fails JSON.parse and is also
|
|
499
|
+
* rejected by the single-line snooze parser). The overwrite goes through
|
|
500
|
+
* `writeFileNoFollow`, so a symlink swapped in for the file is rejected
|
|
501
|
+
* (`ELOOP`) and reported as a failed invalidation rather than followed.
|
|
502
|
+
* Returns true when the file is either gone or guaranteed unparseable.
|
|
503
|
+
*/
|
|
504
|
+
export function invalidateFileAt(path) {
|
|
505
|
+
try {
|
|
506
|
+
unlinkSync(path);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// fall through
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
writeFileNoFollow(path, "");
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Read and consume the just-upgraded marker. Returns null when no marker is
|
|
522
|
+
* present, when the most recent marker is older than MARKER_TTL_MS (stale —
|
|
523
|
+
* likely stuck from a session where the sandbox blocked cleanup), or when
|
|
524
|
+
* the content cannot be parsed.
|
|
525
|
+
*
|
|
526
|
+
* Sandbox-aware: inspects both the primary and the fallback path, prefers the
|
|
527
|
+
* newer one, and best-effort cleans up both regardless of whether the marker
|
|
528
|
+
* was honored or rejected — so a stuck marker stops replaying as soon as we
|
|
529
|
+
* regain write access to its directory.
|
|
530
|
+
*/
|
|
531
|
+
export function readAndClearUpgradeMarker() {
|
|
532
|
+
// Skip the fallback path entirely when its directory fails the hardening
|
|
533
|
+
// check — both the read and the cleanup below stay off a suspicious dir.
|
|
534
|
+
const fallbackUsable = ensureFallbackDir(false).usable;
|
|
535
|
+
const primary = readMarkerAt(PRIMARY_MARKER_PATH);
|
|
536
|
+
const fallback = fallbackUsable ? readMarkerAt(FALLBACK_MARKER_PATH) : null;
|
|
537
|
+
let chosen;
|
|
538
|
+
if (!primary)
|
|
539
|
+
chosen = fallback;
|
|
540
|
+
else if (!fallback)
|
|
541
|
+
chosen = primary;
|
|
542
|
+
else
|
|
543
|
+
chosen = fallback.mtimeMs > primary.mtimeMs ? fallback : primary;
|
|
544
|
+
if (!chosen)
|
|
545
|
+
return null;
|
|
546
|
+
// Negative age beyond 1 minute means clock skew — treat as stale so a
|
|
547
|
+
// future-dated marker can't replay the announcement forever.
|
|
548
|
+
const ageMs = Date.now() - chosen.mtimeMs;
|
|
549
|
+
const isStale = ageMs < -MS_PER_MINUTE || ageMs > MARKER_TTL_MS;
|
|
550
|
+
// Clean up both paths whether or not we honor the marker. We only attempt
|
|
551
|
+
// invalidation for paths that actually had content; otherwise an existsSync
|
|
552
|
+
// miss-then-create race could leave a zero-byte file we just created.
|
|
553
|
+
const primaryCleared = primary ? invalidateFileAt(PRIMARY_MARKER_PATH) : true;
|
|
554
|
+
const fallbackCleared = fallback ? invalidateFileAt(FALLBACK_MARKER_PATH) : true;
|
|
555
|
+
if ((!primaryCleared || !fallbackCleared) && !warnedAboutMarkerClear) {
|
|
556
|
+
warnedAboutMarkerClear = true;
|
|
557
|
+
process.stderr.write(`Warning: could not clear upgrade marker (primary=${primaryCleared ? "ok" : "blocked"}, fallback=${fallbackCleared ? "ok" : "blocked"}). It will be ignored after ${MARKER_TTL_MS / MS_PER_MINUTE}min.\n`);
|
|
558
|
+
}
|
|
559
|
+
if (isStale)
|
|
560
|
+
return null;
|
|
561
|
+
return chosen.data;
|
|
562
|
+
}
|
|
199
563
|
//# sourceMappingURL=update-cache.js.map
|