@nanhara/hara 0.127.0 → 0.127.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/CHANGELOG.md +9 -0
- package/dist/index.js +24 -6
- package/dist/org-fleet/enroll.js +38 -1
- package/dist/profile/profile.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ 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.127.1 — 2026-07-20 — managed access lifecycle
|
|
9
|
+
|
|
10
|
+
- Managed enrollment now persists the control plane's authoritative device-token expiry. Hara warns
|
|
11
|
+
during the final 24 hours, reports the boundary in `whoami`/`enroll --status`, and fails closed with
|
|
12
|
+
a focused re-enrollment command once access expires or lifecycle data is corrupt.
|
|
13
|
+
- Serve provider settings expose only expiry metadata—not the token—so Hara Desktop can distinguish
|
|
14
|
+
an authenticated managed route from an expired one.
|
|
15
|
+
- Upgrade with `npm i -g @nanhara/hara@0.127.1`.
|
|
16
|
+
|
|
8
17
|
## 0.127.0 — 2026-07-20 — structured context and typed task lifecycle
|
|
9
18
|
|
|
10
19
|
- **Prompt assembly is now an explicit, ordered runtime layer.** Cache-stable policy, tool, project, and
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { notifyDone } from "./notify.js";
|
|
|
22
22
|
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
23
23
|
import { completionScript } from "./completions.js";
|
|
24
24
|
import { renderSessionMarkdown } from "./export.js";
|
|
25
|
-
import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fleet/enroll.js";
|
|
25
|
+
import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles, deviceTokenExpired, deviceTokenExpiryWarning, } from "./org-fleet/enroll.js";
|
|
26
26
|
import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
|
|
27
27
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
28
28
|
import { routingProvider } from "./agent/route.js";
|
|
@@ -134,7 +134,7 @@ async function buildProvider(cfg, targetOverride) {
|
|
|
134
134
|
// requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
|
|
135
135
|
const { profile: ap } = profileForConfig(cfg);
|
|
136
136
|
if (ap.kind === "gateway") {
|
|
137
|
-
if (!ap.gatewayUrl || !ap.deviceToken)
|
|
137
|
+
if (!ap.gatewayUrl || !ap.deviceToken || deviceTokenExpired(ap.tokenExpiresAt))
|
|
138
138
|
return null;
|
|
139
139
|
const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
140
140
|
const model = resolveGatewayModel(cfg, ap, process.env, targetOverride?.model);
|
|
@@ -193,8 +193,12 @@ async function buildGuardian(cfg, primary) {
|
|
|
193
193
|
}
|
|
194
194
|
function authHint(cfg) {
|
|
195
195
|
const { profile: ap } = profileForConfig(cfg);
|
|
196
|
-
if (ap.kind === "gateway")
|
|
196
|
+
if (ap.kind === "gateway") {
|
|
197
|
+
if (deviceTokenExpired(ap.tokenExpiresAt)) {
|
|
198
|
+
return `Active profile '${ap.id}' has expired organization access — re-enroll with \`hara profile add ${ap.id} --gateway ${ap.gatewayUrl || "<url>"} --code <code>\`.`;
|
|
199
|
+
}
|
|
197
200
|
return `Active profile '${ap.id}' is a gateway profile but is missing deviceToken — re-enroll with \`hara profile add ${ap.id} --gateway <url> --code <code>\`.`;
|
|
201
|
+
}
|
|
198
202
|
const target = resolveByokProviderTarget(cfg, ap, false);
|
|
199
203
|
const provider = target.provider;
|
|
200
204
|
if (provider === "qwen-oauth")
|
|
@@ -215,6 +219,7 @@ function providerSettingsSnapshot(targetCwd) {
|
|
|
215
219
|
const catalog = providerCatalog();
|
|
216
220
|
if (profile.kind === "gateway") {
|
|
217
221
|
const entry = catalog.find((candidate) => candidate.id === "hara-gateway");
|
|
222
|
+
const tokenExpired = deviceTokenExpired(profile.tokenExpiresAt);
|
|
218
223
|
return {
|
|
219
224
|
current: {
|
|
220
225
|
provider: "hara-gateway",
|
|
@@ -223,11 +228,12 @@ function providerSettingsSnapshot(targetCwd) {
|
|
|
223
228
|
location: entry.location,
|
|
224
229
|
auth: entry.auth,
|
|
225
230
|
keyConfigured: !!profile.deviceToken,
|
|
226
|
-
authenticated: !!profile.gatewayUrl && !!profile.deviceToken,
|
|
231
|
+
authenticated: !!profile.gatewayUrl && !!profile.deviceToken && !tokenExpired,
|
|
227
232
|
profileId: profile.id,
|
|
228
233
|
profileKind: profile.kind,
|
|
229
234
|
profileSource: resolution.source,
|
|
230
235
|
editable: false,
|
|
236
|
+
...(profile.tokenExpiresAt ? { tokenExpiresAt: profile.tokenExpiresAt, tokenExpired } : {}),
|
|
231
237
|
},
|
|
232
238
|
providers: catalog,
|
|
233
239
|
};
|
|
@@ -1664,6 +1670,11 @@ function printWhoami() {
|
|
|
1664
1670
|
out(c.dim(` device: ${p.deviceId.length > 8 ? "…" + p.deviceId.slice(-8) : p.deviceId}\n`));
|
|
1665
1671
|
if (p.availableModels?.length)
|
|
1666
1672
|
out(c.dim(` available: ${p.availableModels.join(", ")}\n`));
|
|
1673
|
+
if (p.tokenExpiresAt)
|
|
1674
|
+
out(c.dim(` expires: ${p.tokenExpiresAt}\n`));
|
|
1675
|
+
const warning = deviceTokenExpiryWarning(p.tokenExpiresAt);
|
|
1676
|
+
if (warning)
|
|
1677
|
+
out(c.yellow(` ⚠ ${warning}\n`));
|
|
1667
1678
|
}
|
|
1668
1679
|
else {
|
|
1669
1680
|
out(c.dim(` provider: ${p.provider}\n`));
|
|
@@ -1795,6 +1806,7 @@ profileCmd
|
|
|
1795
1806
|
defaultModel: e.model || "",
|
|
1796
1807
|
availableModels: e.model ? [e.model] : [],
|
|
1797
1808
|
enrolledAt: e.enrolledAt,
|
|
1809
|
+
tokenExpiresAt: e.expiresAt,
|
|
1798
1810
|
};
|
|
1799
1811
|
upsertProfile(p); // upsert: re-enrolling the same id rotates the token
|
|
1800
1812
|
const r = useProfile(id);
|
|
@@ -1982,8 +1994,8 @@ program
|
|
|
1982
1994
|
.option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
|
|
1983
1995
|
.action(async (gatewayUrl, opts) => {
|
|
1984
1996
|
if (opts.status) {
|
|
1985
|
-
|
|
1986
|
-
return
|
|
1997
|
+
printWhoami();
|
|
1998
|
+
return;
|
|
1987
1999
|
}
|
|
1988
2000
|
if (opts.clear) {
|
|
1989
2001
|
// Behavior change: don't *delete* the gateway profile (keeps the token around for re-use);
|
|
@@ -2010,6 +2022,7 @@ program
|
|
|
2010
2022
|
defaultModel: e.model || "",
|
|
2011
2023
|
availableModels: e.model ? [e.model] : [],
|
|
2012
2024
|
enrolledAt: e.enrolledAt,
|
|
2025
|
+
tokenExpiresAt: e.expiresAt,
|
|
2013
2026
|
};
|
|
2014
2027
|
upsertProfile(p);
|
|
2015
2028
|
useProfile(DEFAULT_ORG_ID);
|
|
@@ -3093,6 +3106,11 @@ program.action(async (opts) => {
|
|
|
3093
3106
|
// to suppress everywhere.
|
|
3094
3107
|
if (!opts.print && process.env.HARA_QUIET !== "1") {
|
|
3095
3108
|
out(c.dim(activeProfileLine(__activeP)) + "\n");
|
|
3109
|
+
const expiryWarning = __activeP.kind === "gateway"
|
|
3110
|
+
? deviceTokenExpiryWarning(__activeP.tokenExpiresAt)
|
|
3111
|
+
: null;
|
|
3112
|
+
if (expiryWarning)
|
|
3113
|
+
out(c.yellow(`⚠ ${expiryWarning}\n`));
|
|
3096
3114
|
}
|
|
3097
3115
|
if (__activeP.kind === "gateway" || cfg.provider === "hara-gateway") {
|
|
3098
3116
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
|
|
6
6
|
//
|
|
7
7
|
// Protocol (what `hara-control` implements on the other end):
|
|
8
|
-
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
|
|
8
|
+
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?, expires_at?}
|
|
9
9
|
// POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
|
|
10
10
|
// GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
|
|
11
11
|
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
@@ -45,6 +45,7 @@ export function loadEnrollment() {
|
|
|
45
45
|
model: ap.defaultModel || "",
|
|
46
46
|
baseURL: ap.baseURL,
|
|
47
47
|
enrolledAt: ap.enrolledAt || new Date().toISOString(),
|
|
48
|
+
expiresAt: ap.tokenExpiresAt,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -70,6 +71,14 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
|
70
71
|
const deviceToken = (j.device_token ?? j.deviceToken);
|
|
71
72
|
if (!deviceToken)
|
|
72
73
|
throw new Error("enroll response missing device_token");
|
|
74
|
+
const rawExpiresAt = j.expires_at ?? j.expiresAt;
|
|
75
|
+
let expiresAt;
|
|
76
|
+
if (rawExpiresAt !== undefined && rawExpiresAt !== null) {
|
|
77
|
+
if (typeof rawExpiresAt !== "string" || !Number.isFinite(Date.parse(rawExpiresAt))) {
|
|
78
|
+
throw new Error("enroll response contains an invalid expires_at");
|
|
79
|
+
}
|
|
80
|
+
expiresAt = new Date(rawExpiresAt).toISOString();
|
|
81
|
+
}
|
|
73
82
|
return {
|
|
74
83
|
gatewayUrl: gatewayUrl.replace(/\/$/, ""),
|
|
75
84
|
deviceToken,
|
|
@@ -77,8 +86,36 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
|
77
86
|
model: String(j.model ?? ""),
|
|
78
87
|
baseURL: (j.base_url ?? j.baseURL),
|
|
79
88
|
enrolledAt: now,
|
|
89
|
+
expiresAt,
|
|
80
90
|
};
|
|
81
91
|
}
|
|
92
|
+
/** Legacy control planes did not advertise token expiry, so absence remains compatible. New control
|
|
93
|
+
* planes provide it and the CLI can fail early with an actionable re-enrollment message. */
|
|
94
|
+
export function deviceTokenExpired(expiresAt, now = new Date()) {
|
|
95
|
+
if (!expiresAt)
|
|
96
|
+
return false;
|
|
97
|
+
const expiryMs = Date.parse(expiresAt);
|
|
98
|
+
// A present-but-corrupt lifecycle boundary must not silently become a legacy non-expiring token.
|
|
99
|
+
return !Number.isFinite(expiryMs) || expiryMs <= now.getTime();
|
|
100
|
+
}
|
|
101
|
+
/** Only warn near the boundary; healthy week-long tokens should not add startup noise. */
|
|
102
|
+
export function deviceTokenExpiryWarning(expiresAt, now = new Date()) {
|
|
103
|
+
if (!expiresAt)
|
|
104
|
+
return null;
|
|
105
|
+
const expiryMs = Date.parse(expiresAt);
|
|
106
|
+
if (!Number.isFinite(expiryMs))
|
|
107
|
+
return "organization access expiry is unreadable; re-enroll this profile";
|
|
108
|
+
const remainingMs = expiryMs - now.getTime();
|
|
109
|
+
if (remainingMs <= 0)
|
|
110
|
+
return "organization access expired; re-enroll this profile before running a task";
|
|
111
|
+
if (remainingMs > 24 * 60 * 60_000)
|
|
112
|
+
return null;
|
|
113
|
+
const remainingMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
|
|
114
|
+
const remaining = remainingMinutes < 60
|
|
115
|
+
? `${remainingMinutes}m`
|
|
116
|
+
: `${Math.ceil(remainingMinutes / 60)}h`;
|
|
117
|
+
return `organization access expires in ${remaining}; ask your admin for a new enrollment code`;
|
|
118
|
+
}
|
|
82
119
|
/** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
|
|
83
120
|
export async function enrollDevice(gatewayUrl, code, signal) {
|
|
84
121
|
const base = gatewayUrl.replace(/\/$/, "");
|
package/dist/profile/profile.js
CHANGED
|
@@ -98,6 +98,7 @@ function readDefaultOrgFromOrgJson(e) {
|
|
|
98
98
|
defaultModel,
|
|
99
99
|
availableModels: defaultModel ? [defaultModel] : [],
|
|
100
100
|
enrolledAt: e.enrolledAt || new Date().toISOString(),
|
|
101
|
+
tokenExpiresAt: typeof e.expiresAt === "string" ? e.expiresAt : undefined,
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
104
|
/** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
|