@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 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
- const p = loadActiveProfile();
1986
- return void out(p.kind === "gateway" ? c.green("enrolled") + c.dim(` · ${p.gatewayUrl} · device ${p.deviceId || "?"} · model ${effectiveModel(p) || "(gateway default)"} · since ${p.enrolledAt || "?"}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
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
@@ -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(/\/$/, "");
@@ -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). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.127.0",
3
+ "version": "0.127.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"