@nanhara/hara 0.124.2 → 0.124.4
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/CHANGELOG.md +31 -0
- package/dist/agent/loop.js +34 -2
- package/dist/checkpoints.js +4 -4
- package/dist/context/workspace-scope.js +46 -9
- package/dist/index.js +151 -30
- package/dist/runtime.js +13 -0
- package/dist/sandbox.js +2 -2
- package/dist/search/semindex.js +5 -5
- package/dist/security/permissions.js +2 -2
- package/dist/session/resume.js +52 -0
- package/dist/session/store.js +51 -4
- package/dist/session/task.js +6 -4
- package/dist/tools/cron.js +2 -2
- package/dist/tools/registry.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.124.4 — 2026-07-18 — deadline-aware task checkpoints
|
|
9
|
+
|
|
10
|
+
- **The 80% run-budget warning now reaches the Agent, not only the user.** Before the next model turn,
|
|
11
|
+
Hara injects a hidden checkpoint instruction to finish the current atomic step, persist artifacts,
|
|
12
|
+
update the checklist, and defer a new multi-minute stage to `/continue`. A video/image batch, full
|
|
13
|
+
validation, preview, render, install, or deployment should no longer begin with too little turn budget
|
|
14
|
+
and then appear to stop mysteriously at the 30-minute safety pause.
|
|
15
|
+
|
|
16
|
+
## 0.124.3 — 2026-07-17 — project-aware resume and runtime hardening
|
|
17
|
+
|
|
18
|
+
- **Saved sessions now resume in the project they belong to.** `hara resume <id>` validates the persisted
|
|
19
|
+
project root and relaunches there even when invoked from another directory; session lists show each project
|
|
20
|
+
path. Inside Hara, `/resume <id>` switches saved sessions while `/continue` exclusively resumes the active
|
|
21
|
+
task. Missing project roots, corrupt records, concurrent writers, and raw/headless cross-project resumes
|
|
22
|
+
remain fail-closed.
|
|
23
|
+
- **A total run deadline is now a resumable safety pause.** The 80% warning tells the agent to finish or
|
|
24
|
+
checkpoint its current step, the hard-stop notice explains that the task/checklist remains resumable with
|
|
25
|
+
`/continue`, and deadline outcomes persist as `paused` instead of looking irrecoverably blocked. Loop and
|
|
26
|
+
repeated-failure breakers remain blocked until the approach changes.
|
|
27
|
+
- **Home protection now covers recursive ancestor scopes.** Launching from `/`, `/Users`, a symlink alias, or
|
|
28
|
+
any directory that contains Home cannot implicitly enumerate or mutate private user state. Explicit child
|
|
29
|
+
projects remain usable, and interactive `/cd <project>` safely relaunches Hara in the selected project while
|
|
30
|
+
preserving explicit profile/model/approval/sandbox choices.
|
|
31
|
+
- **Windows portable Home is consistent for direct module consumers.** Explicit Git Bash/MSYS `HOME` wins over
|
|
32
|
+
a different `USERPROFILE` even when callers bypass the normal CLI bootstrap, keeping config, sessions,
|
|
33
|
+
indexes, permissions, cron state, and project boundaries on one private root.
|
|
34
|
+
- **Session input is bounded before parsing and recursive processing.** Resume/list reject oversized,
|
|
35
|
+
excessively deep, structure-heavy, symlinked, or hard-linked session files through a verified no-follow
|
|
36
|
+
snapshot; saves fail atomically with compact/new-session guidance before unsafe allocations.
|
|
37
|
+
- Upgrade with `npm i -g @nanhara/hara@0.124.3`.
|
|
38
|
+
|
|
8
39
|
## 0.124.2 — 2026-07-17 — reliable WeCom gateway transport
|
|
9
40
|
|
|
10
41
|
- **The WeCom gateway is ready only after WeCom accepts its credentials.** Hara now waits for the
|
package/dist/agent/loop.js
CHANGED
|
@@ -149,6 +149,13 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
|
|
|
149
149
|
}
|
|
150
150
|
const RUN_STOPPED = Symbol("agent-run-stopped");
|
|
151
151
|
const REPEATED_FAILURE_LIMIT = 3;
|
|
152
|
+
export function deadlineCheckpointReminder(timeoutMs) {
|
|
153
|
+
return (`Turn budget checkpoint: about 20% remains before the ${formatAgentDuration(timeoutMs)} safety pause. ` +
|
|
154
|
+
"Stop expanding scope. Finish only the current atomic step, persist any usable artifact, update todo_write, " +
|
|
155
|
+
"and reply with the completed checkpoint plus the next exact step. Do not start another generation batch, " +
|
|
156
|
+
"install, full validation suite, preview, render, deployment, or other multi-minute stage in this turn. " +
|
|
157
|
+
"The user can run /continue to start that next stage with a fresh bounded budget.");
|
|
158
|
+
}
|
|
152
159
|
function showRunNotice(opts, message, critical = false) {
|
|
153
160
|
if (opts.quiet)
|
|
154
161
|
return;
|
|
@@ -164,11 +171,20 @@ function showRunNotice(opts, message, critical = false) {
|
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
173
|
}
|
|
174
|
+
function requestRunCheckpoint(opts, life) {
|
|
175
|
+
if (life.disposed || life.checkpointDue || life.signal.aborted)
|
|
176
|
+
return;
|
|
177
|
+
life.checkpointDue = true;
|
|
178
|
+
const remainingMs = Math.max(0, life.timeoutMs - (Date.now() - life.startedAt));
|
|
179
|
+
showRunNotice(opts, `⚠ agent turn nearing its safety pause: ${formatAgentDuration(remainingMs)} remains. The agent will be told to finish the current atomic step and checkpoint; use \`/continue\` for the next expensive stage.`);
|
|
180
|
+
}
|
|
167
181
|
function warnRun(opts, life) {
|
|
168
182
|
if (life.disposed || life.warned || life.signal.aborted)
|
|
169
183
|
return;
|
|
170
184
|
life.warned = true;
|
|
171
|
-
|
|
185
|
+
const elapsedMs = Date.now() - life.startedAt;
|
|
186
|
+
const remainingMs = Math.max(0, life.timeoutMs - elapsedMs);
|
|
187
|
+
showRunNotice(opts, `⚠ agent still running: ${formatAgentDuration(elapsedMs)} elapsed, round ${life.rounds}/${life.maxRounds}; ${formatAgentDuration(remainingMs)} remains before this turn pauses. Finish the current step or leave a checklist checkpoint; unfinished session work can resume with \`/continue\`.`);
|
|
172
188
|
}
|
|
173
189
|
function createRunLifecycle(opts) {
|
|
174
190
|
const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
|
|
@@ -189,6 +205,9 @@ function createRunLifecycle(opts) {
|
|
|
189
205
|
const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
|
|
190
206
|
const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
|
|
191
207
|
warningTimer.unref?.();
|
|
208
|
+
const checkpointDelay = Math.max(250, Math.floor(timeoutMs * 0.8));
|
|
209
|
+
const checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), checkpointDelay);
|
|
210
|
+
checkpointTimer.unref?.();
|
|
192
211
|
let removeStopListener = () => { };
|
|
193
212
|
const stopPromise = new Promise((resolveStopped) => {
|
|
194
213
|
const stopped = () => resolveStopped(RUN_STOPPED);
|
|
@@ -203,6 +222,7 @@ function createRunLifecycle(opts) {
|
|
|
203
222
|
timeoutController,
|
|
204
223
|
timeoutTimer,
|
|
205
224
|
warningTimer,
|
|
225
|
+
checkpointTimer,
|
|
206
226
|
stopPromise,
|
|
207
227
|
removeStopListener,
|
|
208
228
|
startedAt,
|
|
@@ -211,6 +231,8 @@ function createRunLifecycle(opts) {
|
|
|
211
231
|
rounds: 0,
|
|
212
232
|
timedOut: false,
|
|
213
233
|
warned: false,
|
|
234
|
+
checkpointDue: false,
|
|
235
|
+
checkpointInjected: false,
|
|
214
236
|
limitAnnounced: false,
|
|
215
237
|
disposed: false,
|
|
216
238
|
failedCalls: new Map(),
|
|
@@ -221,12 +243,13 @@ function disposeRunLifecycle(life) {
|
|
|
221
243
|
life.disposed = true;
|
|
222
244
|
clearTimeout(life.timeoutTimer);
|
|
223
245
|
clearTimeout(life.warningTimer);
|
|
246
|
+
clearTimeout(life.checkpointTimer);
|
|
224
247
|
life.removeStopListener();
|
|
225
248
|
}
|
|
226
249
|
function hardStop(opts, life, kind, detail) {
|
|
227
250
|
const elapsedMs = Date.now() - life.startedAt;
|
|
228
251
|
const message = kind === "deadline"
|
|
229
|
-
?
|
|
252
|
+
? `⏸ agent run paused: total deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
|
|
230
253
|
: kind === "max_rounds"
|
|
231
254
|
? `⛔ agent run stopped: ${life.maxRounds}-round safety limit reached after ${formatAgentDuration(elapsedMs)}. This usually means the model is looping. Increase it with \`hara config set maxAgentRounds <n>\` (maximum 256) only if the extra rounds are intentional.`
|
|
232
255
|
: `⛔ agent run stopped: the same failing ${detail?.tool ?? "tool"} call repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
|
|
@@ -323,6 +346,15 @@ async function runAgentInner(history, opts, life) {
|
|
|
323
346
|
life.rounds += 1;
|
|
324
347
|
if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
|
|
325
348
|
warnRun(opts, life);
|
|
349
|
+
if (Date.now() - life.startedAt >= Math.floor(life.timeoutMs * 0.8))
|
|
350
|
+
requestRunCheckpoint(opts, life);
|
|
351
|
+
if (!opts.quiet && life.checkpointDue && !life.checkpointInjected) {
|
|
352
|
+
life.checkpointInjected = true;
|
|
353
|
+
history.push({
|
|
354
|
+
role: "user",
|
|
355
|
+
content: wrapReminders([deadlineCheckpointReminder(life.timeoutMs)]),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
326
358
|
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
327
359
|
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
328
360
|
if (opts.pendingInput && !runSignal.aborted) {
|
package/dist/checkpoints.js
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, join } from "node:path";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { findProjectRoot } from "./context/agents-md.js";
|
|
13
|
-
import {
|
|
13
|
+
import { isUnsafeProjectWorkspace } from "./context/workspace-scope.js";
|
|
14
14
|
import { toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
15
15
|
import { sensitiveFileReason } from "./security/sensitive-files.js";
|
|
16
16
|
import { redactSensitiveText } from "./security/secrets.js";
|
|
@@ -107,7 +107,7 @@ function dropSensitiveIndexEntries(root, gitDir) {
|
|
|
107
107
|
export function checkpoint(cwd, label) {
|
|
108
108
|
// A shadow `git add -A` at ~/ would traverse the user's entire personal/control-data scope. Home is
|
|
109
109
|
// deliberately not an implicit project, including when reached through a symlink alias.
|
|
110
|
-
if (
|
|
110
|
+
if (isUnsafeProjectWorkspace(cwd))
|
|
111
111
|
return null;
|
|
112
112
|
const root = findProjectRoot(cwd);
|
|
113
113
|
const gitDir = shadowGitDir(root);
|
|
@@ -138,7 +138,7 @@ export function checkpoint(cwd, label) {
|
|
|
138
138
|
}
|
|
139
139
|
/** Recent checkpoints, newest first. */
|
|
140
140
|
export function listCheckpoints(cwd, n = 15) {
|
|
141
|
-
if (
|
|
141
|
+
if (isUnsafeProjectWorkspace(cwd))
|
|
142
142
|
return [];
|
|
143
143
|
const root = findProjectRoot(cwd);
|
|
144
144
|
const gitDir = shadowGitDir(root);
|
|
@@ -163,7 +163,7 @@ export function listCheckpoints(cwd, n = 15) {
|
|
|
163
163
|
export function restoreCheckpoint(cwd, ref) {
|
|
164
164
|
if (!/^[0-9a-f]{4,64}$/i.test(ref))
|
|
165
165
|
return null; // checkpoint refs are hashes; reject option/ref injection
|
|
166
|
-
if (
|
|
166
|
+
if (isUnsafeProjectWorkspace(cwd))
|
|
167
167
|
return null;
|
|
168
168
|
const root = findProjectRoot(cwd);
|
|
169
169
|
const gitDir = shadowGitDir(root);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
2
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { effectiveHomeDir } from "../runtime.js";
|
|
4
4
|
/** Resolve existing paths through symlinks before comparing security/workspace scopes. Falling back to
|
|
5
5
|
* resolve() keeps messages deterministic for a path that disappears during startup; normal cwd/home paths
|
|
6
6
|
* exist and therefore take the realpath branch. */
|
|
@@ -14,21 +14,25 @@ export function canonicalWorkspacePath(path) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
/** The user's home directory is a control/personal-data scope, not an implicit project workspace. */
|
|
17
|
-
export function isHomeWorkspace(cwd, home =
|
|
17
|
+
export function isHomeWorkspace(cwd, home = effectiveHomeDir()) {
|
|
18
18
|
return canonicalWorkspacePath(cwd) === canonicalWorkspacePath(home);
|
|
19
19
|
}
|
|
20
20
|
/** A recursive root is unsafe when it is Home itself OR an ancestor that would descend into Home. This
|
|
21
21
|
* closes `path: ".."`, filesystem-root, and symlink-alias bypasses while keeping an explicitly selected
|
|
22
22
|
* project child under Home usable. */
|
|
23
|
-
export function recursiveRootContainsHome(root, home =
|
|
23
|
+
export function recursiveRootContainsHome(root, home = effectiveHomeDir()) {
|
|
24
24
|
const canonicalRoot = canonicalWorkspacePath(root);
|
|
25
25
|
const canonicalHome = canonicalWorkspacePath(home);
|
|
26
26
|
const rel = relative(canonicalRoot, canonicalHome);
|
|
27
27
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
28
28
|
}
|
|
29
|
+
/** Project-scoped execution from Home OR one of its ancestors can implicitly reach private user state. */
|
|
30
|
+
export function isUnsafeProjectWorkspace(cwd, home = effectiveHomeDir()) {
|
|
31
|
+
return recursiveRootContainsHome(cwd, home);
|
|
32
|
+
}
|
|
29
33
|
export function homeWorkspaceActionError(action) {
|
|
30
|
-
return (`Refusing to ${action} from the home directory: it is not an implicit project workspace. ` +
|
|
31
|
-
"
|
|
34
|
+
return (`Refusing to ${action} from a workspace that is the home directory or contains it: it is not an implicit project workspace. ` +
|
|
35
|
+
"Use `/cd /path/to/project`, run `cd /path/to/project` first, or launch with `hara --cwd /path/to/project`.");
|
|
32
36
|
}
|
|
33
37
|
export function recursiveHomeSearchError(tool) {
|
|
34
38
|
return (`Error: ${tool} will not recursively scan the home directory. ` +
|
|
@@ -43,11 +47,44 @@ export function homeWorkspaceDirectoryScanError(tool) {
|
|
|
43
47
|
/** Injected into model context when Hara was intentionally launched at ~/. Runtime checks enforce the same
|
|
44
48
|
* policy, but guidance avoids wasting turns on calls that are guaranteed to be rejected. */
|
|
45
49
|
export function homeWorkspaceGuidance(cwd) {
|
|
46
|
-
if (!
|
|
50
|
+
if (!isUnsafeProjectWorkspace(cwd))
|
|
47
51
|
return "";
|
|
48
52
|
return ("# Home-directory workspace boundary\n" +
|
|
49
|
-
"The working directory resolves to the user's home directory, which is not an implicit project. " +
|
|
53
|
+
"The working directory resolves to the user's home directory or an ancestor that contains it, which is not an implicit project. " +
|
|
50
54
|
"Do not initialize a project, create or modify files, build a repository index, run shell/external " +
|
|
51
55
|
"executable tools, enumerate directories, or grep/glob/search Home or one of its child directories. " +
|
|
52
|
-
"Ask the user to
|
|
56
|
+
"Ask the user to switch with `/cd /path/to/project`, run `cd /path/to/project`, or launch with " +
|
|
57
|
+
"`hara --cwd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
|
|
58
|
+
}
|
|
59
|
+
/** Resolve an explicit interactive workspace handoff without accepting Home/ancestor scopes. */
|
|
60
|
+
export function resolveWorkspaceSwitch(input, currentCwd, home = effectiveHomeDir()) {
|
|
61
|
+
let requested = input.trim();
|
|
62
|
+
if (!requested)
|
|
63
|
+
return { ok: false, error: "usage: /cd <project-directory>" };
|
|
64
|
+
if (requested.length >= 2
|
|
65
|
+
&& ((requested.startsWith('"') && requested.endsWith('"')) || (requested.startsWith("'") && requested.endsWith("'"))))
|
|
66
|
+
requested = requested.slice(1, -1).trim();
|
|
67
|
+
if (!requested)
|
|
68
|
+
return { ok: false, error: "usage: /cd <project-directory>" };
|
|
69
|
+
if (requested === "~")
|
|
70
|
+
requested = home;
|
|
71
|
+
else if (/^~[\\/]/u.test(requested)) {
|
|
72
|
+
const parts = requested.slice(2).split(/[\\/]+/u).filter(Boolean);
|
|
73
|
+
requested = resolve(home, ...parts);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const target = realpathSync.native(resolve(currentCwd, requested));
|
|
77
|
+
if (!statSync(target).isDirectory())
|
|
78
|
+
return { ok: false, error: `not a directory: ${requested}` };
|
|
79
|
+
if (isUnsafeProjectWorkspace(target, home)) {
|
|
80
|
+
return { ok: false, error: homeWorkspaceActionError("switch project scope") };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, cwd: target };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: `cannot switch to '${requested}': ${error instanceof Error ? error.message : String(error)}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
53
90
|
}
|
package/dist/index.js
CHANGED
|
@@ -70,13 +70,14 @@ function renderBgJobs() {
|
|
|
70
70
|
}
|
|
71
71
|
import { qwenDeviceLogin, getValidQwenAuth, loadQwenToken } from "./providers/qwen-oauth.js";
|
|
72
72
|
import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
73
|
-
import { homeWorkspaceActionError,
|
|
73
|
+
import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, } from "./context/workspace-scope.js";
|
|
74
74
|
import { getEmbedder } from "./search/embed.js";
|
|
75
75
|
import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
76
76
|
import { searchHybrid } from "./search/hybrid.js";
|
|
77
77
|
import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
78
78
|
import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
79
79
|
import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
|
|
80
|
+
import { displaySessionCwd, resolveSessionResumeTarget } from "./session/resume.js";
|
|
80
81
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
81
82
|
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
82
83
|
import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
|
|
@@ -392,7 +393,7 @@ function agentRunLimits(cfg) {
|
|
|
392
393
|
return { timeoutMs: cfg.runTimeoutMs, maxRounds: cfg.maxAgentRounds };
|
|
393
394
|
}
|
|
394
395
|
async function runInit(provider, cwd, sandbox = "off", cfg) {
|
|
395
|
-
if (
|
|
396
|
+
if (isUnsafeProjectWorkspace(cwd))
|
|
396
397
|
throw new Error(homeWorkspaceActionError("initialize AGENTS.md"));
|
|
397
398
|
const history = [{ role: "user", content: INIT_PROMPT }];
|
|
398
399
|
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true, ...(cfg ? agentRunLimits(cfg) : {}) });
|
|
@@ -1139,7 +1140,7 @@ program
|
|
|
1139
1140
|
.description("analyze the project and (re)generate AGENTS.md")
|
|
1140
1141
|
.action(async () => {
|
|
1141
1142
|
const cfg = loadConfig();
|
|
1142
|
-
if (
|
|
1143
|
+
if (isUnsafeProjectWorkspace(cfg.cwd)) {
|
|
1143
1144
|
out(c.red(homeWorkspaceActionError("initialize AGENTS.md")) + "\n");
|
|
1144
1145
|
process.exitCode = 2;
|
|
1145
1146
|
return;
|
|
@@ -1163,34 +1164,40 @@ program
|
|
|
1163
1164
|
}
|
|
1164
1165
|
for (const m of metas) {
|
|
1165
1166
|
out(`${c.bold(shortId(m.id))} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${c.dim(m.provider + ":" + m.model)} ${m.title || c.dim("(untitled)")}\n`);
|
|
1167
|
+
out(` ${c.dim(displaySessionCwd(m.cwd))}\n`);
|
|
1166
1168
|
}
|
|
1167
1169
|
out(c.dim("\nResume: hara resume <id>\n"));
|
|
1168
1170
|
});
|
|
1169
1171
|
program
|
|
1170
1172
|
.command("resume [id]")
|
|
1171
|
-
.description("resume a session — no id resumes the most recent here
|
|
1173
|
+
.description("resume a session in its saved project — no id resumes the most recent here")
|
|
1172
1174
|
.action(async (id) => {
|
|
1173
|
-
|
|
1174
|
-
if (
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
process.exit(1);
|
|
1175
|
+
const target = resolveSessionResumeTarget(id, process.cwd());
|
|
1176
|
+
if (!target.ok) {
|
|
1177
|
+
if (target.reason === "not-found") {
|
|
1178
|
+
out(c.red(`No session matching '${id ?? ""}'.`) + c.dim(" Run `hara sessions` to list.\n"));
|
|
1179
|
+
process.exitCode = 1;
|
|
1179
1180
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
if (
|
|
1184
|
-
out(c.
|
|
1185
|
-
process.
|
|
1181
|
+
else if (target.reason === "no-current") {
|
|
1182
|
+
out(c.dim("No sessions for this directory yet — `hara sessions` lists all projects.\n"));
|
|
1183
|
+
}
|
|
1184
|
+
else if (target.reason === "unreadable") {
|
|
1185
|
+
out(c.red(`Session ${shortId(target.id ?? id ?? "")} exists but is unreadable or corrupt; refusing to resume it.\n`));
|
|
1186
|
+
process.exitCode = 2;
|
|
1186
1187
|
}
|
|
1187
|
-
|
|
1188
|
+
else {
|
|
1189
|
+
out(c.red(`Session ${shortId(target.id ?? id ?? "")} belongs to ${target.cwd}, but that project directory is unavailable.\n`));
|
|
1190
|
+
process.exitCode = 2;
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1188
1193
|
}
|
|
1189
|
-
out(c.dim(`↩ resuming ${shortId(
|
|
1194
|
+
out(c.dim(`↩ resuming ${shortId(target.id)} in ${displaySessionCwd(target.cwd)}…\n`));
|
|
1190
1195
|
// Reuse the existing --resume path exactly (one engine), inheriting this terminal. selfArgv() inside
|
|
1191
1196
|
// runSelfAttached distinguishes node+entry from a Bun-compiled binary (whose argv[1] is a user arg).
|
|
1197
|
+
// Launch from the persisted project root: the low-level --resume engine intentionally refuses a
|
|
1198
|
+
// foreign cwd so a transcript can never be reinterpreted against the wrong repository.
|
|
1192
1199
|
try {
|
|
1193
|
-
const result = await runSelfAttached(["--resume",
|
|
1200
|
+
const result = await runSelfAttached(["--resume", target.id], target.cwd);
|
|
1194
1201
|
if (result.signal) {
|
|
1195
1202
|
out(c.yellow(`Resumed Hara process stopped by ${result.signal}.\n`));
|
|
1196
1203
|
process.exitCode = 1;
|
|
@@ -1374,7 +1381,7 @@ program
|
|
|
1374
1381
|
const cwd = process.cwd();
|
|
1375
1382
|
let doRepo = !!(opts.all || opts.repo || (!opts.assets && !opts.all));
|
|
1376
1383
|
const doAssets = !!(opts.all || opts.assets);
|
|
1377
|
-
if (doRepo &&
|
|
1384
|
+
if (doRepo && isUnsafeProjectWorkspace(cwd)) {
|
|
1378
1385
|
if (!doAssets) {
|
|
1379
1386
|
out(c.red(homeWorkspaceActionError("build a repository index")) + "\n");
|
|
1380
1387
|
process.exitCode = 2;
|
|
@@ -2782,7 +2789,7 @@ program.action(async (opts) => {
|
|
|
2782
2789
|
}
|
|
2783
2790
|
const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
|
|
2784
2791
|
const cwd = cfg.cwd;
|
|
2785
|
-
const homeWorkspace =
|
|
2792
|
+
const homeWorkspace = isUnsafeProjectWorkspace(cwd);
|
|
2786
2793
|
// Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
|
|
2787
2794
|
// stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
|
|
2788
2795
|
// object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
|
|
@@ -3248,7 +3255,7 @@ program.action(async (opts) => {
|
|
|
3248
3255
|
const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
|
|
3249
3256
|
out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
|
|
3250
3257
|
if (homeWorkspace) {
|
|
3251
|
-
out(c.yellow("⚠ Home directory is not treated as a project workspace.
|
|
3258
|
+
out(c.yellow("⚠ Home or a directory containing it is not treated as a project workspace. Switch with `/cd /path/to/project`, or launch with `hara --cwd /path/to/project`; project-scoped execution is disabled here.\n"));
|
|
3252
3259
|
}
|
|
3253
3260
|
// Startup update notice — cache-driven (a previous session's background probe), so it costs zero
|
|
3254
3261
|
// latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
|
|
@@ -3417,6 +3424,51 @@ program.action(async (opts) => {
|
|
|
3417
3424
|
}
|
|
3418
3425
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
3419
3426
|
const persistSession = () => saveSession(meta, history, task);
|
|
3427
|
+
let requestedWorkspaceSwitch = null;
|
|
3428
|
+
let requestedSessionSwitch = null;
|
|
3429
|
+
const relaunchRequestedTarget = async () => {
|
|
3430
|
+
if (!requestedWorkspaceSwitch && !requestedSessionSwitch)
|
|
3431
|
+
return;
|
|
3432
|
+
const sessionSwitch = requestedSessionSwitch;
|
|
3433
|
+
const target = sessionSwitch?.cwd ?? requestedWorkspaceSwitch;
|
|
3434
|
+
requestedWorkspaceSwitch = null;
|
|
3435
|
+
requestedSessionSwitch = null;
|
|
3436
|
+
// The foreground child starts a new session. Do not keep the old session artificially locked for the
|
|
3437
|
+
// entire lifetime of the new workspace merely because this small parent process is waiting on it.
|
|
3438
|
+
releaseSessionLock(sessionId);
|
|
3439
|
+
const args = [];
|
|
3440
|
+
if (opts.profile)
|
|
3441
|
+
args.push("--profile", String(opts.profile));
|
|
3442
|
+
if (opts.overlay)
|
|
3443
|
+
args.push("--overlay", String(opts.overlay));
|
|
3444
|
+
if (opts.model)
|
|
3445
|
+
args.push("--model", String(opts.model));
|
|
3446
|
+
if (opts.yes)
|
|
3447
|
+
args.push("--yes");
|
|
3448
|
+
else if (opts.approval)
|
|
3449
|
+
args.push("--approval", String(opts.approval));
|
|
3450
|
+
if (opts.sandbox)
|
|
3451
|
+
args.push("--sandbox", String(opts.sandbox));
|
|
3452
|
+
if (sessionSwitch)
|
|
3453
|
+
args.push("--resume", sessionSwitch.id);
|
|
3454
|
+
out(c.dim(sessionSwitch
|
|
3455
|
+
? `Resuming session ${shortId(sessionSwitch.id)} → ${displaySessionCwd(target)}\n`
|
|
3456
|
+
: `Switching workspace → ${target}\n`));
|
|
3457
|
+
try {
|
|
3458
|
+
const result = await runSelfAttached(args, target);
|
|
3459
|
+
if (result.signal) {
|
|
3460
|
+
out(c.yellow(`Hara in ${target} stopped by ${result.signal}.\n`));
|
|
3461
|
+
process.exitCode = 1;
|
|
3462
|
+
}
|
|
3463
|
+
else if (result.code) {
|
|
3464
|
+
process.exitCode = result.code;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
catch (error) {
|
|
3468
|
+
out(c.red(`Could not start Hara in ${target}: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
3469
|
+
process.exitCode = 1;
|
|
3470
|
+
}
|
|
3471
|
+
};
|
|
3420
3472
|
const keepUnfinishedTaskActive = () => {
|
|
3421
3473
|
resumeTaskPending = Boolean(task && task.status !== "completed");
|
|
3422
3474
|
};
|
|
@@ -3460,14 +3512,51 @@ program.action(async (opts) => {
|
|
|
3460
3512
|
};
|
|
3461
3513
|
const commands = [
|
|
3462
3514
|
{ name: "help", desc: "show this help", run: () => void out(helpText(commands)) },
|
|
3515
|
+
{
|
|
3516
|
+
name: "cd",
|
|
3517
|
+
desc: "switch to a project workspace: /cd <directory>",
|
|
3518
|
+
run: (a) => {
|
|
3519
|
+
const result = resolveWorkspaceSwitch(a, cwd);
|
|
3520
|
+
if (!result.ok)
|
|
3521
|
+
return void out(c.red(`(${result.error})\n`));
|
|
3522
|
+
if (result.cwd === realpathSync.native(cwd))
|
|
3523
|
+
return void out(c.dim(`(already in ${result.cwd})\n`));
|
|
3524
|
+
requestedWorkspaceSwitch = result.cwd;
|
|
3525
|
+
return "exit";
|
|
3526
|
+
},
|
|
3527
|
+
},
|
|
3463
3528
|
{
|
|
3464
3529
|
name: "continue",
|
|
3465
|
-
aliases: ["resume"],
|
|
3466
3530
|
desc: "resume the unfinished task: /continue [instruction]",
|
|
3467
3531
|
// Interactive loops translate this command into an ordinary model turn before slash dispatch. This
|
|
3468
3532
|
// fallback only protects future callers that invoke the command table directly.
|
|
3469
3533
|
run: () => void out(c.dim("(type /continue [instruction] in an interactive session)\n")),
|
|
3470
3534
|
},
|
|
3535
|
+
{
|
|
3536
|
+
name: "resume",
|
|
3537
|
+
desc: "switch to a saved session: /resume <id>",
|
|
3538
|
+
run: (a) => {
|
|
3539
|
+
if (!a.trim()) {
|
|
3540
|
+
const ms = listSessions().slice(0, 12);
|
|
3541
|
+
if (!ms.length)
|
|
3542
|
+
return void out(c.dim("No sessions yet.\n"));
|
|
3543
|
+
for (const m of ms) {
|
|
3544
|
+
out(` ${shortId(m.id)} ${c.dim(displaySessionCwd(m.cwd))} ${m.title || "(untitled)"}\n`);
|
|
3545
|
+
}
|
|
3546
|
+
return void out(c.dim("Use /resume <id> to switch sessions.\n"));
|
|
3547
|
+
}
|
|
3548
|
+
const target = resolveSessionResumeTarget(a.trim(), cwd);
|
|
3549
|
+
if (!target.ok) {
|
|
3550
|
+
if (target.reason === "cwd-unavailable")
|
|
3551
|
+
return void out(c.red(`(saved project unavailable: ${target.cwd})\n`));
|
|
3552
|
+
return void out(c.red(`(cannot resume '${a.trim()}': ${target.reason})\n`));
|
|
3553
|
+
}
|
|
3554
|
+
if (target.id === sessionId)
|
|
3555
|
+
return void out(c.dim("(this session is already open)\n"));
|
|
3556
|
+
requestedSessionSwitch = { id: target.id, cwd: target.cwd };
|
|
3557
|
+
return "exit";
|
|
3558
|
+
},
|
|
3559
|
+
},
|
|
3471
3560
|
{
|
|
3472
3561
|
name: "init",
|
|
3473
3562
|
desc: "analyze project & regenerate AGENTS.md",
|
|
@@ -3637,7 +3726,7 @@ program.action(async (opts) => {
|
|
|
3637
3726
|
if (!ms.length)
|
|
3638
3727
|
return void out(c.dim("No sessions yet.\n"));
|
|
3639
3728
|
for (const m of ms)
|
|
3640
|
-
out(` ${shortId(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.title || "(untitled)"}\n`);
|
|
3729
|
+
out(` ${shortId(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${c.dim(displaySessionCwd(m.cwd))} ${m.title || "(untitled)"}\n`);
|
|
3641
3730
|
},
|
|
3642
3731
|
},
|
|
3643
3732
|
{
|
|
@@ -3994,7 +4083,7 @@ program.action(async (opts) => {
|
|
|
3994
4083
|
// never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
|
|
3995
4084
|
updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
|
|
3996
4085
|
workspaceNotice: homeWorkspace
|
|
3997
|
-
? "Home is not a project workspace ·
|
|
4086
|
+
? "Home is not a project workspace · use /cd /path/to/project to switch"
|
|
3998
4087
|
: undefined,
|
|
3999
4088
|
},
|
|
4000
4089
|
visionNotice: __visionNotice,
|
|
@@ -4004,7 +4093,7 @@ program.action(async (opts) => {
|
|
|
4004
4093
|
onSubmit: async (line, h, images, interaction) => {
|
|
4005
4094
|
// `/continue` is a task interaction, not a control-only command: turn it into a model message before
|
|
4006
4095
|
// slash dispatch, preserving the submitted turn id while explicitly targeting the unfinished task.
|
|
4007
|
-
const continueCommand = /^\/
|
|
4096
|
+
const continueCommand = /^\/continue(?:\s+([\s\S]+))?$/i.exec(line.trim());
|
|
4008
4097
|
if (continueCommand) {
|
|
4009
4098
|
if (!task || task.status === "completed")
|
|
4010
4099
|
return void h.sink.notice("(there is no unfinished task to continue)");
|
|
@@ -4020,6 +4109,35 @@ program.action(async (opts) => {
|
|
|
4020
4109
|
if (isSlashCommand(line)) {
|
|
4021
4110
|
const [nm, ...rest] = line.slice(1).split(/\s+/);
|
|
4022
4111
|
const arg = rest.join(" ").trim();
|
|
4112
|
+
if (nm === "cd") {
|
|
4113
|
+
const result = resolveWorkspaceSwitch(arg, cwd);
|
|
4114
|
+
if (!result.ok)
|
|
4115
|
+
return void h.sink.notice(`(${result.error})`);
|
|
4116
|
+
if (result.cwd === realpathSync.native(cwd))
|
|
4117
|
+
return void h.sink.notice(`(already in ${result.cwd})`);
|
|
4118
|
+
requestedWorkspaceSwitch = result.cwd;
|
|
4119
|
+
h.sink.notice(`(switching workspace → ${result.cwd})`);
|
|
4120
|
+
return void h.exit();
|
|
4121
|
+
}
|
|
4122
|
+
if (nm === "resume") {
|
|
4123
|
+
if (!arg) {
|
|
4124
|
+
const ms = listSessions().slice(0, 12);
|
|
4125
|
+
return void h.sink.notice(ms.length
|
|
4126
|
+
? "Saved sessions — /resume <id>:\n" + ms.map((m) => ` ${shortId(m.id)} ${displaySessionCwd(m.cwd)} ${m.title || "(untitled)"}`).join("\n")
|
|
4127
|
+
: "No sessions yet.");
|
|
4128
|
+
}
|
|
4129
|
+
const target = resolveSessionResumeTarget(arg, cwd);
|
|
4130
|
+
if (!target.ok) {
|
|
4131
|
+
if (target.reason === "cwd-unavailable")
|
|
4132
|
+
return void h.sink.notice(`(saved project unavailable: ${target.cwd})`);
|
|
4133
|
+
return void h.sink.notice(`(cannot resume '${arg}': ${target.reason})`);
|
|
4134
|
+
}
|
|
4135
|
+
if (target.id === sessionId)
|
|
4136
|
+
return void h.sink.notice("(this session is already open)");
|
|
4137
|
+
requestedSessionSwitch = { id: target.id, cwd: target.cwd };
|
|
4138
|
+
h.sink.notice(`(resuming ${shortId(target.id)} → ${displaySessionCwd(target.cwd)})`);
|
|
4139
|
+
return void h.exit();
|
|
4140
|
+
}
|
|
4023
4141
|
if (nm === "exit" || nm === "quit") {
|
|
4024
4142
|
if (shouldAutoEvolve(cfg.evolve, history.length)) {
|
|
4025
4143
|
h.sink.notice("✻ distilling session learnings…");
|
|
@@ -4222,7 +4340,7 @@ program.action(async (opts) => {
|
|
|
4222
4340
|
}
|
|
4223
4341
|
if (nm === "sessions") {
|
|
4224
4342
|
const ms = listSessions();
|
|
4225
|
-
return void h.sink.notice(ms.length ? ms.slice(0, 12).map((m) => ` ${shortId(m.id)} ${m.updatedAt.slice(0, 16).replace("T", " ")} ${m.title || "(untitled)"}`).join("\n") : "No sessions yet.");
|
|
4343
|
+
return void h.sink.notice(ms.length ? ms.slice(0, 12).map((m) => ` ${shortId(m.id)} ${m.updatedAt.slice(0, 16).replace("T", " ")} ${displaySessionCwd(m.cwd)} ${m.title || "(untitled)"}`).join("\n") : "No sessions yet.");
|
|
4226
4344
|
}
|
|
4227
4345
|
if (nm === "usage")
|
|
4228
4346
|
return void h.sink.notice(`tokens — ↑${stats.input} ↓${stats.output}`);
|
|
@@ -4598,7 +4716,8 @@ program.action(async (opts) => {
|
|
|
4598
4716
|
if (loadSession(meta.id))
|
|
4599
4717
|
out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
|
|
4600
4718
|
await closeMcp();
|
|
4601
|
-
|
|
4719
|
+
await relaunchRequestedTarget();
|
|
4720
|
+
process.exit(process.exitCode ?? 0); // TUI done — exit cleanly (ink can leave stdin referenced)
|
|
4602
4721
|
}
|
|
4603
4722
|
out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${hasAgentsMd(cwd) ? " (AGENTS.md loaded)" : ""}\n\n`));
|
|
4604
4723
|
bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output, profileId: __activeP.id, profileKind: __activeP.kind });
|
|
@@ -4623,7 +4742,7 @@ program.action(async (opts) => {
|
|
|
4623
4742
|
if (!line)
|
|
4624
4743
|
continue;
|
|
4625
4744
|
let forcedContinuation;
|
|
4626
|
-
const continueCommand = /^\/
|
|
4745
|
+
const continueCommand = /^\/continue(?:\s+([\s\S]+))?$/i.exec(line);
|
|
4627
4746
|
if (continueCommand) {
|
|
4628
4747
|
if (!task || task.status === "completed") {
|
|
4629
4748
|
out(c.dim("(there is no unfinished task to continue)\n"));
|
|
@@ -4766,9 +4885,11 @@ program.action(async (opts) => {
|
|
|
4766
4885
|
}
|
|
4767
4886
|
}
|
|
4768
4887
|
bar.uninstall();
|
|
4769
|
-
|
|
4888
|
+
if (loadSession(meta.id))
|
|
4889
|
+
out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
|
|
4770
4890
|
rl.close();
|
|
4771
4891
|
await closeMcp();
|
|
4892
|
+
await relaunchRequestedTarget();
|
|
4772
4893
|
});
|
|
4773
4894
|
program.parseAsync().catch((e) => {
|
|
4774
4895
|
try {
|
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
export const MIN_NODE_MAJOR = 22;
|
|
2
3
|
// Commander 15 (a direct runtime dependency) requires this exact floor. Keep package engines, startup,
|
|
3
4
|
// doctor, and docs aligned so an early Node 22 release gets an upgrade hint before dependencies load.
|
|
@@ -26,6 +27,18 @@ export function normalizePortableWindowsHome(value) {
|
|
|
26
27
|
return home[0].toUpperCase() + home.slice(1).replace(/\//g, "\\");
|
|
27
28
|
return home;
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the same user home even when Hara modules are embedded directly instead of entered through
|
|
32
|
+
* `cli.ts`/`runtime-bootstrap.cjs`. Native Windows Node ignores HOME in `os.homedir()`, while Git Bash and
|
|
33
|
+
* portable launchers intentionally use it. Keeping this resolver side-effect free lets security/workspace
|
|
34
|
+
* checks honor that explicit boundary without requiring callers to mutate their environment first.
|
|
35
|
+
*/
|
|
36
|
+
export function effectiveHomeDir(env = process.env, runtimePlatform = process.platform, systemHome = homedir()) {
|
|
37
|
+
const explicit = String(env.HOME ?? "").trim();
|
|
38
|
+
if (!explicit)
|
|
39
|
+
return systemHome;
|
|
40
|
+
return runtimePlatform === "win32" ? normalizePortableWindowsHome(explicit) : explicit;
|
|
41
|
+
}
|
|
29
42
|
function supportedNodeVersion(version) {
|
|
30
43
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
31
44
|
if (!match)
|
package/dist/sandbox.js
CHANGED
|
@@ -13,7 +13,7 @@ import { platform } from "node:os";
|
|
|
13
13
|
import { win32 as winPath } from "node:path";
|
|
14
14
|
import { existingSensitiveSeatbeltMasks, sensitiveFilesAllowed, sensitiveShellCommandReason, } from "./security/sensitive-files.js";
|
|
15
15
|
import { terminateSubprocessTree, toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
16
|
-
import { homeWorkspaceActionError,
|
|
16
|
+
import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "./context/workspace-scope.js";
|
|
17
17
|
// Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
|
|
18
18
|
// `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
|
|
19
19
|
// have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
|
|
@@ -126,7 +126,7 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
|
|
|
126
126
|
export function shellCommand(command, cwd, mode) {
|
|
127
127
|
// Shell syntax can recursively traverse arbitrary paths, so unlike explicit read/write tools it is not
|
|
128
128
|
// safe at the Home root. Check before protected-file mask discovery, which itself would have to walk ~/.
|
|
129
|
-
if (
|
|
129
|
+
if (isUnsafeProjectWorkspace(cwd))
|
|
130
130
|
throw new Error(homeWorkspaceActionError("run shell commands"));
|
|
131
131
|
const protectedReason = sensitiveShellCommandReason(command, cwd);
|
|
132
132
|
if (protectedReason) {
|
package/dist/search/semindex.js
CHANGED
|
@@ -11,7 +11,7 @@ import { listProjectFiles, listProjectFilesAsync, walkFiles, walkFilesAsync, } f
|
|
|
11
11
|
import { zvecBuild, zvecQueryIds, zvecRemove } from "./zvec-store.js";
|
|
12
12
|
import { isSensitiveFilePath } from "../security/sensitive-files.js";
|
|
13
13
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
14
|
-
import { homeWorkspaceActionError,
|
|
14
|
+
import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
|
|
15
15
|
import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, removePrivateStateFile, } from "../security/private-state.js";
|
|
16
16
|
import { atomicWriteText, bindHaraPrivateStateWritePath, } from "../fs-write.js";
|
|
17
17
|
// Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
|
|
@@ -52,7 +52,7 @@ async function ensurePrivateIndexState(name, cwd) {
|
|
|
52
52
|
}
|
|
53
53
|
function existingPrivateIndexPath(name, cwd) {
|
|
54
54
|
try {
|
|
55
|
-
if (name === "repo" &&
|
|
55
|
+
if (name === "repo" && isUnsafeProjectWorkspace(cwd))
|
|
56
56
|
return null;
|
|
57
57
|
const requested = indexPath(name, cwd);
|
|
58
58
|
const base = realpathSync.native(indexBase(name, cwd));
|
|
@@ -132,7 +132,7 @@ async function rotateLegacyIndex(name, cwd, state) {
|
|
|
132
132
|
export async function buildIndex(name, chunks, embed, cwd, model = "embed", signal) {
|
|
133
133
|
if (signal?.aborted)
|
|
134
134
|
throw new Error("semantic index build interrupted");
|
|
135
|
-
if (name === "repo" &&
|
|
135
|
+
if (name === "repo" && isUnsafeProjectWorkspace(cwd))
|
|
136
136
|
throw new Error(homeWorkspaceActionError("build a repository index"));
|
|
137
137
|
const state = await ensurePrivateIndexState(name, cwd);
|
|
138
138
|
const p = state.path;
|
|
@@ -254,7 +254,7 @@ export function collectDirChunks(dir, source) {
|
|
|
254
254
|
}
|
|
255
255
|
/** Walk the repo (respecting .gitignore) and chunk every code/text file — the corpus `hara index` embeds. */
|
|
256
256
|
export function collectRepoChunks(root) {
|
|
257
|
-
if (
|
|
257
|
+
if (isUnsafeProjectWorkspace(root))
|
|
258
258
|
throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
|
|
259
259
|
const chunks = [];
|
|
260
260
|
for (const rel of listProjectFiles(root)) {
|
|
@@ -315,7 +315,7 @@ export async function collectDirChunksAsync(dir, source, options = {}) {
|
|
|
315
315
|
}
|
|
316
316
|
/** Interruptible repository collection. Git discovery and file reads share one total wall budget. */
|
|
317
317
|
export async function collectRepoChunksAsync(root, options = {}) {
|
|
318
|
-
if (
|
|
318
|
+
if (isUnsafeProjectWorkspace(root))
|
|
319
319
|
throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
|
|
320
320
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 30_000;
|
|
321
321
|
const startedAt = Date.now();
|
|
@@ -15,7 +15,7 @@ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdir
|
|
|
15
15
|
import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
|
|
16
16
|
import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
|
|
17
17
|
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
18
|
-
import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
|
|
18
|
+
import { homeWorkspaceActionError, isHomeWorkspace, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
|
|
19
19
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
20
20
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
21
21
|
const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
|
|
@@ -436,7 +436,7 @@ function scaffoldProjectPermissions(cwd) {
|
|
|
436
436
|
const project = realpathSync.native(resolve(cwd));
|
|
437
437
|
// Project scaffolding at ~/ would target the same ~/.hara/permissions.json used by global policy and
|
|
438
438
|
// silently promote repository starter rules to every Hara session. Reject before creating `.hara`.
|
|
439
|
-
if (
|
|
439
|
+
if (isUnsafeProjectWorkspace(project))
|
|
440
440
|
throw new Error(homeWorkspaceActionError("create project permissions"));
|
|
441
441
|
const projectIdentity = verifiedDirectory(project);
|
|
442
442
|
const parent = join(project, ".hara");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
3
|
+
import { canonicalProjectPath } from "../org/projects.js";
|
|
4
|
+
import { latestForCwd, loadSession, resolveSessionId, sessionFileExists, } from "./store.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the execution root before relaunching an interactive session.
|
|
7
|
+
*
|
|
8
|
+
* The low-level `--resume` path still rejects a foreign cwd. This resolver is for the explicit,
|
|
9
|
+
* user-facing `hara resume <id>` transition: it binds the attached child to the session's persisted
|
|
10
|
+
* project root so invoking the command from another directory cannot reinterpret the transcript there.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveSessionResumeTarget(idOrPrefix, currentCwd) {
|
|
13
|
+
let id;
|
|
14
|
+
if (idOrPrefix) {
|
|
15
|
+
id = resolveSessionId(idOrPrefix) ?? undefined;
|
|
16
|
+
if (!id)
|
|
17
|
+
return { ok: false, reason: "not-found" };
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const latest = latestForCwd(currentCwd);
|
|
21
|
+
if (!latest)
|
|
22
|
+
return { ok: false, reason: "no-current" };
|
|
23
|
+
id = latest.meta.id;
|
|
24
|
+
}
|
|
25
|
+
const session = loadSession(id);
|
|
26
|
+
if (!session) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
reason: sessionFileExists(id) ? "unreadable" : "not-found",
|
|
30
|
+
id,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const cwd = canonicalProjectPath(session.meta.cwd, true);
|
|
34
|
+
if (!cwd) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: "cwd-unavailable",
|
|
38
|
+
id,
|
|
39
|
+
cwd: session.meta.cwd,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, id, cwd, meta: session.meta };
|
|
43
|
+
}
|
|
44
|
+
/** Compact a persisted absolute project path for session lists without losing its identity. */
|
|
45
|
+
export function displaySessionCwd(cwd, home = homedir()) {
|
|
46
|
+
const rel = relative(home, cwd);
|
|
47
|
+
if (rel === "")
|
|
48
|
+
return "~";
|
|
49
|
+
if (rel && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
|
|
50
|
+
return `~${sep}${rel}`;
|
|
51
|
+
return cwd;
|
|
52
|
+
}
|
package/dist/session/store.js
CHANGED
|
@@ -4,7 +4,15 @@ import { join } from "node:path";
|
|
|
4
4
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { redactSensitiveValue } from "../security/secrets.js";
|
|
7
|
+
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
7
8
|
import { isTaskExecution } from "./task.js";
|
|
9
|
+
/** Durable transcripts are local input on resume. Bound both allocation and post-parse traversal so a
|
|
10
|
+
* corrupt/hostile session cannot turn startup or `hara sessions` into an unbounded resource operation. */
|
|
11
|
+
export const MAX_SESSION_FILE_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
export const MAX_SESSION_JSON_DEPTH = 64;
|
|
13
|
+
export const MAX_SESSION_JSON_NODES = 250_000;
|
|
14
|
+
export const MAX_SESSION_ARRAY_ITEMS = 50_000;
|
|
15
|
+
export const MAX_SESSION_STRING_CHARS = 8 * 1024 * 1024;
|
|
8
16
|
/** Derive the session source from the spawn environment — the gateway subprocess runs with
|
|
9
17
|
* HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
|
|
10
18
|
export function sessionSourceFromEnv() {
|
|
@@ -382,13 +390,21 @@ function redactedSessionCopy(data) {
|
|
|
382
390
|
export function saveSession(meta, history, task) {
|
|
383
391
|
checkedSessionId(meta.id);
|
|
384
392
|
meta.updatedAt = new Date().toISOString();
|
|
385
|
-
const
|
|
393
|
+
const data = { meta, history, ...(task ? { task } : {}) };
|
|
394
|
+
if (!sessionValueWithinLimits(data)) {
|
|
395
|
+
throw new Error("session exceeds Hara's safe persistence complexity limit; compact or start a new session");
|
|
396
|
+
}
|
|
397
|
+
const safe = redactedSessionCopy(data);
|
|
398
|
+
const encoded = JSON.stringify(safe, null, 2);
|
|
399
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_SESSION_FILE_BYTES) {
|
|
400
|
+
throw new Error("session exceeds Hara's 64 MiB persistence limit; compact or start a new session");
|
|
401
|
+
}
|
|
386
402
|
const target = sessionFile(meta.id);
|
|
387
403
|
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
388
404
|
let fd;
|
|
389
405
|
try {
|
|
390
406
|
fd = openSync(tmp, "wx", 0o600);
|
|
391
|
-
writeFileSync(fd,
|
|
407
|
+
writeFileSync(fd, encoded, "utf8");
|
|
392
408
|
fsyncSync(fd);
|
|
393
409
|
closeSync(fd);
|
|
394
410
|
fd = undefined;
|
|
@@ -480,6 +496,32 @@ function isSessionMeta(value) {
|
|
|
480
496
|
(meta.archived === undefined || typeof meta.archived === "boolean") &&
|
|
481
497
|
(meta.gatewayOwner === undefined || typeof meta.gatewayOwner === "string"));
|
|
482
498
|
}
|
|
499
|
+
/** Iterative on purpose: reject excessive nesting before recursive redaction/validation can exhaust stack. */
|
|
500
|
+
function sessionValueWithinLimits(value) {
|
|
501
|
+
const pending = [{ value, depth: 0 }];
|
|
502
|
+
let nodes = 0;
|
|
503
|
+
while (pending.length) {
|
|
504
|
+
const current = pending.pop();
|
|
505
|
+
nodes += 1;
|
|
506
|
+
if (nodes > MAX_SESSION_JSON_NODES || current.depth > MAX_SESSION_JSON_DEPTH)
|
|
507
|
+
return false;
|
|
508
|
+
if (typeof current.value === "string") {
|
|
509
|
+
if (current.value.length > MAX_SESSION_STRING_CHARS)
|
|
510
|
+
return false;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (!current.value || typeof current.value !== "object")
|
|
514
|
+
continue;
|
|
515
|
+
const values = Array.isArray(current.value)
|
|
516
|
+
? current.value
|
|
517
|
+
: Object.values(current.value);
|
|
518
|
+
if (values.length > MAX_SESSION_ARRAY_ITEMS)
|
|
519
|
+
return false;
|
|
520
|
+
for (const child of values)
|
|
521
|
+
pending.push({ value: child, depth: current.depth + 1 });
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
483
525
|
/** True if a parsed object has the SessionData shape we can safely use. */
|
|
484
526
|
function isSessionData(d) {
|
|
485
527
|
const o = d;
|
|
@@ -491,8 +533,13 @@ function isSessionData(d) {
|
|
|
491
533
|
* here: listing/resuming must not perform an unlocked write. The next explicit save atomically migrates it. */
|
|
492
534
|
function readSessionFile(p) {
|
|
493
535
|
try {
|
|
494
|
-
const
|
|
495
|
-
|
|
536
|
+
const raw = readVerifiedRegularFileSnapshotSync(p, MAX_SESSION_FILE_BYTES, {
|
|
537
|
+
action: "read Hara session",
|
|
538
|
+
protectSensitive: false,
|
|
539
|
+
rejectHardLinks: true,
|
|
540
|
+
}).text;
|
|
541
|
+
const d = JSON.parse(raw);
|
|
542
|
+
return sessionValueWithinLimits(d) && isSessionData(d) ? redactedSessionCopy(d) : null;
|
|
496
543
|
}
|
|
497
544
|
catch {
|
|
498
545
|
return null;
|
package/dist/session/task.js
CHANGED
|
@@ -111,7 +111,7 @@ export function requestsTaskContinuation(text) {
|
|
|
111
111
|
const value = text.trim().toLocaleLowerCase();
|
|
112
112
|
if (!value)
|
|
113
113
|
return false;
|
|
114
|
-
return /^(?:\/
|
|
114
|
+
return /^(?:\/continue(?:\s|$)|(?:continue|resume|go\s+on)(?:[\s,.:;!?,。:;!?]|$)|(?:继续|接着|接着做|继续处理)(?:[\s,.:;!?,。:;!?]|$))/.test(value);
|
|
115
115
|
}
|
|
116
116
|
export function finishTaskExecution(task, outcome, todos = [], interrupted = false, at = new Date()) {
|
|
117
117
|
if (!task)
|
|
@@ -123,9 +123,11 @@ export function finishTaskExecution(task, outcome, todos = [], interrupted = fal
|
|
|
123
123
|
? "paused"
|
|
124
124
|
: outcome?.status === "completed"
|
|
125
125
|
? (incomplete ? "paused" : "completed")
|
|
126
|
-
: outcome?.status === "
|
|
127
|
-
? "
|
|
128
|
-
: "
|
|
126
|
+
: outcome?.status === "halted" && outcome.stopReason === "deadline"
|
|
127
|
+
? "paused"
|
|
128
|
+
: outcome?.status === "error" || outcome?.status === "empty" || outcome?.status === "halted"
|
|
129
|
+
? "blocked"
|
|
130
|
+
: "paused";
|
|
129
131
|
return { ...task, status, lastOutcome, updatedAt: now, endedAt: now };
|
|
130
132
|
}
|
|
131
133
|
/** A process died while this task was running. Recovery is explicit and never claims success. */
|
package/dist/tools/cron.js
CHANGED
|
@@ -11,7 +11,7 @@ import { runJobTracked } from "../cron/runner.js";
|
|
|
11
11
|
import { parseDeliver } from "../cron/deliver.js";
|
|
12
12
|
import { isInstalled } from "../cron/install.js";
|
|
13
13
|
import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
14
|
-
import { homeWorkspaceActionError,
|
|
14
|
+
import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
|
|
15
15
|
const fmt = (ms) => (ms ? new Date(ms).toLocaleString() : "—");
|
|
16
16
|
const fmtNext = (ms, now) => (ms !== null && ms <= now ? "due now" : fmt(ms));
|
|
17
17
|
const duration = (ms) => {
|
|
@@ -87,7 +87,7 @@ registerTool({
|
|
|
87
87
|
if (action === "add") {
|
|
88
88
|
// Every new job persists ctx.cwd and later treats it as its implicit agent/shell workspace. Management
|
|
89
89
|
// of existing jobs remains available at Home, but creating a Home-root job would bypass project scope.
|
|
90
|
-
if (
|
|
90
|
+
if (isUnsafeProjectWorkspace(ctx.cwd))
|
|
91
91
|
return `Error: ${homeWorkspaceActionError("schedule a job")}`;
|
|
92
92
|
const scheduleStr = String(input.schedule ?? "").trim();
|
|
93
93
|
const task = String(input.task ?? "").trim();
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { limitToolResult } from "./result-limit.js";
|
|
2
|
-
import { homeWorkspaceActionError,
|
|
2
|
+
import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
|
|
3
3
|
/** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
|
|
4
4
|
* against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
|
|
5
5
|
* path/content mid-stream) — the loop rejects the call with a precise error instead of executing
|
|
@@ -28,7 +28,7 @@ export function registerTool(t) {
|
|
|
28
28
|
// Only explicitly project-scoped operations are blocked. Tool kinds are also used for safe
|
|
29
29
|
// management/delivery side effects, so a kind must not itself imply a project workspace requirement.
|
|
30
30
|
// Canonical comparison closes Home symlink aliases.
|
|
31
|
-
if (t.requiresProjectWorkspace &&
|
|
31
|
+
if (t.requiresProjectWorkspace && isUnsafeProjectWorkspace(ctx.cwd)) {
|
|
32
32
|
return `Error: ${homeWorkspaceActionError(`run ${t.name}`)}`;
|
|
33
33
|
}
|
|
34
34
|
return limitToolResult(await run(input, ctx));
|