From 9c122a312face64367a8d0578a93c2e4e3eb1f86 Mon Sep 17 00:00:00 2001 From: Paolo Donadeo Date: Sat, 18 Apr 2026 19:17:44 +0200 Subject: [PATCH] feat(security,timer): harden auth and add multi-instance coordination - Extract JASPER_URL into a shared constants module - Pass login credentials via a temp curl config file to avoid exposure in the process argument list (ps/proc) - Replace vim.ui.input secret prompt with vim.fn.inputsecret() - Add -s (silent) flag to all curl calls to suppress progress output - Guard curl output parser against missing newline in stdout - Track per-activity shared timestamp file (/tmp/jasper_.last_activity) so the inactivity watchdog skips auto-pause when another Neovim instance is still active on the same task - Clean up leftover uv_timer on repeated begin_tracking calls - Remove shared activity file on teardown only when this instance wrote it --- lua/jasper/api.lua | 8 +++- lua/jasper/auth.lua | 54 +++++++++++++++---------- lua/jasper/constants.lua | 6 +++ lua/jasper/timer.lua | 86 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 lua/jasper/constants.lua diff --git a/lua/jasper/api.lua b/lua/jasper/api.lua index 93ac3e7..d1d97b7 100644 --- a/lua/jasper/api.lua +++ b/lua/jasper/api.lua @@ -3,7 +3,8 @@ local M = {} -local JASPER_URL = "https://jasper.4sigma.it" +local constants = require("jasper.constants") +local JASPER_URL = constants.JASPER_URL --- Run a curl command and return the parsed JSON body + HTTP status code. --- @param args string[] full curl argument list (without the URL, which is last) @@ -22,6 +23,9 @@ local function curl(args) -- Split body and status code local last_newline = result.stdout:match(".*\n()") + if not last_newline then + return nil, 0, "curl: unexpected output format (no newline)" + end local status_code = tonumber(result.stdout:sub(last_newline)) or 0 local body = result.stdout:sub(1, last_newline - 2) -- strip trailing \n + status line @@ -38,6 +42,7 @@ end local function get(path, token) return curl({ "curl", + "-s", "-H", "Authorization: Token " .. token, JASPER_URL .. path, @@ -51,6 +56,7 @@ end local function post(path, token, form) local args = { "curl", + "-s", "-X", "POST", "-H", diff --git a/lua/jasper/auth.lua b/lua/jasper/auth.lua index 6bb08cb..6e903f9 100644 --- a/lua/jasper/auth.lua +++ b/lua/jasper/auth.lua @@ -4,7 +4,8 @@ local M = {} -local JASPER_URL = "https://jasper.4sigma.it" +local constants = require("jasper.constants") +local JASPER_URL = constants.JASPER_URL --- @return string path to the token cache file local function token_file_path() @@ -46,16 +47,29 @@ end --- @return string|nil token --- @return string|nil error message function M.login(username, password) + -- Write credentials to a temp curl config file so they never appear in + -- the process argument list (ps aux / /proc//cmdline). + local tmpfile = os.tmpname() + local cf = io.open(tmpfile, "w") + if not cf then + return nil, "cannot create temp file for curl config" + end + cf:write('--data-urlencode "username=' .. username .. '"\n') + cf:write('--data-urlencode "password=' .. password .. '"\n') + cf:close() + local result = vim.system({ "curl", "-s", - "--data-urlencode", - "username=" .. username, - "--data-urlencode", - "password=" .. password, + "-X", + "POST", + "--config", + tmpfile, JASPER_URL .. "/api/v1/token/login/", }, { text = true }):wait() + os.remove(tmpfile) + if result.code ~= 0 then return nil, "curl error (exit " .. result.code .. ")" end @@ -84,21 +98,21 @@ function M.prompt_login(callback) callback(nil) return end - vim.ui.input({ prompt = "Jasper password: ", secret = true }, function(password) - if not password or password == "" then - vim.notify("[Jasper] Login cancelled", vim.log.levels.WARN) - callback(nil) - return - end - local token, err = M.login(username, password) - if err or not token then - vim.notify("[Jasper] " .. (err or "Login failed"), vim.log.levels.ERROR) - callback(nil) - return - end - vim.notify("[Jasper] Login successful", vim.log.levels.INFO) - callback(token) - end) + -- vim.fn.inputsecret() is native Neovim: hides typed characters in all environments + local password = vim.fn.inputsecret("Jasper password: ") + if not password or password == "" then + vim.notify("[Jasper] Login cancelled", vim.log.levels.WARN) + callback(nil) + return + end + local token, err = M.login(username, password) + if err or not token then + vim.notify("[Jasper] " .. (err or "Login failed"), vim.log.levels.ERROR) + callback(nil) + return + end + vim.notify("[Jasper] Login successful", vim.log.levels.INFO) + callback(token) end) end diff --git a/lua/jasper/constants.lua b/lua/jasper/constants.lua new file mode 100644 index 0000000..78a158b --- /dev/null +++ b/lua/jasper/constants.lua @@ -0,0 +1,6 @@ +--- jasper/constants.lua +--- Shared constants used across the plugin. + +return { + JASPER_URL = "https://jasper.4sigma.it", +} diff --git a/lua/jasper/timer.lua b/lua/jasper/timer.lua index 95cff76..59390f6 100644 --- a/lua/jasper/timer.lua +++ b/lua/jasper/timer.lua @@ -14,10 +14,81 @@ local state = { running = false, -- inactivity_ms: milliseconds of silence before the timer is auto-paused inactivity_ms = 10 * 60 * 1000, + -- last epoch (seconds) this instance wrote to the shared activity file + last_activity_time = nil, ---@type number|nil uv_timer = nil, ---@type uv.uv_timer_t|nil augroup = nil, ---@type number|nil } +-- --------------------------------------------------------------------------- +-- Shared-activity timestamp (multi-instance coordination) +-- --------------------------------------------------------------------------- + +--- Path of the shared last-activity file for the current attivita_id. +--- @return string|nil +local function activity_file_path() + if not state.attivita_id then + return nil + end + return "/tmp/jasper_" .. tostring(state.attivita_id) .. ".last_activity" +end + +--- Write the current epoch (seconds) to the shared activity file. +local function touch_activity_file() + local path = activity_file_path() + if not path then + return + end + local now = os.time() + local f = io.open(path, "w") + if f then + f:write(tostring(now)) + f:close() + state.last_activity_time = now + end +end + +--- Return true if another instance has recorded activity within `inactivity_ms`. +local function other_instance_active() + local path = activity_file_path() + if not path then + return false + end + local f = io.open(path, "r") + if not f then + return false + end + local raw = f:read("*l") + f:close() + local ts = tonumber(raw) + if not ts then + return false + end + local elapsed_ms = (os.time() - ts) * 1000 + return elapsed_ms < state.inactivity_ms +end + +--- Remove the shared activity file on exit, but only if this instance was +--- the last to write it. If another instance wrote a newer timestamp, leave +--- the file intact so the other instance's watchdog keeps working correctly. +local function cleanup_activity_file() + local path = activity_file_path() + if not path or not state.last_activity_time then + return + end + local f = io.open(path, "r") + if not f then + return + end + local raw = f:read("*l") + f:close() + local ts = tonumber(raw) + -- Only remove if the timestamp on disk matches what we wrote last + if ts and ts == state.last_activity_time then + os.remove(path) + end +end + -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- @@ -47,6 +118,12 @@ local function arm_inactivity_watchdog() if not state.running then return end + -- Don't pause if another instance with the same attivita_id is still active + if other_instance_active() then + notify("Inactivity detected, but another instance is active — keeping timer running") + arm_inactivity_watchdog() + return + end local ok, err = api.stop_timer(state.token, state.timer_id) if ok then state.running = false @@ -104,6 +181,7 @@ function M.on_activity() if not state.timer_id then return end + touch_activity_file() if not state.running then -- Resume after an inactivity pause @@ -131,6 +209,13 @@ local function begin_tracking(token, timer_id) state.token = token state.timer_id = timer_id + -- Close any leftover uv_timer from a previous (unexpected) call + if state.uv_timer then + state.uv_timer:stop() + state.uv_timer:close() + state.uv_timer = nil + end + -- Create the libuv timer used for inactivity detection state.uv_timer = vim.uv.new_timer() @@ -209,6 +294,7 @@ function M.teardown() state.uv_timer:close() state.uv_timer = nil end + cleanup_activity_file() if state.running and state.timer_id then local ok, err = api.stop_timer(state.token, state.timer_id) if ok then